Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制。
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历 加载(Loading)、验证(Verifivation)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和 卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为 链接(Linking)。
类加载的时机
常见的可以触发类型进行 初始化(加载、验证、准备需要在此之前开始)的场景有:
- 遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。
- 对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
- 当初始化类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。如果是接口则不要求其父接口全部都完成了初始化,只有在真正使用到父接口时才会初始化。
- 当虚拟机启动时,用户需指定一个要执行的主类,虚拟机会先初始化这个主类。
上面这些行为称为对一个类型进行主动引用。此外,还有一些不会触发初始化的行为称为被动引用:
- 通过子类引用父类的静态字段,不会导致子类初始化,只有直接定义这个字段的类才会被初始化。
public class SuperClass {
static {
System.out.println("SuperClass init");
}
protected static int value = 1;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init");
}
}
public class TestClass {
// 运行后不会输出SubClass init,表示SubClass类没有被初始化
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
- 通过数组定义来引用类,不会触发此类的初始化。
public class TestClass {
// 运行后不会输出SuperClass init,表示SuperClass类没有被初始化
public static void main(String[] args) {
SuperClass[] a = new SuperClass[1];
}
}
实际上,这段代码触发了一个名为 [LSuperClass 的类的初始化阶段,它是一个由虚拟机自动生成的、直接继承于 java.lang.Object 的子类,创建动作由字节码指令 newarray 触发。这个类代表了一个元素类型为 SuperClass 的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为 public 的 length 属性和 clone() 方法)都实现在这个类里。
Java 语言中对数组的访问要比 C/C++ 相对安全,很大程度上就是因为这个类包装了数组元素的访问,而 C/C++ 中则是直接翻译为对数组指针的移动。在 Java 语言里,当检查到发生数组越界时会抛出 java.lang.ArrayIndexOutOfBoundsException 异常,避免了直接造成非法内存访问。
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
public class ConstantClass {
static {
System.out.println("ConstantClass init");
}
public static final String HELLO = "hello";
}
public class TestClass {
// 运行后不会输出ConstantClass init,表示ConstantClass类没有被初始化
public static void main(String[] args) {
System.out.println(ConstantClass.HELLO);
}
}
TestClass 在编译阶段通过常量传播优化已经将此常量的值 “hello” 直接存储在常量池中了,以后 TestClass 对这个常量的引用实际都被转化为对自身常量池的引用了。即 TestClass 的 Class 文件之中并没有 ConstantClass 类的符号引用入口,这两个类在编译成 Class 文件后就已不存在任何联系了。
类加载的过程
1. 加载
加载(Loading)阶段是整个类加载过程中的第一个阶段,该阶段 Java 虚拟机需要完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
二进制字节流的获取方式:
在《Java 虚拟机规范》中并没有规定二进制字节流必须得从某个 Class 文件中获取,确切地说是根本没有指明要从哪里获取、如何获取。因此这里存在很强的扩展性,用户可以自定义类加载器去控制字节流的获取方式,从而赋予应用程序获取运行代码的动态性。常见的扩展方式主要有:
- 从 ZIP 压缩包中读取,最终成为日后 JAR、WAR 格式的基础。
- 从网络中获取。
- 运行时计算生成,这种场景使用得最多的就是动态代理技术。
- 从加密文件中获取,这是典型的防 Class 文件被反编译的保护措施。
对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的。但数组类的元素类型最终还是要靠类加载器来完成加载。
加载阶段结束后,Java 虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了。之后会在 Java 堆内存中实例化一个 java.lang.Class 类的对象, 这个对象将作为程序访问方法区中的类型数据的外部接口,这也是实现反射的关键数据。
可以通过执行 java -verbose:class xxx.class 来打印类被执行时的类加载过程:
2. 验证
验证 的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。
2.1 文件格式验证
第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。主要包括:
- 是否以魔数 0xCAFEBABE 开头
- 主、次版本号是否在当前 Java 虚拟机接受范围之内
- 常量池的常量中是否有不被支持的常量类型
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8 编码的数据
- ……
该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区内。该阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,这段字节流才被允许进入 Java 虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
2.2 元数据验证
第二阶段是对字节码描述的信息进行语义分析,保证其描述的信息符合《Java 语言规范》的要求,这个阶段可能包括的验证点如下:
- 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾,例如覆盖了父类的 final 字段或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等。
-
2.3 字节码验证
第三阶段是整个验证过程中最复杂的阶段,主要目的是通过 数据流分析 和 控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕后,这阶段就要对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
保证函数的调用都传递了正确类型的参数,变量的赋值都给了正确的数据类型。
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系的一个数据类型,则是危险和不合法的。
- ……
由于数据流分析和控制流分析的高度复杂性,Java 虚拟机的设计团队为了避免过多的执行时间消耗在字节码验证阶段中,在 JDK 6 之后的 Javac 编译器和 Java 虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到 Javac 编译器里进行。
具体做法是给方法体 Code 属性的属性表中新增加了一项名为 StackMapTable 的新属性,这项属性描述了方法体所有的基本块开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java 虚拟机就不需要根据程序推导这些状态的合法性,只需要检查 StackMapTable 属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查,从而节省了大量校验时间。
2.4 符号引用验证
最后一个阶段的校验行为发生在虚拟机将 符号引用 转化为 直接引用 的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证 可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,即该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
- 符号引用中通过字符串描述的全限定名是否能找到对应的类。
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
- 符号引用中的类、字段、方法是否可被当前类访问。
- ……
符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。
验证阶段对于虚拟机的类加载机制来说,是一个非常重要但却不是必须要执行的阶段,因为只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的代码都已被反复使用和验证过,在生产环境就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
3. 准备
准备 阶段是正式为类中定义的 类变量(静态变量)分配内存并设置类变量初始值的阶段,这里进行内存分配的 仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随对象一起分配在 Java 堆中。其次这里所说的初始值通常情况下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时尚未开始执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器 <clinit>() 方法之中,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。
Java 中所有基本数据类型的零值
数据类型 | 零值 | 数据类型 | 零值 |
---|---|---|---|
int | 0 | boolean | false(因为在 Java 虚拟机规范中,boolean 类型被映射成 int 类型,具体来说,true 被映射为整数 1,而 false 被映射为整数 0。由于 int 的默认值是 0,故对应的 boolean 的默认值就是 false) |
long | 0L | float | 0.0f |
short | (short)0 | double | 0.0d |
char | “\u0000” | reference | null |
byte | (byte)0 |
上面提到在通常情况下初始值是零值,因为会存在某些特殊情况:如果类字段的字段属性表中存在 ConstantValue 属性,那在准备阶段变量值就会被初始化为 ConstantValue 属性所指定的初始值,假设上面类变量 value 的定义修改为:
public static final int value = 123;
编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。
4. 解析
解析 阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个 符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。符号引用的字面量已明确定义在《Java 虚拟机规范》的 Class 文件格式中,在 Class 文件中以 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型的常量出现。
比如对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化)。
直接引用 是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。《Java 虚拟机规范》并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码前,需要完成对这些符号引用的解析。
4.1 类或接口的解析
假设当前代码所处的类为 D,如果要把一个从未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,那虚拟机完成整个解析的过程需要包括以下三个步骤:
1)如果 C 不是一个数组类型,那虚拟机会把代表 N 的全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中可能触发其他相关类的加载动作。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
2)如果 C 是一个数组类型,并且数组的元素类型为对象,那将会按照第一点的规则加载数组元素类型,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
3)如果上面两步没有出现任何异常,那么 C 在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认 D 是否具备对 C 的访问权限。如果不具备访问权限将抛出 IllegalAccessError 异常。
4.2 字段解析
要解析一个未被解析过的字段符号引用,首先会对字段所属的类或接口的符号引用进行解析。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析失败。如果解析成功完成,那把这个字段所属的类或接口用 C 表示,后续步骤如下:
1)如果 C 本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。否则,如果在 C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
2)否则会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。否则查找失败,抛出 NoSuchFieldError 异常。
3)如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限将抛出 IllegalAccessError 异常。
4.3 方法解析
方法解析的第一个步骤与字段解析一样,也需要先解析出方法所属的类或接口的符号引用,如果解析成功,那么我们依然用 C 表示这个类,接下来虚拟机将按如下步骤进行后续的方法搜索:
1)在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。否则在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
2)否则,在类 C 实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类 C 是一个抽象类,抛出 AbstractMethodError 异常。否则查找失败,抛出 NoSuchMethodError 异常。
3)最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限将抛出 IllegalAccessError 异常。
4.4 接口方法解析
接口方法也是需要先解析出接口方法所属的类或接口的符号引用,如果解析成功依然用 C 表示这个接口。
在接口 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。否则在接口 C 的父接口中递归查找,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。否则查找失败,抛出 NoSuchMethodError 异常。
5. 初始化
初始化 阶段是类加载过程的最后一个步骤,直到该阶段 Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。进行 准备 阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序编码制定的主观计划去初始化类变量和其他资源。
在 Java 代码中,如果要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值。如果直接赋值的静态字段被 final 修饰且为基本类型或字符串时,该字段会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成(在准备阶段完成)。此外的直接赋值操作以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 <clinit>。
初始化 阶段就是执行类构造器 <clinit> 方法的过程,Java 虚拟机会通过加锁来确保类的
public class TestClass {
static {
a = 1; // 给变量赋值可以正常编译通过
System.out.println(a); // 这句编译器会提示:Illegal forward reference
}
private static int a;
}
<clinit>() 方法与类的构造函数(即在虚拟机视角中的实例构造器
public class TestClass {
private static int a;
static {
a = 2;
}
static class Sub extends TestClass {
private static int b = a;
}
public static void main(String[] args) {
System.out.println(Sub.b); // 结果是2,因为父类的<clinit>()先执行
}
}
实践
通过 JVM 参数 -verbose:class 来打印类加载的先后顺序,并且在 LazyHolder 的初始化方法中打印特定字样。在命令行中运行下述指令(不包含提示符 $):
$ echo '
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
static {
System.out.println("LazyHolder.");
}
}
public static Object getInstance(boolean flag) {
if (flag) return new LazyHolder[2];
return LazyHolder.INSTANCE;
}
public static void main(String[] args) {
getInstance(true);
System.out.println("----");
getInstance(false);
}
}' > Singleton.java
$ javac Singleton.java
$ java -verbose:class Singleton
输出结果:
从图中可以看到,在第 11 行新建 LazyHolder 数组的时候,触发了 LazyHolder 的加载,但没有触发它的初始化过程。直到第 12 行访问了 LazyHolder 的静态字段时才触发了它的初始化过程。