一个 .class 文件从磁盘字节流变成可以被 new 出来的类型,要经过加载、验证、准备、解析、初始化五个阶段。每个阶段干什么、什么时候触发、踩坑点在哪,是理解静态块死锁、NoClassDefFoundError 等线上问题的前提。

它解决什么问题

Java 选择「运行期动态加载」而非 C++ 式的静态链接,换来的是灵活性与扩展性:程序不必在启动时载入全部代码,字节码可以从任意来源到达 JVM。但代价是 JVM 必须在运行期自己完成一整套「准入审查与装配」工作——确认字节流合法、为静态变量安排好内存、把符号化的引用落成真实地址、在正确的时机执行类的初始化代码。这五件事就是五个阶段存在的理由。

核心原理

完整的类生命周期是七步:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载,其中验证、准备、解析合称「连接」。前五步属于类加载过程,各阶段职责一句话概括:

  • 加载:按全限定名拿到二进制字节流,转成方法区的运行时数据结构,并在堆中生成 Class 对象作为访问入口。
  • 验证:确认字节流符合 JVM 规范,不会危害虚拟机安全。
  • 准备:为 static 变量分配内存并设置零值(0、null、false)。
  • 解析:把常量池里的符号引用替换为直接引用。
  • 初始化:执行编译器生成的 <clinit>(),此时静态变量才被赋上代码里的「真值」。

阶段之间有严格顺序,但规范允许交叉进行——比如加载还没收尾,一部分格式验证就可能已经开始。

深入机制

加载:字节流的自由来源

规范只约束「拿到字节流」这个结果,不约束来源:jar 包、网络、动态代理运行时生成、JSP 编译产物都可以。这一步是五阶段中可控性最强的,自定义类加载器重写 findClass 就是在定制加载阶段。需要注意:数组类不经过类加载器,由 JVM 直接创建,其元素类型的加载器即数组类的加载器。

验证:四道安检

验证是连接阶段最耗时的部分,分四层递进:

  1. 文件格式验证:魔数是否为 0xCAFEBABE、版本号能否被当前 JVM 接受、常量池 tag 是否合法。这是唯一基于原始字节流的验证,通过后字节流进入方法区。
  2. 元数据验证:语义层面的检查——这个类是否有父类、是否继承了 final 类、抽象方法是否都有实现。
  3. 字节码验证:通过数据流分析确认方法体逻辑安全,比如操作数栈类型匹配、跳转指令不会越出方法体。
  4. 符号引用验证:发生在解析阶段,确认被引用的类、字段、方法真实存在且有权访问,否则抛 NoSuchMethodError、IllegalAccessError 等。

准备与解析:最容易混淆的两步

准备阶段只干一件事:给类变量(static)在方法区分内存并置零值。 实例变量与此无关——它们随对象一起在堆中分配。看这段代码:

public class PrepareDemo {
public static int value = 111; // 准备阶段:value = 0;初始化阶段才变为 111
public static final int LIMIT = 100; // 编译期常量带 ConstantValue 属性,准备阶段直接赋 100
}

普通 static 在准备阶段拿到的是零值;带 ConstantValue 属性的编译期常量(典型的 static final 字面量)例外,准备阶段即被赋为指定值。这个区别解释了为什么在静态块里「提前读取」后面声明的静态变量只能读到默认值。

解析阶段则把常量池中的符号引用(用字符串描述的「哪个类的哪个方法」)替换为直接引用(内存中的实际目标)。 比如调用方法时,虚拟机依据方法符号引用确定真正的调用目标;HotSpot 内部可能用方法表、入口地址等方式表示,属于实现细节。解析并非必须在初始化前全部完成——为了支持动态绑定,HotSpot 对一个符号引用的解析往往推迟到它第一次被真正使用时,这也是 NoClassDefFoundError 常在运行中途才冒出来的原因。

初始化:何时触发、谁来保证安全

初始化就是执行 <clinit>()——编译器按源码顺序合并所有静态变量赋值语句和静态块得到的方法(没有静态代码的类可以不生成它)。规范定义了六种主动引用会立即触发初始化:

  1. 遇到 new、getstatic、putstatic、invokestatic 四条字节码指令(读写的若是编译期常量则除外);
  2. 反射调用,如 Class.forName("...")、newInstance();
  3. 初始化子类时父类尚未初始化,先初始化父类(接口则不同:用到父接口时才初始化);
  4. JVM 启动时先初始化 main 方法所在的主类;
  5. 使用 MethodHandle 首次解析出静态方法/字段/构造器句柄时;
  6. 接口含 default 方法时,其实现类初始化前该接口须先初始化。

