HotSpot JVM 「01」类加载、链接和初始化

182 阅读7分钟

Loading / Linking /Initializing

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第14天,点击查看活动详情

01-Loading | 查找并创建

加载指查找二进制表示并据此创建类或接口的过程。

注:这里查找的是 .class 文件的二进制表示(可以在文件系统、也可以通过网络等任何方式,具体由类加载器实现决定);创建的是 OOP/Klass 模型(即 Java 类在内存中的表示,实际上是 C++ 中的结构体)。 关于 OOP / Klass 模型,具体参考:[1, 2]

什么时候会触发类或接口的 Loading ?

  1. 被另一个类的运行时常量池引用。
  2. 使用反射等特殊的方法,显式地加载,例如 Class.forName 、classLoader.loadClass 、JNI_FindClass等。
  3. JVM 启动时,会加载一些特定的类,例如 Object 、Thread 类等。

注:这里说的类或接口指的是普通的类或接口,而不是数组类。前者有外部的二进制表示,通过类加载器加载到 JVM 的方法区;而后者没有外部的二进制表示,它是完全由 JVM 创建的,而不通过类加载器。

类加载器分为两种(从 JVM 角度):

  1. bootstrap
  2. user-defined,所有此类的类加载器都是ClassLoader的子类。注:但并非通过继承实现父子关系,而是通过组合。

谈到 JVM 中类加载器的分类,不得不谈一下所谓的双亲委派模型:

  • 在 HotSpot JVM 实现中,所有的类加载器之间是有层级关系的,即除 bootstrap 外,其他的类加载器都是有一个委派双亲(delegation parent)。(注:实现父子关系并非通过继承)
  • 当向某个类加载器请求加载一个类时,该类加载器会先委派其双亲帮其加载。若双亲不能加载,则自己尝试加载。若自己仍不能加载,则抛异常。
  • bootstrap 负责加载 $JAVA_HOME/lib 、-Xbootclasspath 路径下的类
  • user-defined 可进一步细分为:extension 类加载器和 system 类加载器
    • extension 负责加载 $JAVA_HOME/ext/lib 、java.ext.dir 路径下的类
    • system 负责加载 main 方法所在类、classpath 路径下的类(注:默认的应用类加载器)
  • 破坏双亲委派模型的实例,更具体的信息参考[1]
    1. SPI 服务实现加载
    2. Tomcat WebAppClassLoader

找到类的二进制表示后,接下来就是要在内存中(Java 7 及之前是在 PermGen 区,Java 8 之后,是在 Metaspace 中)创建instanceKlassarrayKlass

HotSpot 中维护了三个哈希表,来追踪类加载过程,表由 SystemDictionary_lock 锁保护。

  1. SystemDictionary,记录已加载的类,<name, initiating loader> → klassOop 和 <name, defining loader> → klassOop
  2. PlaceholderTable,记录当前正在被加载的类,用来做ClassCircularityError检查和多线程parallel加载
  3. LoaderConstraintTable,记录类型安全性检查的约束

[1] VM Class Loading [2] 类加载器

02-Linking | 使能执行

Linking 将加载的类或接口与 JVM 的运行时状态相结合,使类或接口能执行。此外,链接也涉及类或接口中符号引用的解析。

链接包括几个步骤:

  1. 验证,主要确保载入的二进制表示结构上的正确性,可导致其他类或接口的加载。
    • 在验证阶段,JVM需要完成如下工作:
      • 文件格式验证。经过了这个阶段的验证,字节流才会加载到方法区(Metaspace)。
      • 元数据验证。对方法区(Metaspace)中的结构进行语义分析,确保符合Java语言规范。
      • 字节码验证。进行数据流、控制流分析,确保被校验的类的方法在执行时不会危害JVM安全。
      • 符号引用验证。发生在JVM将符号引用转化为直接引用的时候,发生在解析阶段。可以看作是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验的过程。
  2. 预处理,创建类或接口中的 static fields,并初始化为默认值(例如0),此阶段不会执行任何 JVM 指令。
    • 示例

      public static int value = 123;
      // 预处理阶段,value对应的内存空间值为0,而非123,
      // 值在初始化阶段才会被置为123
      public static final int VALUE = 123;
      // 对于常量,预处理阶段,VALUE对应的值为123,而非默认值0
      
  3. (symbolic reference)解析,为运行时常量池中的符号引用动态地确定具体值。
    • 符号引用可能指向几种类型:
      • 类或接口
      • a method
      • a method type
      • a method handle
      • 动态计算的常量
    • method 、method handle 和 method type 有什么区别?更具体参考[1]
      • method 是常规意义上理解的方法
      • method handle 是 java 7 引入的概念,它是对可执行的方法(method)的引用,或者说,它是一个有能力安全调用方法的对象。可以通过句柄直接调用句柄引用(或指向)的底层方法。
      • method type 是表示方法签名类型的不可变对象。每个方法句柄都有一个 method type 实例,用来指示方法的返回值类型和参数类型。

