Skip to content

类初始化方法 clinit()(线程安全)

<clinit>():类的构造函数

上一节我们提到,初始化阶段会执行 <clinit>() 方法——编译器自动生成的类构造器。它负责初始化所有 static 变量和执行 static 代码块。

这一节深入探讨 <clinit>() 的生成规则和线程安全性。

<clinit>() 的生成规则

规则一:按源码顺序合并

编译器会把 static 变量的赋值语句和 static 代码块,按照它们在源码中出现的顺序,合并成一个 <clinit>() 方法。

java
// 源码
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>()

java
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>() 一定先于子类的执行

java
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;  // 不触发
java
interface MyInterface {
    int CONST = 100;  // 编译时常量,访问不触发初始化
    static { System.out.println("接口初始化"); }  // 访问这个字段才触发
}

规则四:无 <clinit> 的情况

如果一个类既没有 static 变量赋值,也没有 static 代码块,编译器就不会生成 <clinit>()

java
// 没有 static 初始化
public class Simple {
    public static int x;  // 只是声明,没有赋值
}

// 字节码中没有 <clinit>() 方法

<clinit> 的字节码表示

<clinit> 在字节码中是一个特殊的方法:

方法名: <clinit>
访问标识: ACC_STATIC
描述符: ()V(无参数,返回 void)
注意:没有参数列表,也没有返回值
bash
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> 线程安全的实际验证

java
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 初始化完成");
    }
}
java
// 测试类
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> 执行时可能触发其他类的加载,如果形成循环依赖,可能导致死锁:

java
// 类 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

java
public class BadInit {
    static {
        // 抛出受检异常
        throw new IOException("故意的");
    }
}
bash
# 运行时会抛出:
java.lang.ExceptionInInitializerError: java.io.IOException: 故意的
    at BadInit.<clinit>(BadInit.java:3)
    ...
Caused by: java.io.IOException: 故意的
    at BadInit.<clinit>(BadInit.java:3)
    ...

受检异常被包装<clinit> 只能抛出 ErrorRuntimeException,其他异常会被包装。

常见的 <clinit> 陷阱

陷阱一:循环依赖导致死锁

如上所述,static 块中的循环依赖可能导致死锁。

陷阱二:初始化顺序敏感

java
public class Order {
    static {
        x = 200;  // 这里 x 还没定义!但编译器会处理好
    }
    static int x = 100;
}

// 字节码中的顺序:
// 1. x = 200
// 2. x = 100
// 最终 x = 100

陷阱三:静态常量的初始化顺序

java
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 staticA 定义前),所以 A 不是编译时常量,访问 A 会触发类的初始化,初始化时 B = 100 已经在前面执行了。

陷阱四:static final 的懒加载

java
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");
    }
}

答案:不会输出,因为 VALUEstatic final,在编译时值就确定了,编译器会内联这个常量,运行时不需要执行 <clinit>

本节小结

<clinit>() 的核心要点:

维度说明
生成来源编译器合并 static 变量赋值和 static 代码块
执行顺序按源码顺序,父类先于子类
线程安全JVM 通过类初始化锁保证,只执行一次
<clinit>没有 static 初始化时不生成
异常处理受检异常被包装为 ExceptionInInitializerError
<init> 的区别<clinit> 初始化类,<init> 初始化实例

理解 <clinit> 的线程安全机制,才能理解类初始化的可靠性和可能出现的死锁问题。

下一节,我们来看 类加载器命名空间/类的唯一性

基于 VitePress 构建