类加载过程详解
一个 .class 文件从磁盘字节流变成可以被 new 出来的类型,要经过加载、验证、准备、解析、初始化五个阶段。每个阶段干什么、什么时候触发、踩坑点在哪,是理解静态块死锁、NoClassDefFoundError 等线上问题的前提。
它解决什么问题
Java 选择「运行期动态加载」而非 C++ 式的静态链接,换来的是灵活性与扩展性:程序不必在启动时载入全部代码,字节码可以从任意来源到达 JVM。但代价是 JVM 必须在运行期自己完成一整套「准入审查与装配」工作——确认字节流合法、为静态变量安排好内存、把符号化的引用落成真实地址、在正确的时机执行类的初始化代码。这五件事就是五个阶段存在的理由。
核心原理
完整的类生命周期是七步:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载,其中验证、准备、解析合称「连接」。前五步属于类加载过程,各阶段职责一句话概括:
- 加载:按全限定名拿到二进制字节流,转成方法区的运行时数据结构,并在堆中生成
Class对象作为访问入口。 - 验证:确认字节流符合 JVM 规范,不会危害虚拟机安全。
- 准备:为
static变量分配内存并设置零值(0、null、false)。 - 解析:把常量池里的符号引用替换为直接引用。
- 初始化:执行编译器生成的
<clinit>(),此时静态变量才被赋上代码里的「真值」。
阶段之间有严格顺序,但规范允许交叉进行——比如加载还没收尾,一部分格式验证就可能已经开始。
深入机制
加载:字节流的自由来源
规范只约束「拿到字节流」这个结果,不约束来源:jar 包、网络、动态代理运行时生成、JSP 编译产物都可以。这一步是五阶段中可控性最强的,自定义类加载器重写 findClass 就是在定制加载阶段。需要注意:数组类不经过类加载器,由 JVM 直接创建,其元素类型的加载器即数组类的加载器。
验证:四道安检
验证是连接阶段最耗时的部分,分四层递进:
- 文件格式验证:魔数是否为
0xCAFEBABE、版本号能否被当前 JVM 接受、常量池 tag 是否合法。这是唯一基于原始字节流的验证,通过后字节流进入方法区。 - 元数据验证:语义层面的检查——这个类是否有父类、是否继承了
final类、抽象方法是否都有实现。 - 字节码验证:通过数据流分析确认方法体逻辑安全,比如操作数栈类型匹配、跳转指令不会越出方法体。
- 符号引用验证:发生在解析阶段,确认被引用的类、字段、方法真实存在且有权访问,否则抛
NoSuchMethodError、IllegalAccessError等。
准备与解析:最容易混淆的两步
准备阶段只干一件事:给类变量(static)在方法区分内存并置零值。 实例变量与此无关——它们随对象一起在堆中分配。看这段代码:
public class PrepareDemo { |
普通 static 在准备阶段拿到的是零值;带 ConstantValue 属性的编译期常量(典型的 static final 字面量)例外,准备阶段即被赋为指定值。这个区别解释了为什么在静态块里「提前读取」后面声明的静态变量只能读到默认值。
解析阶段则把常量池中的符号引用(用字符串描述的「哪个类的哪个方法」)替换为直接引用(内存中的实际目标)。 比如调用方法时,虚拟机依据方法符号引用确定真正的调用目标;HotSpot 内部可能用方法表、入口地址等方式表示,属于实现细节。解析并非必须在初始化前全部完成——为了支持动态绑定,HotSpot 对一个符号引用的解析往往推迟到它第一次被真正使用时,这也是 NoClassDefFoundError 常在运行中途才冒出来的原因。
初始化:何时触发、谁来保证安全
初始化就是执行 <clinit>()——编译器按源码顺序合并所有静态变量赋值语句和静态块得到的方法(没有静态代码的类可以不生成它)。规范定义了六种主动引用会立即触发初始化:
- 遇到
new、getstatic、putstatic、invokestatic四条字节码指令(读写的若是编译期常量则除外); - 反射调用,如
Class.forName("...")、newInstance(); - 初始化子类时父类尚未初始化,先初始化父类(接口则不同:用到父接口时才初始化);
- JVM 启动时先初始化
main方法所在的主类; - 使用
MethodHandle首次解析出静态方法/字段/构造器句柄时; - 接口含
default方法时,其实现类初始化前该接口须先初始化。
其余引用方式都是被动引用,不会触发初始化,三个经典反例:
class Parent { |
最后是 <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 这类问题就都有了分析框架。

