面试 | JVM 类加载机制 13 问

2023-10-24 15:21:49 浏览数 (1)

原文链接 https://juejin.cn/post/7292955856545972275

1. 类加载机制

JVM(Java虚拟机)的类加载主要分为以下几个阶段:加载、验证、准备、解析、初始化,以及卸载。

  1. 加载(Loading):
代码语言:txt复制
加载阶段,主要完成了以下三个步骤:
  • 加载.class文件:将.class文件加载到内存中,获取二进制数据流,此步骤可能通过网络传输、文件系统、或者是zip包等途径。
  • 将二进制数据流转换成Class对象:ClassLoader(类加载器)负责将.class文件中的二进制数据转换成Class对象。
  • 加载本地方法库:同时将Class对象依赖的本地库加载到内存中。
  1. 验证(Verification):

验证阶段是为了确保加载的类信息符合Java虚拟机的规范。这个过程要确定Class文件中的字节流代码是符合JVM规格的,主要完成以下检查:

  • 文件格式验证:验证文件头,是否包含魔数(Magic Number),主次版本号等。
  • 元数据验证:验证常量池的结构以及字段、方法的语义。
  • 字节码验证:验证.class文件的字节码正确性,保证JVM能够正常执行。
  • 符号引用验证:验证类、方法、属性的引用是否合法。
  1. 准备(Preparation):

在准备阶段,JVM为类静态变量分配存储空间(主要是类变量和静态变量)并设置默认初始化值。注意:这里的初始化值是指数据类型的初始值,而不是用户自定义的初始值。但是如果是 static final 的话,这里赋值的是 final 定义的值

  1. 解析(Resolution):

解析阶段的主要任务是将类的符号引用(常量池中的符号)替换为内存中的直接引用。简单来说,就是将类似于字符串的引用地址,转换成实际内存地址。

  1. 初始化(Initialization):

在初始化阶段,JVM对类的变量进行赋值以及静态代码块的执行。与准备阶段的区别是,这里会将用户自定义的初始值进行赋值,同时执行静态代码块。

  1. 卸载(Unloading):

当一个类不再被使用时,JVM会将其卸载,回收内存空间。这一过程由垃圾回收(Garbage Collection)进行。

了解类加载阶段,我们还需要了解Java中的类加载器(ClassLoader)机制。主要有以下几类:

  • 引导(Bootstrap)类加载器:这是最顶层的类加载器,用于加载JDK内部的类库,如rt.jar等。
  • 扩展(Extension)类加载器:加载位于jre/lib/ext目录下的类库。
  • 应用(Application)类加载器:加载用户程序及第三方类库。
  • 自定义类加载器:用户自定义的类加载器,主要用于满足特殊需求。

类加载器之间具有双亲委派(Parent Delegation)模型。简单来说,就是在加载一个类时,会首先委托父加载器进行加载。如果父加载器无法完成加载任务,子加载器才会尝试进行加载。这个机制保证了子类加载器不会重复加载已经被父类加载器加载过的类。

2. 什么时候会用到扩展类加载器?

当需要加载一些JDK提供的扩展库时,扩展类加载器就会被用到。扩展类加载器主要负责加载JRE(Java Runtime Environment)扩展目录(jre/lib/ext)下的类库,例如Java的一些扩展API功能。

扩展类加载器的使用场景包括但不限于:

  • Java提供的一些扩展功能,如Java通信API(Java Communication API),Java安全API(Java Cryptography Extension, JCE)等
  • 用户自定义的扩展组件库,将这些库放置在扩展目录下,可以让Java程序自动加载这些扩展组件
  • 当需要将一些共享的类库与标准类库分开,以便于程序的分层与解耦时,可以考虑使用扩展类加载器

需要注意的是,使用扩展类加载器加载的扩展库在程序中必须遵循双亲委派模型。另外,应谨慎使用扩展类加载器,因为扩展库通常是全局共享的,可能导致类库版本冲突等问题。如果有需要,也可以考虑使用自定义类加载器进行加载。

3. 双亲委派机制?

