Skip to content

02.1 - 类加载生命周期

定位: Class 文件从二进制字节流到可运行对象的完整蜕变过程 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 7 阶段流程、各阶段动机、主动引用 vs 被动引用、初始化触发条件

一、这是什么?为什么需要它?

Java 类的两个世界之间存在一条鸿沟:

┌─ 编译时世界 ─────────────────┐     ┌─ 运行时世界 ────────────────┐
│  .java 源码                   │     │  堆上的对象实例              │
│      ↓                        │     │  方法区的类元数据           │
│  .class 字节流 (静态结构)      │ →   │  执行引擎的栈帧操作         │
│  常量池 / 字段表 / 方法表      │     │  运行时常量池的动态解析      │
└────────────────────────────────┘     └────────────────────────────┘

类加载机制就是跨越这条鸿沟的桥梁。它把 Class 文件这种静态存储格式,转化为 JVM 运行时可以操作的数据结构

为什么需要这么复杂的生命周期? 因为一个简单的"读取→加载"两步走不足以解决以下问题:

  1. 安全: 字节码在加载前必须经过严格校验(防止恶意构造的 Class 文件)
  2. 性能: 有些工作可以延迟到真正需要时再做(迟绑定)
  3. 可靠性: 多线程下类的初始化只能执行一次,必须保证线程安全
  4. 动态性: 不是所有符号引用在编译时就确定了(多态本质)

二、原理拆解

2.1 为什么 7 个阶段是交错而非串行的?

教科书上列出:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载。

但 JVM 规范明确允许交错执行

时间线 ────────────────────────────────────────────────────────────→

阶段:    加载    验证    准备    解析    初始化    使用      卸载
        ┌────┐
 线程A  │加载│══════════╗
        └────┘          ║
        ┌──────┐        ║
 线程B  │验证  │══════════╗           ← 加载和验证可并行
        └──────┘        ║          ║
        ┌──────┐        ║          ║
 线程C  │准备  │════════╝══════════╝  ← 准备独立
        └──────┘
        ┌──────┐
 线程D  │解析  │═══════════╗          ← 解析可延迟
        └──────┘           ║
        ┌────────┐         ║
 线程E  │初始化  │═════════╝          ← 触发时解析已完成
        └────────┘
  • 加载和验证交错: JVM 一边读取字节流,一边对已读取的部分做格式校验。不需要等整个 Class 文件读完才开始验证
  • 解析延迟: 实现可以使用迟到绑定 (late resolution / lazy binding)——字节码指令第一次引用符号引用时才去解析。除了被 invokedynamic 引用的指令必须提前解析外,其他都可以延迟
  • 为什么允许延迟? 支持多态。实际调用的方法可能只在运行时才能确定,提前解析可能解析到错误的目标

2.2 加载 (Loading)

核心任务: 根据全限定名获取二进制字节流 → 在方法区构造类结构 → 在堆上创建 Class<?> 对象

┌────────────────────────────────────────────────────────────────┐
│                     加载 (Loading)                              │
│                                                                │
│  "com.example.Foo"                                              │
│          │                                                      │
│          ▼                                                      │
│  ① 获取二进制字节流                                              │
│     ┌─ 文件系统 (.class 文件)                                    │
│     ├─ JAR 包                                                    │
│     ├─ 网络 (URLClassLoader)                                    │
│     ├─ 数据库 BLOB                                              │
│     └─ 动态生成 (代理类 / ASM 生成的字节码)                       │
│          │                                                      │
│          ▼                                                      │
│  ② 解析字节流 → 方法区数据结构                                   │
│     ┌ 类元数据 (字段、方法、接口)                                 │
│     ├ 运行时常量池 (符号引用表)                                   │
│     └ 方法字节码 (Code 属性)                                     │
│          │                                                      │
│          ▼                                                      │
│  ③ 在堆上创建 Class<?> 对象                                      │
│     └ 这个对象是 Java 反射 API 的入口                             │
│        (getClass(), Class.forName(), 字节码中的 .class)         │
└────────────────────────────────────────────────────────────────┘

为什么堆上也要有 Class 对象? 方法区存储的是 JVM 内部使用的类元数据(指针、偏移量、vtable),不对外暴露。但 Java 开发者需要通过反射获取类信息——obj.getClass()Class.forName()Foo.class。堆上的 Class<?> 对象就是给 Java 代码用的门面,其内部持有指向方法区类元数据的指针。