03-Initialization | 调用<clinit>方法

Initialization 指执行类或接口的初始化方法的过程。导致类或接口初始化的场景有如下几种:

  1. JVM 指令 new / getstatic / putstatic / invokestatic 引用了某个类或接口,导致该类初始化。
  2. The first invocation of a java.lang.invoke.MethodHandle instance。
  3. 调用类库中的反射方法,例如 Class 类中的某些方法或 java.lang.reflect 包中某些方法。
  4. 子类被初始化,导致父类的初始化。
  5. 若接口中声明了非抽象、非静态方法,则实现该接口的类初始化将导致接口的初始化。
  6. JVM 启动时,指定实例化的类或接口。
  • 初始化过程是如何的?
    1. 对于每个类或接口C来说,存在唯一的初始化锁LC。对C进行初始化,需要首先获得LC。
    2. 如果C正在被某些其他线程初始化,则释放LC,阻塞当前线程,直到收到C初始化完成的通知,从1.重新开始。
    3. 如果C正在被当前线程初始化,必定是递归请求,释放LC,退出初始化过程。
    4. 如果C已初始化完成,释放LC,正常退出初始化过程。
    5. 如果C处于错误状态,初始化不可能完成,释放LC,抛NoClassDefFoundError
    6. 否则,记录C正在被当前线程初始化,释放LC。
    7. 接下来,如果C是类而非接口,那么它的超类、超接口(包含至少一个非抽象、非静态方法)如果存在仍未被初始化的情况,则递归地进行初始化。如果在初始化超类的过程中抛异常了,则获得LC,标记C为错误状态,并通知其他阻塞在LC上的线程,释放LC,异常退出C的初始化过程。
    8. 接下来,determine whether assertions are enabled for C by querying its defining class loader.
    9. 接下来,执行C的初始化方法。
    10. 如果步骤9正常结束,请求获取LC,标记C为初始化完成,通知所有阻塞在LC上的线程,释放LC,正常退出C的初始化过程。
    11. 否则,即步骤9异常退出,且抛出Error或其子类的异常E,则以E为参数创建ExceptionInInitializerError
    12. 请求获取LC,标记C为异常状态,通知所有阻塞在LC上的线程,释放LC,抛出异常。
  • 初始化阶段才真正开始执行类或接口中定义的字节码,即执行类的 方法的过程:
    • 方法由 JVM 自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。
    • 方法与类的构造器 方法不同,不需要显示地调用父类的 方法,而由 JVM 保证执行子类 方法时,父类的已执行完毕。所以,在 JVM 中执行的第一个 方法是 java.lang.Object 的 方法。
    • 接口也可以拥有 方法,但与类不同的是,执行接口的 方法不需要先执行其父类的 方法,只有父接口中定义的变量被使用时才会对父接口初始化。同样,接口的实现类在初始化时,不会执行接口的 方法
    • 不是必须的,若类或接口中无静态变量,则不会为该类或接口生成 方法
    • JVM 会保证一个类的 方法只会有一个线程在执行,其他试图初始化该类的线程都会被阻塞

04-总结

01-03 步骤之间有一些基本的准则需要遵循,例如:

  1. 链接之前,类或接口必须保证已完全载入。
  2. 初始化之前,类或接口必须保证已完成验证、预处理(即链接)。