代码语言:text复制
  protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
      // First, check if the class has already been loaded
      Class<?> c = findLoadedClass(name);
      if (c == null) {
          try {
              if (parent != null) {
                  c = parent.loadClass(name, false);
              } else {
                  c = findBootstrapClassOrNull(name);
              }
          } catch (ClassNotFoundException e) {
              // ClassNotFoundException thrown if class not found
              // from the non-null parent class loader
          }

          if (c == null) {
              // If still not found, then invoke findClass in order
              // to find the class.
              c = findClass(name);
          }
      }
      return c;
  }

4. 为什么 Java 要给 Class 提供获取类加载器的方法,而不是直接通过实例化类加载器?

Java提供了获取类的类加载器的方法(getClassLoader()),而不是直接实例化一个类加载器来加载类,主要有以下原因:

  1. 维护类加载器层次结构:Java类加载器遵循双亲委派模型,即在加载一个类时,检查类加载器是否已经加载过这个类。如果没有,它会委托给父类加载器进行加载。这个过程利用了已存在的类加载器层次结构,提高了加载性能,避免了类的重复加载。通过让类公开它们的类加载器,可以在需要时沿着类加载器层次结构向上查找,而无需创建额外的类加载器实例。
  2. 避免类加载冲突:不同的类加载器可能加载不同版本的类库。如果直接实例化一个新的类加载器来加载类,可能会导致类库的版本冲突和不一致。通过获取类的类加载器,可以确保在存在多个版本的类库时,始终使用与当前对象相同版本的类库。
  3. 资源和内存管理:直接实例化多个类加载器会导致额外的资源和内存开销。通过获取类的类加载器,可以减少创建新的类加载器实例的需求,并在多个对象共享相同的类加载器的情况下,允许重用已经加载的类,从而提高了性能和内存利用率。
  4. 安全性:获取类加载器确保了Java程序的安全性,因为JVM可以为不同的类加载器分配不同的权限。这有助于防止恶意代码执行不受控制的操作,例如加载和执行未经授权的类。

综上所述,Java为类提供了获取它的类加载器的方法,以维护类加载器层次结构,避免类加载冲突,提高资源和内存管理效率,以及确保程序的安全性。与直接实例化一个类加载器相比,这种方法对于处理类加载和管理方面更为灵活且高效。

5. 类加载器中会保存所有已经加载过的类吗?

是的,类加载器具有一个称为“类缓存”的内部数据结构,用于存储已经加载过的类。当加载一个新类时,类加载器会首先查找类缓存以确定是否已经加载过这个类。如果已经加载过这个类,类加载器就会直接从类缓存中返回这个类。这样做可以避免重复加载类,提高加载性能。

6. 是不是意味着一个Java程序里每种类加载器只有一个实例?

不一定。Java程序中每种类加载器类型通常只有一个实例,特别是对于引导类加载器、扩展类加载器和应用程序类加载器。但这并不是一个确定的规则。有时,你可能需要创建多个自定义类加载器的实例,以满足特定的需求(例如,加载不同路径或不同版本的类库等)。

7. 什么时候我们需要主动去加载一个类?

主动加载一个类通常用于以下几个场景:

  • 动态加载:当你需要在运行时动态加载某个类,而不能在编译时静态确定这个类时,需要主动加载这个类。例如:动态代理、插件化框架等场景。
  • 初始化时执行静态代码块:如果你想在某个特定时刻执行一个类的静态代码块,但是这个类可能还没有被加载和初始化,这时你可以主动加载这个类,触发静态代码块的执行。
  • 避免类加载顺序问题:在某些复杂应用中,类加载顺序可能对程序的执行有影响。这种情况下,你可以通过主动加载类来确保某个类被加载和初始化,以避免潜在的类加载顺序问题。

总之,当你需要控制类加载的时间和顺序,执行静态代码块,或者动态加载某个类时,需要主动加载类。

8. 加载一个类是不是意味着一定会实例化这个类,加载类和实例化类的区别是什么?

加载一个类并不意味着一定会实例化这个类。类加载是指将类的.class文件(字节码文件)读取到内存中,并生成相应的 Class 对象。实例化类是指通过调用类的构造器创建类的对象。

加载类主要涉及类的字节码文件的读取、解析和验证等过程。而实例化类涉及类的对象的创建、字段的初始化以及构造方法的调用等过程。加载类主要由类加载器(ClassLoader)负责,实例化类由 Java 虚拟机负责。

9. 一个类被加载之后的产物是什么?

