第2章-类加载子系统-知识补充
第2章-类加载子系统-知识补充
补充真实案例来帮助理解类加载机制的过程。
1-类加载过程
在Java中,类加载过程分为:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
✅ 以你给出的类为例:
public class Persson {private static final String PERSON_PREFIX = "人类";private static final Int PERSON_MAX_AGE = 150;private String name;private int age;
}
一、类加载过程(简化说明)
1. 加载(Loading)
- JVM 使用 类加载器(ClassLoader) 加载
Persson.class文件。 - 类信息(类名、字段、方法、常量池等)被加载到 方法区(Method Area)(在 HotSpot 中,JDK8+ 是 元空间 Metaspace)。
2. 验证(Verification)
- 验证字节码是否符合 JVM 规范。
3. 准备(Preparation)
- 为 类变量(static 字段) 分配内存并设置 默认值。
- 注意:
final的常量(如PERSON_PREFIX和PERSON_MAX_AGE)在准备阶段就会被赋值为 编译期确定的常量值。
4. 解析(Resolution)
- 将符号引用解析为直接引用(如类、方法、字段的引用)。
5. 初始化(Initialization)
- 执行
<clinit>()方法(类构造器),初始化静态变量和静态代码块。 - 由于你的类中所有
static字段都是final且编译期可确定值,不会生成<clinit>()方法。
二、运行时数据区(Runtime Area)分配
| 项目 | 存储位置 | 说明 |
|---|---|---|
| 类元信息(类名、字段、方法、访问修饰符等) | 方法区(Metaspace) | 类加载后,类的结构信息存储在方法区。 |
| 常量池(如字符串常量 “人类”) | 方法区(Metaspace) | 字符串字面量 "人类" 会进入 运行时常量池,并在字符串池中创建对应对象。 |
| static final 基本类型常量(如 PERSON_MAX_AGE = 150) | 方法区(Metaspace) | 编译期常量,直接嵌入到使用处,不占用额外空间。 |
| 实例字段(如 name, age) | Java 堆(Heap) | 每次 new Persson() 时,这些字段作为对象的一部分存储在堆中。 |
三、总结一句话
类信息、常量池、static final 常量 存储在 方法区(Metaspace);实例字段 存储在 堆(Heap) 中,方法调用 使用 Java 栈(JVM Stack)。
🔍 补充说明:字符串常量 "人类"
- 字符串常量
"人类"会被放入 字符串常量池(String Pool),这是 堆中的一部分(JDK7+ 开始从方法区移到堆)。 - 所以
"人类"实际是 堆中的一个 String 对象,但引用存储在 运行时常量池(方法区)。
2-类加载过程
public class HelloLoader {public static void main(String[] args) {System.out.println("谢谢ClassLoader加载我....");System.out.println("你的大恩大德,我下辈子再报!");}
}
当 JVM 执行 java HelloLoader 时,类的生命周期从“被加载”到“被卸载”一共要经过 7 大阶段。下面把镜头对准 HelloLoader.class,完整走一遍这 7 个阶段,并指出每一步在内存里到底留下了什么痕迹。阅读时可以把“方法区”自动替换成“元空间(Metaspace)”,因为 HotSpot 8 以后就没有永久代了。
- Loading(加载)
- 启动类加载器(Bootstrap ClassLoader)先把 rt.jar 里的
java/lang/Object、java/lang/System、java/io/PrintStream等一股脑装进方法区。 - 用户类路径(-cp 或 CLASSPATH)下的
HelloLoader.class由 应用类加载器(AppClassLoader) 负责读取字节码。 - 读完之后,JVM 在 方法区 生成一个
InstanceKlass结构(C++ 对象),里面装着:- 魔数、版本号、常量池(ConstantPool)
- 字段表、方法表、访问标志(ACC_PUBLIC、ACC_STATIC 等)
- 字节码指令数组(
main方法对应的 Code 属性)
- 同时在 Java 堆 里创建一个
java.lang.Class<HelloLoader>镜像对象,作为InstanceKlass的“Java 视图”,后面所有HelloLoader.class都指它。 - 把类加载器(AppClassLoader)记录到该
InstanceKlass的“类加载器”字段里,形成“加载器-类”双向绑定。
- Verification(验证)
- 文件格式验证:魔数 0xCAFEBABE、主次版本号是否支持。
- 元数据验证:检查
main方法签名是否合规(public static void main(String[]))。 - 字节码验证:用类型推导(StackMapTable)保证操作数栈不会溢出、不会把 int 当引用用。
- 符号引用验证:看看
java/lang/System.out:Ljava/io/PrintStream;在 rt.jar 里能不能找到。
- Preparation(准备)
- 只为 类变量(static 字段) 分配内存并设 零值。
HelloLoader里没有 static 字段,所以这一步只是 把常量池里的符号引用“标好号”,等待下一步解析。- 注意:方法区里已经给
ConstantPool分配了内存,但还没把CONSTANT_Methodref、CONSTANT_String等符号变成真正的指针。
- Resolution(解析)
- 把常量池的符号引用“翻译”成 直接引用(内存地址或句柄)。
–java/lang/System.out解析成PrintStream对象的 字段地址。
–java/io/PrintStream.println(Ljava/lang/String;)V解析成 方法指针。 - 字符串字面量
"谢谢ClassLoader加载我...."被解析为 堆中 String 对象(StringTable 驻留)。 - 类、接口、字段、方法、方法类型、方法句柄、调用点限定符 7 类常量全部“转正”。
- Initialization(初始化)
- 执行
<clinit>()方法。HelloLoader没有 static 变量,也没有 static 代码块,因此 不会生成<clinit>(),这一步直接跳过。 - JVM 规定:初始化阶段必须“线程安全”,内部用
ClassLoader对象做同步锁,保证多线程下只初始化一次。
- 执行 main 方法
- JVM 在 当前线程的 Java 栈 里压入一个 栈帧(Stack Frame),局部变量表只有一项:args。
- 解释器(或 JIT 编译后的汇编)开始执行字节码:
getstatic #2 // System.out ldc #3 // "谢谢ClassLoader加载我...." invokevirtual #4 // PrintStream.println(String) - 两条
println指令执行完后,栈帧弹出,main 方法返回。 - 字符串常量
"谢谢ClassLoader加载我...."和"你的大恩大德,我下辈子再报!"此时已在 堆的 StringTable 中驻留,且被PrintStream引用过一次。
- Unloading(卸载,可选)
- 当
HelloLoader的 类加载器实例(AppClassLoader) 被 GC 判定为“不可达”时,它加载的所有类才会被卸载。 - 对于单次
java HelloLoader进程,主线程结束、JVM 退出,整个方法区和堆都被操作系统回收,卸载逻辑由操作系统一次性完成,无需 JVM 级卸载。
一张图总结内存布局
方法区(Metaspace)
├── InstanceKlass<HelloLoader>
│ ├── ConstantPool(14 项常量:类/方法/字符串引用)
│ ├── method[1] = {name=main, access=ACC_PUBLIC|ACC_STATIC, code=...}
│ └── ...
└── 无 <clinit>(因为无 static 字段/代码块)Java 堆
├── java.lang.Class<HelloLoader> (Class 镜像对象)
├── java.lang.String("谢谢ClassLoader加载我....") (StringTable 驻留)
├── java.lang.String("你的大恩大德,我下辈子再报!")
└── PrintStream 对象(System.out 引用自 rt.jar)Java 栈(线程私有)
└── main 栈帧├── 局部变量表:args└── 操作数栈:执行 getstatic/ldc/invokevirtual 时进/出栈
一句话回顾
HelloLoader 被 AppClassLoader 装进方法区 → 常量池符号解析成直接引用 → 字符串入堆并驻留 → 主线程栈帧执行 main → 进程退出后方法区与堆一起被操作系统回收。
3-类加载过程
public class HelloApp {private static int a = 1; public static void main(String[] args) {System.out.println(a);}
}
把镜头继续对准 HelloApp,这一次类里 终于有了 static 变量。
从“类加载”到“main 方法执行”的完整 7 阶段,重点看 准备、初始化 两步的内存变化,以及 () 的生成与执行。
- 前置约定
- 方法区 ≈ Metaspace(C++ 侧的
InstanceKlass) - 类变量(static)≠ 实例变量;前者在方法区,后者在对象里(堆里)
<clinit>()由编译器 自动合成,专门用来执行 static 变量赋值 + static 代码块
- Loading(加载)
AppClassLoader 读取 HelloApp.class,在 Metaspace 创建 InstanceKlass<HelloApp>,并在堆生成 java.lang.Class<HelloApp> 镜像对象。
此时 a 的字面量“1”还躺在常量池里,字段表只标记了:
name=a, descriptor=I, access=ACC_PRIVATE|ACC_STATIC|ACC_FINAL(no)
- Verification(验证)
常规检查:魔数、版本号、字节码类型安全、符号引用是否存在。
getstatic java/lang/System.out / invokevirtual PrintStream.println(I) 等引用解析前会被校验。
- Preparation(准备)—— 关键一步
- 为类变量 a 分配内存(在
InstanceKlass的 静态字段块 里,Metaspace 背后是一整块 C++ 内存)。 - 赋“零值” → 此时 a 的值是 0,不是源代码里的 1!
(int 的零值是 0,引用是 null,boolean 是 false) - 源代码里的“1”仍躺在常量池的
ConstantInteger_info里,尚未生效。
- Resolution(解析)
把常量池里的符号引用翻译成直接引用:
java/lang/System.out→ 真正的PrintStream对象地址Methodref HelloApp.a:I→ 字段地址(指向 Metaspace 静态区)String_info/Methodref println(I)等同样被解析。
- Initialization(初始化)—— 重头戏
5.1 编译器自动生成 <clinit>() 字节码
因为存在“显式赋值语句”:
private static int a = 1;
编译器把它搬到 <clinit>() 里,等价于:
static {a = 1;
}
生成的字节码只有 2 条指令:
iconst_1
putstatic #2 // Field a:I
return
5.2 JVM 获取 ClassLoader 的对象锁,确保多线程下单例初始化。
5.3 执行 <clinit>() → a 从 0 变成 1。
5.4 把 InstanceKlass 的 初始化标志位 设为 “fully initialized”,后续再也进不来。
- 执行 main 方法
- 当前线程 Java 栈压入新帧:局部变量表 args。
- 字节码序列:
getstatic #3 // System.out
getstatic #2 // HelloApp.a:I
invokevirtual #4 // PrintStream.println(int)
return
- 操作数栈:
–getstatic #3把PrintStream引用压栈
–getstatic #2把 1 压栈(此时已不再是 0)
–invokevirtual消费栈顶两个 slot,调用println(int) - 控制台输出:
1
- Unloading(卸载)
与上一个例子相同:只有当 AppClassLoader 本身不可达时,HelloApp 的 InstanceKlass 和静态变量内存才会被 GC 回收。对于一次性 java HelloApp 进程,整个 JVM 退出即全部回收。
内存时间线一览
| 阶段 | a 的真实值 | 存储位置 | 说明 |
|---|---|---|---|
| 加载后 | 未存在 | — | 仅字段表 |
| 准备后 | 0 | Metaspace 静态区 | 零值规则 |
| 后 | 1 | Metaspace 静态区 | 执行 putstatic |
| main 运行时 | 1 | Metaspace 静态区 | getstatic 读取 |
一句话总结
准备阶段给 static 变量 a 分配内存并置 0;初始化阶段执行编译器生成的 <clinit>() 把 1 写进去;后续所有 getstatic 都从 方法区(Metaspace)里的静态字段块 拿到最新值。
4-类加载过程
public class ClinitTest {private int a = 1;public static void main(String[] args) {int b = 2;}
}
继续跟拍 ClinitTest,这次类里 只有实例变量 a 和局部变量 b,没有任何 static 字段或 static 代码块。
因此:
- 不会生成 ()(类初始化方法)
- 只会生成 ()(实例初始化方法,供构造方法调用)
- 局部变量 b 完全活在 Java 栈帧的局部变量表 里,跟堆/方法区无关
下面按 7 阶段走一遍,重点看 “没有类初始化” 时 JVM 在内存里干了啥。
- Loading(加载)
AppClassLoader 读取 ClinitTest.class,在 Metaspace 创建 InstanceKlass<ClinitTest>,字段表记录:
name=a, descriptor=I, access=ACC_PRIVATE // 注意:没有 ACC_STATIC
常量池里只有 ClinitTest 自身、java/lang/Object、java/lang/System 等符号引用。
- Verification(验证)
常规检查:字节码合法、栈映射表正确、符号引用存在。
因为 没有 static 字段,准备阶段无需赋零值。
- Preparation(准备)
- 类变量(static)0 个 → 什么都不做,a 是实例字段,内存分配推迟到 new 对象 时。
- 零值规则对 a 不适用;a 的“0”值要等 对象在堆里分配 时才出现。
- Resolution(解析)
把常量池里的符号引用转成直接引用:
java/lang/Object→ 已加载的InstanceKlass地址java/lang/System.out→PrintStream对象地址(虽然本例没用,但解析阶段仍可能提前)
- Initialization(初始化)
关键结论:
- 编译器 不会生成 ()(因为无 static 字段 / static 代码块)
- 因此初始化阶段 什么都不执行,直接标记
InstanceKlass为 “fully initialized” - 实例字段 a 的赋值 推迟到构造方法(即 ())
- 执行 main 方法
6.1 线程 Java 栈压入 main 栈帧
局部变量表 slot 0:args
局部变量表 slot 1:b(int 2)
6.2 字节码
iconst_2
istore_1
return
iconst_2把 2 压操作数栈istore_1存到局部变量表 slot 1 → b = 2- 全程没有访问堆/方法区,只在栈里完成
6.3 方法返回,栈帧弹出 → b 随帧销毁
- 对象创建场景(补充说明)
虽然 main 里没有 new ClinitTest(),但如果哪天代码里出现:
ClinitTest obj = new ClinitTest();
JVM 会:
- 在 Java 堆 分配对象内存,对象头 + 实例字段 a
- 把 a 赋 零值(0)
- 调用 ()(实例初始化器),把 1 写进 obj.a
对应字节码:aload_0 invokespecial java/lang/Object.<init> aload_0 iconst_1 putfield ClinitTest.a:I return
内存分布小结
| 元素 | 存储区域 | 生命周期 | 初始值来源 |
|---|---|---|---|
| 类元数据(ClinitTest) | Metaspace | 与类加载器相同 | 加载时建立 |
| 实例字段 a | Java 堆(对象内) | 与对象相同 | new 时先 0, 再 1 |
| 局部变量 b | Java 栈(帧内) | 方法调用期 | iconst_2 显式赋值 |
一句话总结
ClinitTest 没有 static 上下文,类初始化阶段空转;实例字段 a 的赋值延迟到 构造方法 ();局部变量 b 完全在 main 栈帧里自生自灭。
5-类加载过程
public class ClinitTest {private int a = 1;private static int c = 3;public static void main(String[] args) {int b = 2;}
}
这一次 ClinitTest 里 既有实例变量 a,又有 static 变量 c,于是:
- 会生成 () 负责 static 字段 c 的初始化
- 会生成 () 负责实例字段 a 的初始化
- main 里的局部变量 b 仍只在 栈帧 里存活
下面按 7 阶段把两条初始化路径彻底拆开。
- Loading(加载)
AppClassLoader 读入 ClinitTest.class,Metaspace 里建立 InstanceKlass,字段表新增两条记录:
a: I private // 实例字段
c: I private static // 类字段
- Verification(验证)
常规字节码、符号引用检查,确保 putstatic c / putfield a 的类型安全。
- Preparation(准备)—— 关键差异点
- 类变量 c
– 在 Metaspace 的 静态字段块 分配内存
– 赋 零值 → 此时 c = 0 - 实例变量 a
– 什么都不做,等 new 对象 时再处理
- Resolution(解析)
把常量池里的符号引用转成直接引用:
java/lang/System、java/lang/Object、ClinitTest.c:I 等全部“转正”。
- Initialization(初始化)—— () 登场
5.1 编译器自动生成 <clinit>() 字节码,等价于:
static {c = 3;
}
实际指令:
iconst_3
putstatic #5 // Field c:I
return
5.2 JVM 获取类初始化锁,确保多线程单例执行
5.3 执行完毕后 c 从 0 → 3
5.4 标记 InstanceKlass 为 fully initialized,后续再也进不来
- 执行 main 方法
6.1 线程 Java 栈压入 main 栈帧
局部变量表 slot 0:args
局部变量表 slot 1:b
6.2 字节码
iconst_2
istore_1
return
- 与类/对象毫无交互,只在栈内完成
- 方法返回后 b 随帧销毁
- 如果 new 一个对象(补充 路径)
虽然 main 里没写 new,但只要出现:
ClinitTest obj = new ClinitTest();
会触发:
- 在 Java 堆 分配对象内存(含字段 a)
- 先把 a 填 零值 0
- 调用 () 实例初始化器:
把 1 写进 obj.aaload_0 invokespecial java/lang/Object.<init> aload_0 iconst_1 putfield #3 // Field a:I return - 返回对象引用给 obj
内存时间线一览
| 阶段 | c 值 | a 值 | 存储位置 |
|---|---|---|---|
| 准备后 | 0 | — | Metaspace 静态区 |
| 后 | 3 | — | Metaspace 静态区 |
| new 对象后 | 3 | 0→1 | a 在堆内对象体 |
一句话总结
类初始化 <clinit>() 只负责 static 字段 c(0→3),实例字段 a 的赋值 延迟到构造方法 ();main 里的 b 依旧 纯栈内变量,与堆/方法区无交集。
6-类加载过程
public class ClassInitTest {private static int num = 1;private static int number = 10;// linking之prepare: number = 0 --> initial: 10 --> 20static {num = 2;number = 20;System.out.println(num); }public static void main(String[] args) {System.out.println(ClassInitTest.num);System.out.println(ClassInitTest.number);}
}
下面把 ClassInitTest 的完整生命周期拆开,重点看 “prepare 赋零值 → initial 赋初值 → static 块再改值” 的三级跳。
- 代码速览
public class ClassInitTest {private static int num = 1; // ① 编译期生成 <clinit> 赋值private static int number = 10; // ② 同上static { // ③ 编译器按 **源码顺序** 拼到 <clinit> 里num = 2;number = 20;System.out.println(num); // 打印 2}public static void main(String[] args) {System.out.println(ClassInitTest.num); // 2System.out.println(ClassInitTest.number); // 20}
}
- Loading(加载)
AppClassLoader 把 ClassInitTest.class 装进 Metaspace,生成 InstanceKlass,字段表:
num:I private static
number:I private static
常量池里除了类名、字段名,还多了 java/lang/System.out 以及 println(I) 的符号引用。
- Verification(验证)
常规字节码、符号引用检查,确保 putstatic num/number 类型正确。
- Preparation(准备)—— 零值阶段
- 在 Metaspace 静态变量块 分配内存
- 按类型赋零值 → 此时
num = 0 number = 0 - 源代码里的
1/10还躺在常量池ConstantInteger_info里,尚未生效
- Resolution(解析)
把 System.out、println(I) 等符号引用解析成直接引用(指针/句柄)。
- Initialization(初始化)—— () 一条龙
编译器把 “字段赋值 + static 块” 按 源码出现顺序 拼成 <clinit>():
0: iconst_1
1: putstatic #2 // num = 1
4: bipush 10
6: putstatic #3 // number = 10
9: iconst_2
10: putstatic #2 // num = 2
13: bipush 20
15: putstatic #3 // number = 20
18: getstatic #4 // System.out
21: getstatic #2 // num
24: invokevirtual #5 // println(int)
27: return
执行流程:
- num 0→1→2
- number 0→10→20
- 控制台输出 2(static 块里的 println)
- 执行 main
main 栈帧里两条 getstatic 分别读取:
num = 2
number = 20
所以打印:
2
20
- 卸载
当 AppClassLoader 不可达时,该类与静态变量内存被 GC 回收;单次 java ClassInitTest 进程退出即整体回收。
内存时间线速查
| 阶段 | num | number | 说明 |
|---|---|---|---|
| prepare 后 | 0 | 0 | 零值规则 |
| 前半 | 1 | 10 | 字段赋值 |
| 后半 | 2 | 20 | static 块覆盖 |
| main 读取 | 2 | 20 | 最终可见值 |
一句话总结
prepare 只给 零值,initial 按 源码顺序 执行 <clinit>():先字段赋值(1/10),再 static 块覆盖成(2/20),因此 main 永远拿到的是 最后一版。
7-类加载过程
public class ClinitTest {// 任何一个类声明以后,内部至少存在一个类的构造器private int a = 1;private static int c = 3;public static void main(String[] args) {int b = 2;}public ClinitTest() {a = 10;int d = 20;}
}
把镜头对准 ClinitTest,这一次重点区分 类初始化 <clinit>() 与 实例初始化 <init>() 的两条独立赛道:
<clinit>只干 static 的事(c = 3)<init>干 实例 的事:先给字段 a 赋默认值 0 → 字段显式赋值 a=1 → 构造器里 a=10- 局部变量 b、d 永远只在 栈帧 里存活
下面按 7 阶段拆给你看。
- Loading(加载)
AppClassLoader 把 ClinitTest.class 装进 Metaspace,生成 InstanceKlass,字段表:
a:I private // 实例字段
c:I private static // 类字段
方法表包含:
<clinit>()V // 编译器自动生成
<init>()V // 对应无参构造器
main([Ljava/lang/String;)V
- Verification(验证)
常规字节码/符号引用检查,确保 putstatic c、putfield a 类型正确。
- Preparation(准备)
- 类变量 c
– 在 Metaspace 静态区分配内存 → 零值 c = 0 - 实例变量 a
– 什么都不做,等 new 对象 时再处理
- Resolution(解析)
把 java/lang/Object、java/lang/System 等符号引用解析成直接引用。
- Initialization(类初始化)—— () 只做 static
编译器生成的 <clinit>() 等价于:
static {c = 3;
}
字节码:
iconst_3
putstatic #2 // Field c:I
return
执行后 c 从 0 → 3,类状态标为 fully initialized。
- 执行 main 方法
main 栈帧:
slot 0: args
slot 1: b
字节码:
iconst_2
istore_1
return
纯栈内操作,与堆/方法区无交集;方法结束 b 被弹出。
- 如果 new 一个对象—— () 赛道启动
示例代码:
ClinitTest obj = new ClinitTest();
JVM 内部顺序:
- 在 Java 堆 分配对象内存(含字段 a)
先 填零值 → a = 0 - 调用 ()(实例初始化器),编译器按 源码顺序 拼接:
结果:obj.a 的最终值是 10aload_0 invokespecial java/lang/Object.<init> // 父构造 aload_0 iconst_1 putfield #3 // a = 1 (字段显式赋值) aload_0 iconst_10 putfield #3 // a = 10 (构造器里再覆盖) iconst_20 istore_2 // d = 20 (局部变量,仅在此帧) return - 返回对象引用给 obj
内存时间线一览
| 阶段 | c 值 | a 值(对象未创建前) | a 值(对象创建后) | 存储位置 |
|---|---|---|---|---|
| prepare 后 | 0 | — | — | Metaspace 静态区 |
| 后 | 3 | — | — | Metaspace 静态区 |
| new 对象 先 | 3 | 0(零值) | 0 | Java 堆 |
| 字段赋 | 3 | 1 | 1 | Java 堆 |
| 构造器 | 3 | 10 | 10 | Java 堆 |
| 局部变量 d | — | — | — | 栈帧(随 结束销毁) |
一句话总结
<clinit>() 只负责把 static c 从 0 → 3;真正的对象字段 a 要经历 零值→1→10 的三级跳,全在 () 里完成;局部变量 b、d 永远 栈内自生自灭。
8-类加载过程
public class ClinitTest1 {static class Father {public static int A = 1;static {A = 2;}}static class Son extends Father {public static int B = A;}public static void main(String[] args) {// 加载Father类,其次加载Son类。System.out.println(Son.B); // 2}
}
这题是经典的 “父类 先于子类 执行” 场景。
只要记住一条 JVM 规则:
首次主动使用某个类时,它的全部祖先类会被自上而下、递归地初始化,且每个类只会初始化一次。
下面把 Son.B 这一行触发的完整链路拆开给你看。
- 触发点 —— 主动使用
main 里第一次 读取 Son.B,属于 “读取类的静态字段”,是 主动使用(JLS 5.4.3)。
于是 JVM 开始 初始化 Son;初始化 Son 之前必须 先初始化 Father(因为 Son extends Father)。
- 初始化顺序
2.1 先执行 Father 的 ()
编译器把“字段赋值 + static 块”按源码顺序拼成:
iconst_1
putstatic #2 // Father.A = 1
iconst_2
putstatic #2 // Father.A = 2
return
结果:Father.A = 2
(控制台无输出,因为 Father 里没有 println)
2.2 再执行 Son 的 ()
只有一行赋值:
getstatic #2 // Father.A(此时已是 2)
putstatic #3 // Son.B = Father.A
return
结果:Son.B = 2
- 类加载/初始化时序图
Loading (按需自顶向下)
Father.class -> Son.class↓ ↓
Verification Verification↓ ↓
Prepare Prepare
Father.A=0 Son.B=0↓ ↓
Resolution Resolution↓ ↓
Initialization
Father.<clinit>() → A=1→2↓
Son.<clinit>() → B=A=2
- 控制台输出
2
与代码注释完全一致。
- 如果反过来写 —— 反例验证
若把 Father.A 的 static 块改成:
static { A = B; } // 编译错误:非法前向引用
或
static { A = 2; System.out.println(Son.B); } // 非法:Son 尚未初始化
都会直接 编译期报错 或 运行时 IllegalAccessError,因为 JVM 强制 父类 完成之前子类尚未初始化。
- 卸载时机
两类的加载器都是 AppClassLoader,只有当该加载器不可达时才会被 GC 卸载;单次 java ClinitTest1 进程退出即整体回收。
一句话总结
先父后子 是铁律:Father 的 <clinit> 把 A 从 1→2 后,Son 的 <clinit> 才用 A 给 B 赋值,因此 Son.B 永远是 2。
9-类加载过程
public class DeadThreadTest {public static void main(String[] args) {Runnable r = () -> {System.out.println(Thread.currentThread().getName() + "开始");DeadThread dead = new DeadThread();System.out.println(Thread.currentThread().getName() + "结束");};Thread t1 = new Thread(r, "线程1");Thread t2 = new Thread(r, "线程2");t1.start();t2.start();}
}class DeadThread {static {if (true) {System.out.println(Thread.currentThread().getName() + "初始化当前类");while (true) {// This loop is intentional to demonstrate a deadlock scenario}}}
}
这段代码是教科书级的 “类初始化死锁” 示例:
利用 JVM 同一个类初始化锁(<clinit>() 锁)的 可重入 + 互斥 特性,让第一个抢到锁的线程在 static 块里写死循环,第二个线程就 永久阻塞 在类初始化阶段,连 main 都结束不了。
下面把底层锁机制、线程堆栈、以及为什么“停不下来”一次说清。
- 触发点 —— 首次 new DeadThread()
两个线程都执行到
DeadThread dead = new DeadThread();
属于 “主动使用” → 必须初始化 DeadThread 类 → 必须执行其 <clinit>()。
- JVM 的类初始化锁规则(JDK 8+ 依旧如此)
- 每个类对应 一把全局 Monitor,由 JVM 内部实现(不是
synchronized(XXX.class)那种用户可见锁)。 - 同一个类加载器下,类的
<clinit>()只能被一个线程执行,其余线程阻塞在锁上。 - 锁是 可重入的:如果线程已持有锁,再次进入同类静态域/方法不会死锁;但 别的线程绝对进不来。
- 时间线(假设 t1 先抢到锁)
t1: 进入 DeadThread.<clinit>()↓ 打印
"线程1初始化当前类"↓ 进入 while(true) { }↓ 永不退出,也 **不释放** 类初始化锁t2: 跑到 new DeadThread()↓ 尝试获取 DeadThread 的 <clinit> 锁↓ 锁已被 t1 占有 → **阻塞在 JVM 内部 monitor**↓ 表现为
java.lang.Thread.State: **RUNNABLE** // 底层其实是 native 阻塞,JVM 把状态抬成了 RUNNABLEat DeadThread.<clinit>(DeadThreadTest.java:19)at DeadThreadTest.lambda$main$0(DeadThreadTest.java:7)at java.lang.Thread.run(Thread.java:750)
用 jstack 可以看到 t2 的栈顶停在
- waiting to lock <0x00000000d5c8c0a0> (a java.lang.Class for DeadThread)
- 现象总结
- 控制台必现:
之后 程序永远挂起,CPU 100% 由 t1 的线程1开始 线程1初始化当前类while(true)吃掉。 - t2 既不会打印“线程2开始”,也不会打印“初始化当前类”,卡在类初始化锁。
jstack会看到 t1 状态 RUNNABLE(死循环),t2 也是 RUNNABLE(等锁,JVM 把 native 阻塞标成 RUNNABLE)。- 强制
Ctrl-C才能结束进程。
- 如何验证“类锁”而不是对象锁
把代码改成:
synchronized (DeadThread.class) { // 用户态 Class 对象锁while(true);
}
此时 t1 与 t2 都 不会阻塞在类初始化阶段,而是阻塞在 用户态 synchronized,现象类似但栈信息不同;而原例的锁是 JVM 内部 monitor,栈里不会出现 synchronized 字样。
- 与“单例双重检查”类锁死锁的区别
- 单例双重检查使用 用户可见的
synchronized(Class)或static synchronized方法,栈会显示monitor enter。 - 本例是 JVM 自动插入的
<clinit>锁,栈只停在<clinit>,没有monitor字样。
- 结论一句话
第一个线程把类初始化锁占住并死循环,第二个线程永远进不了 <clinit>(),于是整个进程卡死——这就是“类初始化死锁”的最简演示。

