类加载过程:Linking(验证/准备/解析)
Linking:加载之后的「质检与翻译」
字节码被加载到内存后,JVM 并不能直接使用。它需要先经过 Linking(链接)阶段的检查和转换。Linking 分三步:验证(Verification)、准备(Preparation)、解析(Resolution)。
Loading(加载)
│
▼
Linking(链接)
├─ Verification(验证)── 字节码合法吗?安全吗?
├─ Preparation(准备)── 分配内存,设默认值
└─ Resolution(解析)── 符号引用 → 直接引用
│
▼
Initialization(初始化)
│
▼
使用Verification:字节码的安检门
验证是 JVM 的安全防线。它要确保加载进来的字节码不会破坏 JVM 的完整性。
想象一下:如果一段恶意代码伪造了一个 Object 引用,让它看起来像是一个数组指针,然后用它去访问任意内存——没有验证的话,JVM 就直接崩溃了。
验证的四个阶段
JVM 的字节码验证器会对字节码进行以下检查:
1. 文件格式验证
检查字节码文件是否符合 Class 文件格式规范。
// 检查项:
// - 魔数是否为 0xCAFEBABE
// - 主版本号和次版本号是否在当前 JVM 支持的范围内
// - 常量池中是否有不支持的常量类型
// - ...如果这一步失败,你会遇到 java.lang.ClassFormatError。
2. 元数据验证
对字节码描述的元信息进行语义分析,确保符合 Java 语言规范。
// 检查项:
// - 这个类是否有父类(除 Object 外都应该有)
// - 继承关系是否合法(final 类不能被继承)
// - 方法签名是否冲突
// - 抽象方法是否全部实现
// - ...这一步会捕获大部分的代码错误。比如:
public class BadClass extends String {
// 编译不会报错(语法上合法)
// 但运行时会报 ClassFormatError
// 因为 String 是 final 的,不能被继承
}3. 字节码验证
这是最复杂的一步。通过数据流分析和控制流分析,确保字节码不会做出危险操作。
// 检查项:
// - 操作数栈不会溢出或下溢
// - 本地变量使用前已初始化
// - 方法调用参数类型匹配
// - 不会访问非法内存(用非法引用访问对象)
// - ...这个阶段最经典的问题是:栈操作的一致性。
// 恶意字节码(会被验证器拦截)
iload_1 // 从局部变量槽 1 加载 int
aload_2 // 把它当作对象引用使用 —— 验证失败!HotSpot 在 JDK 8 之后对验证进行了优化(StackMapTable),用预设的类型信息替代了全量分析,大幅加快了类的加载速度。
4. 符号引用验证
在解析阶段之前,JVM 需要检查符号引用的目标是否真实存在、是否具有访问权限。
// 检查项:
// - 类/字段/方法是否存在
// - 是否有足够的访问权限(public / protected / private)
// - 是否是 final 类的非 final 方法
// - ...这一步会抛出 IllegalAccessError、NoSuchFieldError、NoSuchMethodError 等错误。
Preparation:分配内存,设默认值
准备阶段是分配内存并设置默认值的阶段。
关键:默认值不是初始值
这是最容易搞错的地方。
public class PreparationDemo {
// 准备阶段:value 被分配内存,设置为默认值 0
// 注意:此时还没有执行 static {} 中的赋值
public static int value = 123;
// 准备阶段:str 被分配内存,设置为默认值 null
public static String str = "hello";
// 准备阶段:arr 被分配内存,设置为默认值 null
public static int[] arr = new int[10];
static {
// 初始化阶段:这里才会执行真正的赋值
// value 才会被设为 123
value = 456;
System.out.println("Static block executed, value = " + value);
}
}基本类型的默认值
| 类型 | 默认值 |
|---|---|
int | 0 |
long | 0L |
short | 0 |
byte | 0 |
char | '\u0000' |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
常量(static final)的特殊情况
如果一个字段是 static final 且是编译期常量(字面量),在准备阶段就会直接赋值,不需要等到初始化。
public class ConstantDemo {
// 编译期常量:在编译时值就已知
// 准备阶段直接赋值为 123,不需要等到初始化
public static final int CONST = 123;
// 不是编译期常量:比如 new Random().nextInt()
// 准备阶段只赋默认值 null
public static final String STR = new String("hello");
}这是编译器优化的空间:static final 常量在编译时就被嵌入到使用它的地方,不需要运行时解析。
Resolution:符号引用变直接引用
解析是把符号引用转换为直接引用的过程。
符号引用 vs 直接引用
符号引用(Symbolic Reference):用字符串表示的引用,不依赖具体内存地址。在 Class 文件中,所有对类、字段、方法的引用都是符号引用。
"java.lang.String"是类的符号引用"java.io.PrintStream.out"是字段的符号引用"java.lang.Object.<init>()V"是方法的符号引用
直接引用(Direct Reference):直接指向目标的指针或句柄,是运行时确定的真实内存地址。
// 源代码中:
public class ResolutionDemo {
Object obj = new Object();
// "Object" 是符号引用
// 解析后:obj 的类型槽中存储的是 Object 类在方法区中的实际地址
}
// 字节码中:
// new #2 // #2 是常量池中的符号引用索引
// dup
// invokespecial #3 // #3 也是符号引用索引解析的时机:早期绑定 vs 晚期绑定
JVM 规范允许在初始化阶段之后再进行解析,这叫晚期绑定(Lazy Resolution)。
- 非虚方法(private、static、final、构造器):在解析时就知道目标是哪个,可以提前解析
- 虚方法(instance 方法):编译时不知道运行时实际类型,可能延迟到第一次调用时解析
不过 HotSpot 的实现基本上是激进解析:能解析的尽量在类加载时就解析完,以提高后续调用效率。
解析的几种情况
| 符号引用 | 解析目标 | 失败错误 |
|---|---|---|
| 类或接口 | 直接引用:方法区中该类的类对象 | NoClassDefFoundError |
| 字段 | 直接引用:字段在类中的内存偏移量 | NoSuchFieldError |
| 方法 | 直接引用:方法的入口地址或 vtable 槽位 | NoSuchMethodError |
| 接口方法 | 直接引用:接口方法表中的槽位 | AbstractMethodError |
三个阶段的执行者
Verification(验证)──→ 字节码验证器(native 代码)
Preparation(准备)──→ JVM 内存管理模块(设置默认零值)
Resolution(解析)──→ JVM 运行时(符号引用解析)这三个阶段都没有执行任何 Java 代码,它们只是把字节码「准备好」。
本节小结
Linking 阶段的三步各有分工:
| 阶段 | 任务 | 关键词 |
|---|---|---|
| Verification | 检查字节码是否合法、安全 | 验证器、安全检查 |
| Preparation | 分配内存,设置默认值零值 | 零值、基本类型 |
| Resolution | 符号引用 → 直接引用 | 符号引用、内存地址 |
真正给变量赋初始值、执行静态代码块,是在下一节——类加载过程:Initialization。
加载 → 链接 → 初始化的完整流程图
类字节码
│
│ Loading
▼
读取到内存
│
│ Verification(检查格式、元数据、字节码、符号引用)
▼
分配内存 + 设置默认值(零值)
│
│ Preparation
▼
符号引用 → 直接引用
│
│ Resolution
▼
Initialization(执行 clinit)
│
▼
类可以使用