类初始化方法 clinit()(线程安全)
<clinit>():类的构造函数
上一节我们提到,初始化阶段会执行 <clinit>() 方法——编译器自动生成的类构造器。它负责初始化所有 static 变量和执行 static 代码块。
这一节深入探讨 <clinit>() 的生成规则和线程安全性。
<clinit>() 的生成规则
规则一:按源码顺序合并
编译器会把 static 变量的赋值语句和 static 代码块,按照它们在源码中出现的顺序,合并成一个 <clinit>() 方法。
// 源码
public class Demo {
static int a = 1;
static {
System.out.println("static 块 1");
}
static int b = 2;
static {
System.out.println("static 块 2");
}
static int c = 3;
}编译器生成的 <clinit>():
static {};
Code:
iconst_1
putstatic #Demo.a
getstatic #System.out
ldc #"static 块 1"
invokevirtual #println
iconst_2
putstatic #Demo.b
getstatic #System.out
ldc #"static 块 2"
invokevirtual #println
iconst_3
putstatic #Demo.c
return合并原则:static 变量赋值和 static 代码块按源码顺序「编织」在一起。
规则二:父类 <clinit>() 先执行
如果一个类有父类,父类的 <clinit>() 一定先于子类的执行。
class Parent {
static { System.out.println("Parent <clinit>"); }
}
class Child extends Parent {
static { System.out.println("Child <clinit>"); }
}new Child() 执行时:
1. 加载 Child
2. 发现有父类 Parent,先初始化 Parent
3. 执行 Parent.<clinit>() → 输出 "Parent <clinit>"
4. 执行 Child.<clinit>() → 输出 "Child <clinit>"这个规则保证了父类的 static 初始化在子类之前完成——子类 static 初始化时,父类静态字段一定已经就位。
规则三:接口的 <clinit>() 不一定执行
接口的 <clinit>() 和类不同:
接口的 <clinit>() 只有在以下情况执行:
1. 有非编译时常量的 static 字段赋值
2. 有 static 代码块
接口访问常量字段(static final)不触发初始化:
static final int MAX = 100; // 不触发interface MyInterface {
int CONST = 100; // 编译时常量,访问不触发初始化
static { System.out.println("接口初始化"); } // 访问这个字段才触发
}规则四:无 <clinit> 的情况
如果一个类既没有 static 变量赋值,也没有 static 代码块,编译器就不会生成 <clinit>():
// 没有 static 初始化
public class Simple {
public static int x; // 只是声明,没有赋值
}
// 字节码中没有 <clinit>() 方法<clinit> 的字节码表示
<clinit> 在字节码中是一个特殊的方法:
方法名: <clinit>
访问标识: ACC_STATIC
描述符: ()V(无参数,返回 void)
注意:没有参数列表,也没有返回值javap -v Demo.class
# 输出:
# static {};
# descriptor: ()V
# flags: (0x0008) ACC_STATIC
# Code:
# stack=2, locals=0, args_size=0
# 0: iconst_1
# 1: putstatic #Demo.a
# ...
# 9: return<clinit> 的线程安全性
为什么需要线程安全?
考虑这个场景:
线程 A:首次访问 User 类 → 触发初始化
线程 B:首次访问 User 类 → 触发初始化
线程 C:首次访问 User 类 → 触发初始化
...如果没有同步机制,多个线程可能同时执行 <clinit>(),导致静态变量出现不可预期的状态。
JVM 的解决方案:类初始化锁
JVM 给每个类配备了一把「类初始化锁」:
类初始化的同步机制:
线程 A 首次访问类 → 获取类初始化锁 → 开始执行 <clinit>()
线程 B 首次访问类 → 获取锁失败 → 阻塞等待
线程 C 首次访问类 → 获取锁失败 → 阻塞等待
线程 A 执行完 <clinit>() → 释放锁 → 唤醒所有等待线程
线程 B/C 获取到锁 → 发现已初始化 → 直接使用类类加载 → 链接完成 → 初始化(需要获取锁)
│
┌───────────┼───────────┐
│ │ │
Thread-A Thread-B Thread-C
获取锁 阻塞等待 阻塞等待
执行clinit
│
▼
释放锁,唤醒所有线程
│
▼
所有线程继续执行<clinit> 线程安全的实际验证
public class StaticInit {
public static int value = 0;
static {
System.out.println("StaticInit 初始化开始");
value = 1;
// 模拟一些耗时操作
try {
Thread.sleep(1000);
} catch (InterruptedException e) { }
value = 2;
System.out.println("StaticInit 初始化完成");
}
}// 测试类
public class Test {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
System.out.println("t1: value=" + StaticInit.value);
});
Thread t2 = new Thread(() -> {
System.out.println("t2: value=" + StaticInit.value);
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}结果:无论线程如何调度,StaticInit 的 <clinit> 只执行一次,且 value 的值一定是 2(初始化完成后的值)。
死锁风险:<clinit> 中的类加载
<clinit> 执行时可能触发其他类的加载,如果形成循环依赖,可能导致死锁:
// 类 A
class A {
static { B.method(); }
static void test() { }
}
// 类 B
class B {
static { A.test(); } // 这里会触发 A 的初始化
static void method() { }
}
// 死锁场景:
// 线程 1:初始化 A → A.<clinit> 执行 → 访问 B.method()
// → B 没有初始化 → 开始初始化 B
// → B.<clinit> 执行 → 访问 A.test()
// → A 正在初始化中 → 等待 A 初始化完成
// → 但锁在谁手里? → 死锁!避免死锁的建议:static 代码块中避免触发其他类的初始化,尽量只做简单的赋值操作。
<clinit> 与 <init> 的对比
| 维度 | <clinit>() | <init>() |
|---|---|---|
| 触发时机 | 类初始化时 | new 创建实例时 |
| 生成来源 | 编译器自动生成 | 根据构造函数生成 |
| 执行次数 | 每个类最多一次 | 每个实例一次 |
| 执行顺序 | 父类先于子类 | 父类构造器先执行 |
| 线程安全 | JVM 保证(类初始化锁) | 需要开发者保证 |
| 参数 | 无参数 | 可有参数 |
<clinit> 中的异常处理
<clinit> 如果抛出异常(不是 Error 或 RuntimeException),会被包装成 ExceptionInInitializerError:
public class BadInit {
static {
// 抛出受检异常
throw new IOException("故意的");
}
}# 运行时会抛出:
java.lang.ExceptionInInitializerError: java.io.IOException: 故意的
at BadInit.<clinit>(BadInit.java:3)
...
Caused by: java.io.IOException: 故意的
at BadInit.<clinit>(BadInit.java:3)
...受检异常被包装:<clinit> 只能抛出 Error 或 RuntimeException,其他异常会被包装。
常见的 <clinit> 陷阱
陷阱一:循环依赖导致死锁
如上所述,static 块中的循环依赖可能导致死锁。
陷阱二:初始化顺序敏感
public class Order {
static {
x = 200; // 这里 x 还没定义!但编译器会处理好
}
static int x = 100;
}
// 字节码中的顺序:
// 1. x = 200
// 2. x = 100
// 最终 x = 100陷阱三:静态常量的初始化顺序
public class Const {
static final int A = B * 10; // A 依赖 B
static final int B = 100; // B 的定义在 A 后面
public static void main(String[] args) {
// 结果是什么?
// A = 1000,而不是编译时常量报错!
System.out.println(Const.A); // 1000
}
}原理:这种写法不是编译时常量(因为 B 不是 final static 在 A 定义前),所以 A 不是编译时常量,访问 A 会触发类的初始化,初始化时 B = 100 已经在前面执行了。
陷阱四:static final 的懒加载
public class Lazy {
public static final int VALUE = compute(); // 不是编译时常量!
static int compute() {
System.out.println("计算中...");
return 42;
}
public static void main(String[] args) {
// 会输出 "计算中..." 吗?
System.out.println("Hello");
}
}答案:不会输出,因为 VALUE 是 static final,在编译时值就确定了,编译器会内联这个常量,运行时不需要执行 <clinit>。
本节小结
<clinit>() 的核心要点:
| 维度 | 说明 |
|---|---|
| 生成来源 | 编译器合并 static 变量赋值和 static 代码块 |
| 执行顺序 | 按源码顺序,父类先于子类 |
| 线程安全 | JVM 通过类初始化锁保证,只执行一次 |
无 <clinit> | 没有 static 初始化时不生成 |
| 异常处理 | 受检异常被包装为 ExceptionInInitializerError |
与 <init> 的区别 | <clinit> 初始化类,<init> 初始化实例 |
理解 <clinit> 的线程安全机制,才能理解类初始化的可靠性和可能出现的死锁问题。
下一节,我们来看 类加载器命名空间/类的唯一性。
