大部分内容从《周志明:深入理解 Java 虚拟机》整理而来。

类加载的目标

使一个类是完全可用的(判断子类继承,接口实现等元信息可以执行定义的代码,包括实例和静态方法,属性表中的 code,以及访问类变量)

  • 加载:将字节流转换为 jvm 可读的数据结构,为之后的步骤提供支持
  • 验证:在 jvm 可读的数据结构之上检测这个类的合法性(和解析阶段有混合)
  • 准备:主要是初始化静态变量为 0 值
  • 解析:将字面量的引用转化为内存地址形式的直接引用
  • 初始化:静态初始化块的执行

类加载时机

一个 .class 文件在 Jvm 中的生命周期大致如下(解析不一定在初始化之前,也可能在初始化之后开始)。

类加载的 6 个时机

  1. 在遇到字节码 new, getstatic, putstatic, invokestatic 时,如果相应的类型没有被初始化,那么将会触发相应类型的初始化。
    具体来说是使用 new 创建对象时,读写类静态字段时(final 修饰的字段除外,这些字段会在编译期就放到 .class 文件中的常量池中),调用类静态方法是。
  2. 使用反射,且相应的类没有被初始化。
  3. 初始化(这里是上面 .class 文件的生命周期中的初始化)时,父类没有初始化。
  4. 虚拟机启动时指定的主类。
  5. Java 动态语言支持特性中解析出来的相应的类没有被初始化。
  6. 定义了默认实现的接口(default 关键字修饰)的实现类发生初始化时。

3 个反例

在下面的例子中 JVM 不会进行类加载。

1.引用父类的静态字段
class SuperClass {
	static {
    	System.out.println("SuperClass Init!");
    }
    public static int value = 1;
}

class SubClass extends SuperClass{
	Static {
    	System.out.println("SubClass Init!");
    }
}

public class Main {
	public static void main(String[] args) {
    	System.out.println(SubClass.value);
    }
}

即使在同一个文件中定义多个类,在 javac 编译后仍会生成多个 .class 文件。

上述代码只会输出 "SuperClass Init!"

2.定义一个类型的数组

定义数组不会触发一个类型的初始化

TargetClass[] refArray = new TargetClass[8]

带有数组初始化的语句也是,Java 将这个类的数组单独作为一个类进行加载,而不会加载这个类(某个类的数组也是一个类,jvm 中一个匿名的类)。

3.引用 final 修饰的字段

我们知道 final 修饰的字段将被放在 .class 文件的常量池中,那为什么不需要加载 final 所在的类即可引用呢?

原因在于编译阶段的常量传播优化会将类引用的别的类的 final 字段放入当前类的常量池中,运行时实际是对自身常量池字段的引用。

类加载的步骤

1.加载

加载作为类加载中的一个步骤,完成以下 3 件事

  1. 通过一个类的全限定名获取需要加载的类的二进制字节流。
  2. 将获取的字节流转化为方法区中的运行时数据结构。
  3. 在堆中创建相应的 java.lang.Class 对象,作为方法去这个类的各种数据的访问入口。

由之前的反例,数组作为一个类和数组元素的类相区别,本身并不是由类加载器创建,而是由 JVM 动态创建的。

由于一个类必须确定其自身的类加载器,数组类同样与类加载器相关。

数组的类根据元素的类型分为两类:1.引用类型的数组;2.基本数据类型的数组。引用类型的数组将被标识在加载元素类型的类加载器的类名称空间上(考虑多维数组的存在,这个过程是递归的)。

基本数据类型的数组将被标识在引导类加载器(jvm 内置的类加载器)的类名称空间上。

引用类型的数组将被标识在该数组的上一维的类型(比如 MyClass[][] 的上一维的类型为 MyClass[])的类加载器的类名称空间上,这个过程是递归的。

数组类的可访问性与元素的类型一致。如果是基本数据类型,则为 public。

加载阶段结束后,Java 虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了(由 JVM 的实现自行定义)。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class 类的对象, 这个对象将作为程序访问方法区中的类型数据的外部接口。

2.验证

验证是连接(一共包括 3 个阶段,见上图)的第一步,目的是验证获取的字节码符合规范。