方法区的数据是 JVM 管理的内部结构,堆上的 Class 对象是 Java 代码可以操作的入口。二者通过指针关联。

2.3 验证 (Verification)

核心任务: 确保 Class 文件中的字节流符合 JVM 规范,不会危害 JVM 自身安全。

四层防御体系——JVM 采用层层递进的验证策略:

┌─ ① 文件格式验证 ─────────────────────────────┐
│  检查: 魔数 0xCAFEBABE? 版本号是否支持?       │
│  目的: 第一道门,快速过滤损坏/非法文件          │
│  失败: ClassFormatError                       │
└──────────────────────────────────────────────┘


┌─ ② 元数据验证 ───────────────────────────────┐
│  检查: 是否有父类(除 Object)? 是否继承了final类?│
│  方法/字段是否存在与父类冲突?                    │
│  目的: 语义层面的合法性检查                     │
│  失败: VerifyError                             │
└──────────────────────────────────────────────┘


┌─ ③ 字节码验证 ──── 最复杂 ──────────────────┐
│  检查: 操作数栈类型是否匹配? 局部变量表赋值前    │
│  是否被读取? 跳转指令是否指向合法位置?           │
│  目的: 确保指令执行不会破坏 JVM 内部状态        │
│  方法: 类型推导 (StackMapTable, JDK 6+)       │
│  失败: VerifyError                             │
└──────────────────────────────────────────────┘


┌─ ④ 符号引用验证 ─────────────────────────────┐
│  检查: 引用的类/字段/方法是否存在? 当前类有     │
│  访问权限吗?                                   │
│  目的: 确保解析阶段能成功完成                   │
│  失败: NoClassDefFoundError /                  │
│        IllegalAccessError / NoSuchFieldError   │
└──────────────────────────────────────────────┘

为什么需要四层?防御纵深原则。

  • 第一层快速淘汰明显损坏的文件,不需要深入解析
  • 第二层捕获语义问题(比如继承了 final 类)
  • 第三层最耗时但最关键——分析数百条字节码指令的执行路径,确保类型安全
  • 第四层在解析前做最后一次权限检查

可以用 -Xverify:none 关闭验证(JDK 13 废弃),启动速度会快但 JVM 安全风险增加。生产环境不要关闭。

2.4 准备 (Preparation)

核心任务: 为类变量(static 字段)在方法区分配内存,并设置零值

java
public class Example {
    private static int count = 10;    // 准备阶段: count = 0
    private static final int MAX = 100; // 准备阶段: MAX = 100 (ConstantValue 属性)
    private static Object obj = new Object(); // 准备阶段: obj = null
}

为什么是零值而不是初始值? 准备阶段是在方法区上"画格子"——分配好内存空间,写入默认值(JVM 定义的各种类型的零值)。Java 源码中写的 = 10初始化值,需要在 &lt;clinit&gt; 方法中执行字节码指令 bipush 10 + putstatic 才能赋值。

唯一例外:static final 常量。如果常量值在编译期已知,javac 会在字段上标记 ConstantValue 属性,准备阶段直接赋值,不需要等到 &lt;clinit&gt;

2.5 解析 (Resolution)

核心任务: 将运行时常量池中的符号引用替换为直接引用(内存地址 / 偏移量)。

