一个类型的加载过程分为 5 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization);其中验证、准备、解析三部分统称为连接(Linking);
1. 加载
加载
(Loading)是类加载
(Class Loading)过程中的一个阶段,JVM 会完成一下三件事:
- 通过一个类的全限定名获取类的二进制字节流(可以来自 Class 文件,zip 压缩包如 JAR、EAR、WAR 等,网络,运行时生成,数据库读取,加密文件等);
- 将字节流表达的静态存储结构转化为方法区的运行时数据结构(JVM 实现自行定义);
- 在内存中生成一个代表该类的
java.lang.Class
对象,作为方法区该类的各种数据的访问入口(外部接口);
非数组类的加载阶段既可以使用 JVM 内置的引导类加载器完成,也可以用户自定义类加载器来完成;用户通过控制类加载器中字节流的获取方式(findClass() 或 loadClass())实现应用程序获取动态代码的动态性;
数组类的加载不通过类加载器,JVM 直接在内存动态构造;但数组类的元素类型
(去掉所有维度)最终也是靠类加载器加载的;
- 若数组的
组件类型
(去掉一维后的类型)是引用类型,递归进行组件类型的加载;然后将数组类型将被标识在组件类型的类加载器的类名称空间(一个类型与其类加载器组合必须全局唯一); - 若数组的
组件类型
不是引用类型,JVM 会把数组类型标记为引导类加载器关联; - 数组类的可访问性与其组件类型的可访问性一致(若组件类型不是引用类型,访问性默认为 public);
加载阶段与连接阶段的部分动作(如部分字节码文件格式校验)是交叉进行的,但这两个阶段的开始时间是保持固定先后顺序的;
2. 验证
连接
阶段的第一步,确保 Class 文件的字节流包含的信息符合《Java 虚拟机规范》的全部约束要求,保障代码运行后不会危害 VM 本身;
验证
阶段的工作量和性能耗费在 JVM 的类加载过程占了较大比重,但它直接决定了 JVM 是否能承受恶意代码的攻击;
文件格式验证
验证字节流是否与 Class 文件格式规范和 JVM 版本适配,只有经过文件格式验证,二进制字节流才被允许进入 JVM 内存的方法区;
- 是否以魔数
0xCAFEBABE
开头; - 主、次版本号是否在 JVM 兼容范围;
- 常量池的产量是否是被支持的常量类型;
- 执行常量的索引是否指向的是存在的常量、符合类型的常量;
- CONSTANT_Utf8_info 型的常量是否符合 UTF-8 编码;
- Class 文件中是否有被删除或附加的其他信息;
- …
只有文件格式验证是基于字节流的,其他三个验证阶段都是基于方法区的存储结构进行的;
元数据验证
对元数据描述的信息做语义解析,保障其符合《Java 语言规范》;
- 类是否存在父类(除了
java.lang.Object
,所有类都应该有父类); - 类的父类是否继承了不允许被继承的类(被 final 修饰的类);
- 非抽象类是否实现了其父类和接口中要求实现的所有方法;
- 类中的字段、方法是否与父类产生矛盾(如复写父类的 fianl 字段、不符合规则的方法重载,方法参数一致,返回值不同等);
- …
字节码验证
针对类的方法体(Class 文件中 Code 属性),通过数据流分析和控制流分析,确保语义是合法的、符合逻辑的;
- 确保操作数栈的数据类型与指令操作都能配合工作;
- 确保跳转指令不会跳到方法体以外的指令上;
- 确保方法体重的类型转换总是有效的;
- …
由于数据流分析和控制流分析的高复杂性(不可能用程序来准确判定一段程序是否存在 Bug),JDK 6 在 javac 编译器和 JVM 进行了一项联合优化,将尽可能多的校验辅助措施挪到 javac 编译器中进行(在 Code 属性的属性表新增了 StackMapTable
属性);
StackMapTable
,记录了方法体所有基本块(Basic Block,按控制流拆分的代码块)开始时本地变量表和操作数栈的状态;在字节码验证
阶段,JVM 不需要推导这些状态的合法性,而是检查 StackMapTable 的记录是否合法即可(类型推导 -> 类型检查),节省了一定时间(StackMapTable 也可能被篡改,存在安全风险);
JDK 7 Update 50 之后只支持类型检查,不再允许退回类型推导的验证方式;
符号引用验证
在连接
的解析
阶段,JVM 将符号引用
转化为直接引用
时,验证类自身以外(常量池的各种符号引用)的各种信息是否匹配(类是否缺少或被禁止访问它依赖的外部类、方法、字段等资源);
- 符号引用中字符串描述的全限定名是否能找到对应的类;
- 在指定类是否存在符合描述符及简单名称所描述的方法和字段;
- 符号引用中类、字段、方法的可访问下(private、protected、public、package)是否可被当前类访问;
- …
若符号引用验证失败,JVM 将抛出 java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等异常(java.lang.IncompatibleClassChangeError
的子类);
验证阶段对 JVM 类加载机制非常重要,但不是必须执行的;若程序运行的全部代码(自己编写的、第三方包的、外部加载的、动态生成的等)都已被反复使用和验证,生产环境的部署可以考虑通过 -Xverify:none
关闭大部分类验证措施,这可以缩短 VM 类加载的时间;
3. 准备
准备
阶段为类变量(类中定义的变量,被 static 修饰的静态变量,非实例变量)分配内存并设置变量初始值(通常是数据类型的零值);
// value 在准备阶段过后的初始值是 0,而非 123;
public static int value = 123;
// value = 123 的 putstatic 指令是程序被编译后,存放在类构造器 <clinit>() 方法,并在初始化阶段执行的;
基本数据类型的零值
数据类型 | 零值 |
---|---|
int | 0 |
long | 0L |
short | (short)0 |
char | ‘\u0000’ |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
若类变量呗 final 修饰,变为常量,javac 编译时会为类字段生成 ConstantValue
属性;此时准备阶段该变量会被初始化为 ConstantValue
属性所指定的初始值;
// value 在准备阶段过后的初始值是 123;
public static final int value = 123;
类变量在方法区存放,而在 JDK 7 前,HotSpot 使用永久代实现方法区,在 JDK 8 之后,类变量随 Class 对象一起存放在 Java Heap 中;
4. 解析
解析阶段是 JVM 将常量池中的符号引用替换为直接引用的过程;
符号引用
(Symbolic References),以一组符号来描述所引用的目标,明确定义在《Java 虚拟机规范》的 Class 文件格式中的,无歧义的字面量(Class 文件中如 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info 等类型的常量);直接引用
(Direct References),直接指向目标的指针、相对偏移量、能间接定位到目标的句柄等;与 VM 实现的内存布局直接相关;
解析阶段发生的时机
执行操作符号引用的字节码(anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、 invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield、putstatic)之前,先对他们所使用的符号引用进行解析;可以是类加载阶段,也可以是符号引用将被使用前;
对方法或字段的访问,会在解析阶段检查他们的可访问性(public、protected、private、package);
JVM 通过解析缓存避免重复解析;同一个符号引用可以被解析多次,但 JVM 必须保证一个符号引用第一次解析成功则后续的解析请求一直成功,第一次解析失败则后续请求收到相同的异常,即使请求的符号在后来已经被成功加载进 VM Memory;
invokedynamic
指令触发的解析不具备缓存条件,其目的是支持动态语言;必须等到程序实际运行到这条指令时,解析动作才能进行;
解析动作主要针对类
、接口
、字段
、类方法
、接口方法
、方法类型
、方法句柄
、调用点限定符
这 7 类符号引用进行;分别对应于常量池的 CONSTANT_Class_info
、CONSTANT_Fieldref_info
、CONSTANT_M ethodref_info
、CONSTANT_InterfaceMethodref_info
、CONSTANT_MethodType_info
、CONSTANT_MethodHandle_info
、CONSTANT_Dynamic_info
和 CONSTANT_InvokeDynamic_info
8 种常量类型;
类或接口的解析
在 D 类将一个从未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,所需步骤如下:
- 若 C 不是数组类型,VM 将通过 D 的类加载器解析 N 的类全限定名代表的类(由于元数据验证、字节码验证,可能触发其他相关类的加载动作,例如父类的加载);
- 若 C 是数组类型,且其元素类型是对象,N 的描述符是
Lxxx/xxx/XXX
,先按上一步的方式加载元素类型,接着由 VM 生成一个代表该数组维度和元素的数组对象; - 若上两步都没有任何异常,此时 C 在 JVM 中已经是一个有效的类或接口了;接着进行符号引用验证,确认 D 具备对 C 的访问权限(无权限则抛出
java.lang.IllegalAccessError
);
访问权限验证(考虑 JDK 9 引入的模块化)
- C 是 public,且与 D 处在同一模块;
- C 是 public,与 D 不再统一模块,但 D 所在模块具备访问 C 所在模块的访问权限;
- C 不是 public,但与 D 处在同一包;
字段解析
解析一个未被解析过的字段符号引用,需要先解析字段表内的 class_index 索引的 CONSTANT_Class_info 符号引用(即字段所属类或接口的符号引用);假设该类或接口为 C,解析成功后,JVM 对 C 的后续字段搜索如下:
- 若 C 本身包含简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,结束查找;
- 否则,若 C 中实现了接口,按照继承关系从下往上递归搜索接口和父接口,直到找到简单名称和字段描述符与目标相符的字段,返回其直接引用,结束查找;
- 否则,若 C 不是
java.lang.Object
,按继承关系从下往上递归搜索父类,直到找到简单名称和字段描述符与目标相符的字段,返回其直接引用,结束查找; - 否则,查找失败,抛出
java.lang.NoSuchFieldError
;
字段解析过程保障了 JVM 获得字段的结果是唯一的;但 javac 编译器对字段的约束比这里的字段解析过程可能要更严格(如 Oracle 的 javac 编译器不允许在类的接口和父类中同时出现同名字段);
方法解析
方法解析与字段解析类似,先解析出方法表的 class_index 索引的 CONSTANT_Class_info 符号引用(方法所属类或接口的符号引用);假设该类或接口为 C,解析成功后,JVM 对 C 的后续方法搜素如下:
- 若 C 是接口(Class 文件中类的方法和接口的方法的符号引用的常量类型定义是分开的),直接抛出
java.lang.IncompatibleClassChangeError
; - 在类 C 中查找简单名称和描述符都与目标匹配的方法,返回其直接引用,结束查找;
- 若未找到,在类 C 的父类中递归查找简单名称和描述符都与目标匹配的方法,返回其直接引用,结束查找;
- 若未找到,在类 C 的接口列表及它们的父接口中递归查找简单名称和描述符都与目标匹配的方法,若找到,说明类 C 是一个抽象类,结束查找,抛出
java.lang.AbstractMethodError
; - 否则,查找失败,抛出
java.lang.NoSuchMethodError
;
查找到方法的直接引用后,对方法进行权限验证,若不具备方法的访问权限,抛出 java.lang.IllegalAccessError
;
接口方法解析
接口方法解析也需要先解析出接口方法表的 class_index 索引的 CONSTANT_Class_info 符号引用(方法所属类或接口的符号引用);假设该类或接口为 C,解析成功后,JVM 对 C 的后续方法搜素如下:
- 若 C 是类而非接口,抛出
java.lang.IncompatibleClassChangeError
(与类的方法解析相反); - 否则,在接口 C 中查找简单名称和描述符都与目标匹配的方法,返回其直接引用,结束查找;
- 否则,在接口 C 的父接口中递归查找简单名称和描述符都与目标匹配的方法,返回其直接引用,结束查找;
- 若 C 的父接口存在多个简单名称和描述符都与目标匹配的方法(Java 的接口允许多重继承),返回其中一个方法的直接引用,并结束查找;
- 否则,查找失败,抛出
java.lang.NoSuchMethodError
;
JDK 9 以前,Java 接口的方法默认都是 public 的,也没有模块化约束,不存在访问权限问题(不可能抛出 java.lang.IllegalAccessError
);
JDK 9 中增加了接口的静态私有方法,多了模块化的访问约束;
5. 初始化
初始化阶段,就是执行类构造器 <clinit>()
方法的过程; <clinit>()
是 javac
编译器收集类中所有类变量的赋值动作和静态语句块(static {} 块)自动生成,收集的顺序与语句在 Java 源码中的顺序一致;
public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.print(i); // 这里编译器提示: java: illegal forward reference
}
static int i = 1;
}
<clinit>()
方法与类的构造函数(实例构造器<init>()
方法)不同,它不需要显示调用父类构造器,JVM 会保障子类的 <clinit>()
执行前,父类的 <clinit>()
一定是已执行完毕的;即父类中定义的静态代码块一定是优先与子类的变量赋值的;
public class Test {
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
// 输出:
// 2
}
}
若一个类没有静态语句块,没有对变量的赋值操作,编译器就不会为这个类生成 <clinit>()
方法;
- 接口中不能使用静态代码块,但可以使用静态变量赋值语句,所以接口也会生成
<clinit>()
方法;但接口执行<clinit>()
方法不需要先执行父接口的<clinit>()
方法,而是只在父接口的静态变量呗使用时才执行父类的<clinit>()
;类执行<clinit>()
时也不需要先执行其父接口的<clinit>()
; - JVM 会保障一个类的
<clinit>()
在多线程环境会被正确的加锁同步,永远只有一个线程去执行一个类的<clinit>()
方法;因此若类的<clinit>()
耗时很长,就可能造成多线程阻塞;
public class DeadLoopClass {
public static void main(String[] args) {
Runnable script = () -> {
System.out.println(Thread.currentThread() + "start");
DeadLoop dlc = new DeadLoop();
System.out.println(Thread.currentThread() + " run over");
};
Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
}
class DeadLoop {
static {
System.out.println("<clinit>()");
// 如果不加上这个 if 语句,编译器将提示 “Initializer does not complete normally” 并拒绝编译
if (true) {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread() + "init DeadLoopClass");
}
}
}
运行结果
Thread[Thread-0,5,main]start
Thread[Thread-1,5,main]start
<clinit>()
Thread[Thread-0,5,main]init DeadLoopClass
Thread[Thread-0,5,main]init DeadLoopClass
Thread[Thread-0,5,main] run over
Thread[Thread-1,5,main] run over
类加载的几个动作,除了在加载阶段通过自定义类加载器应用程序可以局部干预,其余动作是完全交由 JVM 主导控制的;直到初始化阶段,JVM 才真正开始执行类中编写的 Java 代码(应用程序掌握主导权);
上一篇:「JVM 执行子系统」类加载的时机
下一篇:「JVM 执行子系统」类加载器与双亲委派机制
PS:感谢每一位志同道合者的阅读,欢迎关注、评论、赞!
参考资料:
- [1]《深入理解 Java 虚拟机》