验证大致分为下面 4 种。

  1. 文件格式验证
    主要是基于二进制流进行验证。
    例如字节码是否以魔数(0xCAFEBABE)开头
    字节码的主次版本号(在 JVM 可接受的范围内)
    常量池中的类型是否合法
    指向常量的索引没有越界类型匹配
    CONSTANT_Utf8_info 型的常量是否符合 utf-8 的编码等。
  2. 元数据验证
    主要是验证类型的元信息,比如父类是否合法,如果实现接口是否实现了接口方法,字段是否与父类冲突等。
  3. 字节码验证
    操作数栈的数据类型与指令代码中匹配,跳转指令的合法性(不会跳转到方法体外),类型转换是有效的。
    在 JDK 6之后的 Javac 编译器和 JVM 里进行了一项联合优化,把尽可能多的校验辅助措施挪到 Javac 编译器里进行。具体做法是给方法体 Code 属性的属性表中新增加了一项名 为 “StackMapTable” 的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。(可能存在 “StackMapTable” 被篡改导致错误)
  4. 符号引用的验证
    发生在 jvm 将方法区中的符号引用转化为直接引用的时候(转化动作在连接的第三阶段,解析中完成)。
    包括以下的验证
    是否能通过字符串描述的类的全限定名找到对应的类
    对应的类中,指定的字段和方法是否存在,以及是否是可访问的

类加载中的验证阶段如果被认为是没有必要的(比如已经多次使用过该应用),那么可以使用 -Xverify:none jvm 参数跳过验证阶段,以加快类加载的过程。

3.准备

为类变量(静态变量)分配内存并初始化(指置 0,以指定值赋值在类的初始化阶段,如果是 final 修饰,那么直接使用指定值初始化)。注意方法区是一个逻辑概念,类变量使用的内存处于方法区中,在 jdk 8 之后,方法区和 Class 对象一起存放在堆中。

在 Jdk 6 之前,静态变量存放在方法区中,之后,静态变量存放在 Class 对象的末尾,也就是堆中。

4.解析

JVM 将常量池中的符号引用(在 .class 文件中以 CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info 等类型的常量出现)替换为直接引用(即将描述引用目标的字面量转换成一个指向目标的内存地址,偏移量或者能访问目标的句柄)。对于字段,方法和类的可访问性也是在这个阶段中完成的。

《Java虚拟机规范》并没有明确对解析的时机做约束,到底是在类加载时对常量池中的符号引用做解析,还是在一个符号引用被使用时再做解析依赖于虚拟机的实现。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7 类符号引用进行,分别对应于常量池的 (8 种常量类型)
CONSTANT_Class_info:类或接口
CON-STANT_Fieldref_info:字段
CONSTANT_Methodref_info:类方法CONSTANT_InterfaceMethodref_info:接口方法 CONSTANT_MethodType_info:方法类型CONSTANT_MethodHandle_info:方法句柄
CONSTANT_Dyna-mic_info:调用点限定符 CONSTANT_InvokeDynamic_info :调用点限定符

类或接口的解析

假设类 D 的代码区中出现一个未解析过的类或者接口 C 的符号引用,那么

  • 如果 C 不是一个数组类型,那么使用 D 的类加载器,加载 C。
  • 如果 C 是一个数组类型,且元素类型为引用(并非基础数据类型),那么首先使用 D 的类加载器加载元素类型,接着由虚拟机生成一个数组类型。

在完成上面的过程之后,还需要验证 C 对 D 类型的可访问性。

(除了位于同一个包 和 public 之外,jdk 9 引入了模块对可访问性的控制)

字段解析

要解析一个未被解析过的字段符号引用,首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的符号引用。

如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那么进行下面的步骤:

* 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
* 如果如果在C中实现了接口/继承了父类(先接口,在父类),将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
* 查找失败。

(值得注意的是,一个类的父类和实现的接口可以定义同名的字段,在引用这个字段的时候,需要使用 super.field 访问父类字段,field 访问接口中的字段。)

方法解析

先解析出方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,那么我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索

* 由于 Class 文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现 class_index 中索引的 C 是个接口的话,那就直接抛出 java.lang.IncompatibleClassChangeError 异常。
* 如果通过了第一步,在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
* 否则,在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
* 否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类 C 是一个抽象类,这时候查找结束,抛出 java.lang.AbstractMethodError 异常。
* 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
接口方法解析

接口方法也是需要先解析出接口方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用 C 表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

* 与类的方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那么就直接抛出 java.lang.IncompatibleClassChangeError 异常。
* 否则,在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
否则,在接口C的父接口中递归查找,直到 java.lang.Object 类(接口方法的查找范围也会包括 Object 类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
* 对于上面的规则,由于 Java 的接口允许多重继承,如果 C 的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,Javac 也可能拒绝编译。
* 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

5.初始化

初始化阶段就是执行类构造器 <clinit>() 方法的过程。<clinit>() 并不是程序员在Java代码中直接编写的方法,它是 Javac 编译器的自动生成物。

<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

<clinit>() 方法与类的构造函数(即在虚拟机视角中的实例构造器 <init>() 方法)不同,它不需要显式地调用父类构造器,Java 虚拟机会保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行完毕。因此在 Java 虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。

<clinit>() 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit>() 方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成

<clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。

Java 虚拟机必须保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,直到活动线程执行完毕 <clinit>() 方法。如果在一个类的 <clinit>() 方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。