一个类被加载之后,它的产物是一个与该类对应的 Class 对象。这个 Class 对象包含了类的元数据(如类名、方法、字段等信息),同时也是类对象的创建和类的静态方法、静态字段的访问入口。你可以通过 Class.forName("com.example.MyClass") 或者 ClassLoader.loadClass("com.example.MyClass") 等方法获取类的 Class 对象,然后通过这个 Class 对象来实例化类、访问静态方法及静态字段等操作。

10. Class.forName() 和 ClassLoader.loadClass() 的区别?

Class.forName(String)ClassLoader.loadClass(String) 都用于加载类,但它们有以下区别:

  • 初始化过程:Class.forName(String) 在加载类之后会立即对类进行初始化(执行静态代码块和静态变量的赋值操作),而 ClassLoader.loadClass(String) 方法只会加载类,但不会执行初始化操作,只有在实际使用时,才会触发类的初始化。
  • 类加载器:Class.forName(String) 方法默认使用调用者所在类的类加载器,可以通过 Class.forName(String, boolean, ClassLoader) 指定类加载器。ClassLoader.loadClass(String) 由调用者显式指定类加载器。

使用场景:

  • 如果需要在加载类的同时执行类的静态代码块和静态变量的赋值操作,那么可以使用 Class.forName(String) 方法。
  • 如果希望延迟执行类的初始化操作,只在需要时执行,可以使用 ClassLoader.loadClass(String) 方法。

11. 类加载的最后一步流程不是初始化吗?ClassLoader.loadClass 是怎么做到只加载类,不初始化的?

是的,类加载的最后一步流程是初始化。但ClassLoader.loadClass(String)方法在加载类的时候,不会立即执行类的初始化操作。

Java的类加载器在加载类时,会遵循"延迟加载"(Lazy Loading)原则,即只在实际需要时才进行加载和初始化。ClassLoader.loadClass(String) 方法在加载类时,只会完成加载、验证、准备、解析这四个阶段,而类的初始化操作会被推迟到实际使用时进行。

当你用ClassLoader.loadClass(String)加载一个类时,只有在以下情况之一发生时,才会触发类的初始化:

  • 创建类的实例(通过 new 关键字或者反射 API)
  • 访问类的静态变量(读取或修改)
  • 调用类的静态方法
  • 反射调用 Class.forName() 方法

12. 可以在子线程加载类吗?类加载是线程安全的吗?

可以在子线程中加载类。类加载器有一个内部机制来确保多线程环境下类加载的线程安全。当一个类被加载时,类加载器会获取一个与请求的类关联的内部锁。这意味着,当多个线程试图加载相同的类时,只有一个线程能够获得锁并进行加载。其他线程将等待,直到类加载完成并释放锁。因此,类加载过程是线程安全的。

13. 类加载过程的初始化和实例化过程的初始化有什么区别?

类加载过程的初始化(Initialization)主要包括执行静态代码块和为静态变量赋值。在这个阶段,JVM会确保类的静态代码块只被执行一次,静态变量只会被赋值一次。类加载过程的初始化主要涉及类级别的操作,且只会在类第一次使用时进行。

实例化过程的初始化是指创建类的对象(实例)时,调用类的构造器和为对象的实例变量赋值。实例化过程的初始化是针对对象(实例)级别的操作,对于每个新创建的对象,都会进行实例化过程的初始化。

总结一下,类加载过程的初始化关注类的静态部分(静态变量、静态代码块),而实例化过程的初始化关注类的对象部分(实例变量、构造器)。

14. 以下代码,A.value 会输出什么

代码语言:java复制
class A {
    public static int value = B.value   1;
}

class B {
    public static int value = A.value   1;
}

以上代码并不会导致循环依赖,A.value 输出 2

  1. 当调用 A.value 的时候,先加载类 A,加载-验证-准备-解析-会执行静态成员 value 的赋值
  2. 由于 A.value = B.value 1, 所以会开始加载类 B,加载-验证-准备-解析-会执行 B 的 value 的赋值
  3. 当走到 B 的 value = A.value 1 的时候,因为类 A 已经完成了类 A 的加载、验证、准备、解析阶段,A.value 此时的值是默认值为 0,所以 B.value = 0 1 = 1,B 完成类加载流程
  4. 回到类 A 的初始化阶段,此时 B.value = 1, 所以 A.value = 1 1 = 2

0 人点赞