其余引用方式都是被动引用,不会触发初始化,三个经典反例:

class Parent {
static { System.out.println("Parent init"); }
public static int value = 123;
}
class Child extends Parent {
static { System.out.println("Child init"); }
}

// 反例1:通过子类访问父类静态字段,只初始化 Parent,不初始化 Child
int x = Child.value;

// 反例2:引用编译期常量,常量已在编译期存入调用方常量池,两个类都不初始化
// public static final String NAME = "hi"; 访问 Parent.NAME 不触发任何初始化

// 反例3:定义数组,触发的是 [LParent; 数组类的创建,Parent 本身不初始化
Parent[] arr = new Parent[10];

最后是 <clinit>() 的线程安全:JVM 保证同一时刻只有一个线程执行某个类的初始化方法,其他线程会被阻塞等待。这带来两个推论:其一,静态变量的初始化天然是线程安全的单例语义(枚举单例、静态内部类单例正是利用这一点);其二,如果静态块里写了耗时或等待逻辑,多线程首次访问时会集体卡住——线上「静态块死锁」就是这么来的。

工程实践

排查类加载相关故障时,阶段知识直接对应排查路径:ClassNotFoundException 说明加载阶段就没找到字节流,查 classpath 和加载器;NoClassDefFoundError 多半发生在链接或初始化环节——常见原因是首次初始化失败(静态块抛异常),JVM 将该类标记为 erroneous,之后每次触碰都抛 NoClassDefFoundError,此时要往回翻第一次的 ExceptionInInitializerError。另外 -verbose:class 能打印每个类的加载时机与来源 jar,是定位依赖冲突的第一把刀。

对比与权衡

对比项 准备阶段 初始化阶段
动作 分配内存、置零值 执行 <clinit>(),赋代码中的真值
触发时机 连接阶段固定发生 六种主动引用之一首次出现才发生
对 static int a = 1 的效果 a = 0 a = 1
对编译期常量的效果 直接赋常量值(ConstantValue) 无需再处理
引用方式 是否触发初始化 说明
new、读写非常量静态字段、调静态方法 是 四条字节码指令
反射(Class.forName 等) 是 未初始化则触发
子类初始化 是(连带父类) 父类必先于子类
读编译期常量 否 已内联到调用方常量池
定义该类的数组 否 创建的是数组类

面试视角

问:类加载五个阶段各自做什么? 加载取字节流生成 Class 对象;验证做格式、元数据、字节码、符号引用四层检查;准备为静态变量分配内存并置零值;解析把符号引用转为直接引用;初始化执行 <clinit>() 赋真值。

问:准备和初始化的区别? 同样一句 static int value = 111,准备阶段 value 是 0,初始化阶段才变成 111。只有带 ConstantValue 属性的编译期常量在准备阶段就能拿到真实值。

问:哪些情况会触发类的初始化? 口诀是「主动引用才触发」:new/静态字段/静态方法、反射、先父后子、主类启动、MethodHandle 静态句柄、接口 default 方法六种。读编译期常量、定义数组、经子类读父类静态字段都是被动引用,不触发。

问:<clinit>() 的线程安全怎么保证? JVM 对类的初始化过程加锁,同一时刻只允许一个线程执行 <clinit>(),其余线程阻塞等待。这既是静态单例方案成立的根基,也是静态块死锁的根源。

问:父类和子类的静态块执行顺序? 初始化子类前必先初始化父类,所以父类静态块先执行;同一类内静态变量与静态块按源码书写顺序合并进 <clinit>()。

小结

类加载五阶段是一条分工清晰的流水线:加载解决「字节从哪来」,验证守住安全底线,准备给静态变量「占位清零」,解析把符号落成地址,初始化才真正执行开发者写的静态代码。理解「准备赋零值、初始化赋真值」的区别、「六种主动引用」的边界、以及 <clinit>() 由 JVM 加锁执行这三件事,静态块死锁、初始化顺序、NoClassDefFoundError 这类问题就都有了分析框架。