本文目录导读:

这是关于 JVM(Java 虚拟机)类加载机制中 加载、链接、初始化 三个核心阶段的深度解析,这是 Java 程序员面试高频考点,也是理解 Java 虚拟机运行机制的基础。
我们将分为三个阶段详细讲解,并附上关键图示描述和代码示例。
一个 Java 类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期包括七个阶段: 加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
加载、验证、准备、解析、初始化 这五个阶段是顺序发生的(但解析阶段在某些情况下可以在初始化之后开始,为了支持动态绑定)。
我们今天重点解析前三步:加载、链接(包含验证、准备、解析)、初始化。
第一阶段:加载 (Loading)
定义:查找并加载类的二进制数据(字节码)。
JVM 在这个阶段具体做了什么?
- 通过类的全限定名获取定义此类的二进制字节流。
- 通常是从
.class文件读取,但也可以从 JAR、WAR 包、网络、动态代理生成、JSP 文件(编译后)等获取。
- 通常是从
- 将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
类信息(字段、方法、接口、常量池等)存储在方法区。
- 在内存中生成一个代表这个类的
java.lang.Class对象。这个对象存储在堆中,作为方法区这个类的各种数据的访问入口。
关键角色:类加载器 (ClassLoader)。
- 双亲委派模型:当一个类加载器收到加载请求,它首先不会自己尝试加载,而是委派给父类加载器,只有父类加载器无法加载时,子类才会尝试自己加载。
- 三层类加载器:
- Bootstrap ClassLoader:加载
JAVA_HOME/lib目录中的核心库(如rt.jar)。 - Extension ClassLoader:加载
JAVA_HOME/lib/ext目录中的扩展库。 - Application ClassLoader:加载用户类路径 (
classpath) 上的类。
- Bootstrap ClassLoader:加载
示例:当你写 System.out.println() 时,System.class 是由 Bootstrap ClassLoader 加载的。
第二阶段:链接 (Linking)
链接阶段分为三个子步骤:验证、准备、解析。
验证 (Verification)
目的:确保被加载的类的字节流符合 JVM 规范,且不会危害虚拟机自身安全,这是最严格的一步。
主要动作:
- 文件格式验证:验证魔数 (0xCAFEBABE)、版本号等。
- 元数据验证:检查类是否有父类(Object除外),父类是否可继承,是否实现了所有抽象方法等。
- 字节码验证:最复杂的一步,通过数据流和控制流分析,确保字节码指令序列合法(如:操作数栈不会溢出,跳转指令不会指向方法体外等)。
- 符号引用验证:发生在解析阶段之前,确保后面的解析能正常进行(如:检查类中引用的类/方法/字段是否存在、是否有访问权限)。
例子:如果修改一个 .class 文件的魔数或篡改字节码指向一个非法内存地址,JVM 在验证阶段就会抛出 java.lang.VerifyError。
准备 (Preparation)
目的:为类变量(静态变量 static)分配内存,并设置默认初始值(零值)。
注意:
- 内存分配:仅包含
static变量,不包含实例变量(实例变量在对象实例化时分配在堆中)。 - 零值:
int→ 0,boolean→ false,Object→ null。 - 例外情况:如果类变量被
final修饰,即常量,那么在准备阶段就会被赋值为用户指定的值。
代码演示:
public class Example {
public static int value = 123; // 准备阶段:value = 0
public static final int CONST = 456; // 准备阶段:CONST = 456(final 特殊处理)
private String name; // 准备阶段:不分配内存
}
在准备阶段结束后,value 的值是 0,而不是 123,赋值操作 value = 123 发生在后面的初始化阶段(调用 <clinit> 方法)。
解析 (Resolution)
目的:将常量池内的符号引用 (Symbolic Reference) 替换为直接引用 (Direct Reference)。
- 符号引用:以一组符号(如
CONSTANT_Class_info,CONSTANT_Methodref_info)来描述所引用的目标,与 JVM 内存布局无关。 - 直接引用:可以直接指向目标的指针、偏移量或句柄,与 JVM 内存布局相关。
解析动作针对:类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符。
关键点:JVM 规范允许在 初始化阶段 之后才执行部分解析(延迟解析),只要保证在解析操作真正发生之前,符号引用都是可用的即可,这支持了 Java 的动态绑定(多态)。
第三阶段:初始化 (Initialization)
定义:执行类构造器方法 <clinit>() 的过程。
发起时机(主动引用):JVM 规范严格规定了只有以下 6 种情况才会立即对类进行初始化(加载、验证、准备自然在此之前发生):
new关键字实例化对象、读取或设置一个类的静态字段(不包括被final修饰的常量)、调用一个类的静态方法。- 反射调用:使用
Class.forName()或java.lang.reflect.Method.invoke()。 - 启动类:当初始化一个类,如果其父类还没有初始化,需先触发其父类的初始化。
- main 方法:虚拟机启动时,包含
main()方法的那个类。 MethodHandle和VarHandle:java.lang.invoke.MethodHandle解析结果为REF_getStatic,REF_putStatic,REF_invokeStatic等时。- 接口默认方法:如果一个接口定义了
default方法,那么直接或间接实现该接口的类初始化时,该接口需要先初始化。
<clinit>() 方法详解:
- 由编译器自动收集类中所有类变量(static)的赋值动作和静态语句块(static {}) 中的语句合并而成。
- 收集顺序:按语句在源文件中出现的顺序。
- 该方法不需要显示调用,父类的
<clinit>()会先于子类的执行。 - 线程安全:JVM 会保证一个类的
<clinit>()方法在多线程环境中被正确地加锁、同步,如果有多个线程同时初始化一个类,只有一个线程会执行<clinit>(),其他线程必须等待。
代码示例 - 初始化触发:
class Parent {
public static int A = 1;
static {
A = 2;
}
}
class Sub extends Parent {
public static int B = A; // 子类访问父类静态变量
}
public class InitOrder {
public static void main(String[] args) {
// 主动引用1:new对象
Parent p = new Parent(); // 触发Parent的初始化
// 主动引用4:main方法所在类的初始化(此处就是InitOrder)
// 主动引用3:通过子类引用父类的静态字段,不会导致子类初始化
System.out.println(Sub.B); // 输出:2(只初始化了Parent,子类Sub没有被初始化)
}
}
总结对比表
| 阶段 | 主要任务 | 生成产物 | 是否必须按顺序 | 关键点 |
|---|---|---|---|---|
| 加载 | 查找并读入字节流 | 方法区数据结构 + 堆中的 Class 对象 |
是 | 双亲委派模型 |
| 链接-验证 | 检查字节流安全与合法性 | 无 | 是 | 魔数检查、字节码校验 |
| 链接-准备 | 为静态变量分配内存并设零值 | 静态字段内存空间 | 是 | final 常量直接赋初始值 |
| 链接-解析 | 符号引用 -> 直接引用 | 直接指针/偏移量 | 可延迟 | 支持多态的动态绑定 |
| 初始化 | 执行 <clinit>() 方法 |
静态字段的最终赋值 | 是 | 执行主动引用,线程安全 |
经典面试问题
- 什么时候会发生类的初始化?(前文6种主动引用)
Class.forName()和ClassLoader.loadClass()有什么区别?forName()默认会执行初始化(执行<clinit>),并触发静态代码块。loadClass()默认只执行加载阶段(不进行链接和初始化),常用于延迟加载或框架中(如 Spring IoC 容器的BeanFactory)。
- 如果静态代码块出现异常,会怎样?
JVM 会捕获该异常,并将该类标记为不可用,后续任何使用该类的尝试都会抛出
NoClassDefFoundError(而不是ExceptionInInitializerError)。
理解这三个阶段,能帮助你清晰掌握 JVM 如何将 .class 文件变为可运行的内存对象,也是诊断类加载异常(如 ClassNotFoundException, NoClassDefFoundError, UnsupportedClassVersionError 等)的基础。