Java 类加载的过程是怎样的?
结论先行:一个类从 .class 字节流到能用,要走加载 → 验证 → 准备 → 解析 → 初始化五个阶段,之后才是使用和卸载。前三个阶段由类加载器驱动,后两个阶段由 JVM 按「首次主动使用」的时机触发。其中准备阶段只分配内存并给零值,真正赋值发生在初始化阶段的 <clinit>。
一、五阶段速览
| 阶段 | 做什么 | 关键细节 |
|---|---|---|
| 加载(Loading) | 通过全限定名取字节流,生成 Class 对象 | 字节流可来自文件、JAR、网络、动态生成 |
| 验证(Verification) | 文件格式、元数据、字节码、符号引用校验 | 防止非法字节码破坏 JVM |
| 准备(Preparation) | 为静态变量分配内存并设零值 | static int a = 1 此刻 a == 0 |
| 解析(Resolution) | 常量池符号引用替换为直接引用 | 可延迟到首次使用时进行 |
| 初始化(Initialization) | 执行 <clinit>,赋真实值、跑静态块 | JVM 保证只执行一次且线程安全 |
.class 字节流
│
├─ 加载:读取 → 生成 java.lang.Class 实例 → 放入方法区
├─ 验证:魔数 CAFEBABE → 版本号 → 元数据 → 字节码 → 符号引用
├─ 准备:静态字段分配内存,赋 0 / null / false
├─ 解析:CONSTANT_Class / Methodref → 内存地址
└─ 初始化:<clinit> 按源码顺序执行静态赋值与静态块
二、准备阶段的零值陷阱
public class PrepareDemo {
static int a = 1; // 准备阶段 a = 0,初始化阶段才变 1
static final int B = 2; // 编译期常量,准备阶段直接写入 2
static final int C = "abc".length(); // 非编译期常量,仍在 <clinit> 中赋值
static boolean flag = true; // 准备阶段 false
static {
System.out.println("a=" + a + ", B=" + B + ", flag=" + flag);
}
public static void main(String[] args) {
System.out.println("main: a=" + a);
}
}
// 输出:
// a=0, B=2, flag=true
// main: a=1
静态块里打印出 a=0,正是因为静态块和 a = 1 同属 <clinit>,按源码顺序执行,此时赋值语句还没轮到。
三、初始化的六个时机
只有主动使用才触发初始化,且父类先于子类:
| 场景 | 是否触发 |
|---|---|
new、读写静态字段、调用静态方法 | 是 |
反射 Class.forName("X") | 是(默认 initialize=true) |
| 初始化子类时,父类先初始化 | 是 |
| 虚拟机启动时的主类(含 main) | 是 |
MethodHandle / VarHandle 解析到该类的静态成员 | 是 |
| 通过子类引用父类静态字段 | 否(只初始化父类) |
定义该类型的数组 new X[0] | 否 |
引用编译期常量 static final | 否(已内联到调用方常量池) |
class Parent {
static { System.out.println("Parent init"); }
static int value = 10;
}
class Child extends Parent {
static { System.out.println("Child init"); }
}
public class PassiveRef {
public static void main(String[] args) {
Parent[] arr = new Parent[3]; // 不初始化
System.out.println(Parent.value); // 只初始化 Parent
Child[] children = new Child[1]; // 仍不初始化 Child
}
}
// 输出:
// Parent init
// 10
四、<clinit> 的线程安全
JVM 对 <clinit> 加锁:多线程同时初始化同一个类时,只有一个线程执行,其余阻塞等待,执行完再唤醒。这也意味着静态块里做耗时操作会拖住所有使用该类的线程,甚至造成死锁——两个类在各自的 <clinit> 里互相等待对方初始化完成,就是经典的类初始化死锁。
public class ClinitLock {
static {
try {
Thread.sleep(2000); // 静态块里做 I/O,其他线程全部卡住
} catch (InterruptedException ignored) {
}
}
public static void main(String[] args) {
new Thread(() -> System.out.println("t1: " + System.currentTimeMillis())).start();
new Thread(() -> System.out.println("t2: " + System.currentTimeMillis())).start();
}
}
五、常见追问
| 追问 | 回答要点 |
|---|---|
| 加载和初始化是一回事吗 | 不是。加载把字节流变成 Class 对象,初始化执行 <clinit>;两者可能相隔很久 |
Class.forName 和 loadClass 区别 | 前者默认初始化,后者只加载不初始化;JDBC 驱动用 forName 正是要触发静态块注册 |
接口有 <clinit> 吗 | 有,但不要求先初始化父接口,只有真正用到父接口的常量时才初始化它 |
| 解析一定要在初始化前完成吗 | 不必须。JVM 规范允许延迟解析,只要在首次使用符号引用前完成即可 |
| 数组类怎么加载 | 数组类由 JVM 直接创建,元素类型由对应类加载器加载,数组类本身没有 <clinit> |
小结
五个阶段里最容易被面试追问的是「准备赋零值、初始化赋真值」和「主动使用才初始化」。记住 <clinit> 只跑一次且带锁,静态块里就别放重活了。