解析前 (运行时常量池)                   解析后 (运行时常量池)
┌──────────────────────────┐          ┌──────────────────────────┐
│ #1 = Class               │          │ #1 = Class 引用          │
│     "java/lang/String"   │  解析    │     [指向方法区 String    │
│                          │ ─────→  │      类元数据的指针]       │
│ #2 = Methodref           │          │                          │
│     #1.#3                │          │ #2 = Methodref           │
│                          │          │     [指向 vtable 中      │
│ #3 = NameAndType         │          │      方法入口地址]        │
│     "length":"()I"       │          │                          │
└──────────────────────────┘          └──────────────────────────┘

为什么可以延迟解析(迟到绑定)? Java 的多态本质——编译时不知道实际调用哪个方法。比如:

java
List<String> list = new ArrayList<>();
list.add("hello");  // invokeinterface #N

编译时只知道 listList 类型,add 方法在 List 接口中声明。但运行时 list 指向 ArrayList 对象,实际调用的是 ArrayList.add()。如果提前解析,解析到 List.add() 的地址是接口方法,不是实际实现类的方法——所以必须等到运行时实际类型确定后再解析(或至少确认虚分派的目标)。

2.6 初始化 (Initialization)

核心任务: 执行 &lt;clinit&gt; 方法,为类变量赋值初始值,执行静态代码块。

JVM 如何保证线程安全的初始化?

线程A 触发 Example 类初始化           线程B 同时触发 Example 类初始化
         │                                    │
         ▼                                    ▼
  获取 Class 对象的初始化锁            尝试获取初始化锁 → BLOCKED


  检查是否正在初始化 → 否


  标记状态为 "正在初始化"                     │
         │                                    │
  执行 <clinit>                           BLOCKED (等待)


  标记状态为 "初始化完成"                      │
         │                                    │
  唤醒等待线程                             被唤醒 → 初始化已完成
  释放锁                                   直接使用 Class

这种机制保证了 &lt;clinit&gt; 在任意时刻、任意线程下只会执行一次

触发初始化的条件(主动引用,只有这 6 种会触发初始化):

场景示例代码说明
new 关键字new Foo()创建对象实例
调用静态方法Foo.staticMethod()直接访问静态方法
访问静态字段(非 final)int x = Foo.count读取/写入静态 field
反射调用Class.forName("Foo")反射 API 触发
初始化子类new SubFoo() (SubFoo extends Foo)先初始化父类
main 方法所在类java FooJVM 启动入口

不触发初始化的场景(被动引用):

java
// ① 通过子类引用父类的静态字段 —— 只会触发父类初始化,不会触发子类
class Parent { static int x = 1; static { System.out.println("Parent init"); } }
class Child extends Parent { static { System.out.println("Child init"); } }
public class Test {
    public static void main(String[] args) {
        System.out.println(Child.x);  // 输出 "Parent init" + "1",不会输出 "Child init"
    }
}

// ② 通过数组定义引用类 —— 不会触发初始化
Foo[] arr = new Foo[10];  // 不触发 Foo 的初始化 (触发的是数组类 [LFoo 的初始化)

// ③ 引用编译期常量 (static final) —— 常量在编译时已被内联
class Const {
    static final String MSG = "hello";  // 编译期常量,通过 ConstantValue 属性直接赋值
    static { System.out.println("Const init"); }
}
public class Test {
    public static void main(String[] args) {
        System.out.println(Const.MSG);  // 输出 "hello",不会输出 "Const init"
    }
}

2.7 使用与卸载

使用: 对象在堆上被创建、使用、GC 回收。Class<?> 对象继续存在于堆上,方法区保存类元数据。

卸载: 当以下 3 个条件同时满足时,类可以被卸载(GC 从方法区回收类元数据):

  1. 该类所有的 Class<?> 对象都被 GC(堆上无引用)
  2. 该类的 ClassLoader 被 GC(加载器自身不可达)
  3. 该类不再通过反射被访问

现实: 只有自定义 ClassLoader 加载的类才可能被卸载。Bootstrap / Ext / App ClassLoader 是 JVM 内置的,始终可达,它们的类不会被卸载。

三、图解全景

┌───────────────────────────────────────────────────────────────────────┐
│                    类加载 7 阶段甘特图 (按时间线)                       │
│                                                                      │
│  阶段名称        │ 时间 ──────────────────────────────────────────→   │
│  ────────────────┼────────────────────────────────────────────────── │
│  ① 加载          │ ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│                  │                   并行交错                         │
│  ② 验证          │ ░░░░████████████████████████████░░░░░░░░░░░░░░░░ │
│                  │                                                   │
│  ③ 准备          │ ░░░░░░░░██████████████░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│                  │                                                   │
│  ④ 解析          │ ░░░░░░░░░░░░░░░░██████░░░░░░░░░░███░░░░░░░░░░░░░ │
│                  │              ↑  初始解析  ↑  第一次使用某符号引用时  │
│                  │                                                   │
│  ⑤ 初始化        │ ░░░░░░░░░░░░░░░░░░░░████████████░░░░░░░░░░░░░░░░ │
│                  │                  ↑ new / getstatic / 反射等触发   │
│                  │                                                   │
│  ⑥ 使用          │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░██████████████████░░ │
│                  │                                                   │
│  ⑦ 卸载          │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░████████ │
│                  │             ClassLoader + Class 对象都不可达时    │
└───────────────────────────────────────────────────────────────────────┘

四、实战验证

下面通过代码验证 主动引用 vs 被动引用 以及 线程安全的初始化:

java
// ClassInitDemo.java
class Parent {
    static int value = 42;
    static {
        System.out.println("Parent <clinit> executed by " + Thread.currentThread().getName());
    }
}

class Child extends Parent {
    static {
        System.out.println("Child <clinit> executed by " + Thread.currentThread().getName());
    }
}

class LazyField {
    static final String CONST = "compile-time constant";
    static {
        System.out.println("LazyField <clinit> executed");
    }
}

public class ClassInitDemo {
    public static void main(String[] args) throws Exception {
        System.out.println("=== 1. 通过子类引用父类静态字段 ===");
        System.out.println(Child.value);
        // 验证: Child 的 <clinit> 没有执行,Parent 的 <clinit> 执行了

        System.out.println("\n=== 2. 引用编译期常量 ===");
        System.out.println(LazyField.CONST);
        // 验证: LazyField 的 <clinit> 没有执行

        System.out.println("\n=== 3. 通过数组引用类 ===");
        LazyField[] arr = new LazyField[10];
        // 验证: LazyField 的 <clinit> 没有执行

        System.out.println("\n=== 4. Class.forName 强制初始化 ===");
        Class.forName("LazyField");
        // 验证: 此时才触发 LazyField 的 <clinit>

        System.out.println("\n=== 5. 多线程初始化保证 ===");
        Thread t1 = new Thread(() -> { new Child(); }, "T1");
        Thread t2 = new Thread(() -> { new Child(); }, "T2");
        t1.start(); t2.start();
        t1.join();  t2.join();
        // 验证: Child 的 <clinit> 只会输出一次
    }
}

编译运行:

bash
javac ClassInitDemo.java
java ClassInitDemo

预期输出(注意 &lt;clinit&gt; 只执行一次):

=== 1. 通过子类引用父类静态字段 ===
Parent <clinit> executed by main
42
=== 2. 引用编译期常量 ===
compile-time constant
=== 3. 通过数组引用类 ===
=== 4. Class.forName 强制初始化 ===
LazyField <clinit> executed by main
=== 5. 多线程初始化保证 ===
Parent <clinit> executed by T1
Child <clinit> executed by T1
(注意:T2 不会再次执行 <clinit>,阻塞后直接使用)
Child value = 42
Child value = 42

五、面试视角

追问答案要点
类加载有哪 7 个阶段?哪些可以交错?加载→验证→准备→解析→初始化→使用→卸载。加载与验证可交错,解析可延迟到首次使用,准备独立于验证
为什么准备阶段赋零值而不是初始值?准备只是"画格子"分配内存,初始值需要在 &lt;clinit&gt; 中通过字节码指令完成。唯一例外是 static final 编译期常量
什么是主动引用?什么是被动引用?主动引用 6 种:new、调用静态方法、访问非 final 静态字段、反射、初始化子类、main 类。被动引用如通过子类引用父类静态字段、数组定义、引用编译期 final 常量
&lt;clinit&gt; 的执行流程是怎样的?线程安全如何保证?JVM 在 Class 对象上持有初始化锁。第一个线程获取锁后标记"正在初始化",其他线程阻塞等待。初始化完成后唤醒等待线程——保证只执行一次
为什么解析可以延迟?支持多态。编译时不知道实际类型,运行时才能确定符号引用的目标。迟到绑定让方法调用地址可以在运行时才解析
解析和初始化哪个先发生?二者没有固定先后顺序。JVM 规范允许在初始化完成后才开始解析(只要在字节码指令实际使用前完成解析即可)
类的卸载条件是什么?ClassLoader 不可达 + 该类的 Class 对象不可达 + 类不再被反射使用。只有自定义 ClassLoader 加载的类才可能被卸载
验证阶段的字节码验证是最复杂的——具体检查什么?操作数栈类型一致、局部变量使用前已赋值、跳转目标合法、类型转换安全。JDK 6+ 使用 StackMapTable 进行类型推导验证

📚 相关链接

  • **Class文件结构** — 类加载的输入:Class 文件结构
  • **双亲委派模型与打破** — 类加载器如何协调加载请求
  • **自定义类加载器实战** — 自定义加载器如何影响生命周期
  • **方法区与元空间** — 类加载完成后类元数据存储在方法区
  • ← 返回 **类加载索引**

Knowledge4J — Java 知识库