《Java 并发编程·JUC 源码与容器》15.1 双重检查锁定的正确写法

用 javap 把 new 拆成分配、构造、发布三步,说清双重检查锁定为什么字段必须 volatile、它挡的是哪条重排;再给出 VarHandle 的 acquire/release 版本、静态持有者的单条 getstatic 与枚举的反射边界,并用一个 this 逃逸的构造稳定复现未初始化对象被发布。

承接的问题

前面十四章拆的是 JUC 自己的实现:锁怎么排队、容器怎么分桶、线程池怎么补线程。这一章换一个方向,把那些机制当作判据,去审我们自己的代码。第一个案例是最老的并发题:双重检查锁定(DCL)。

它值得放在收尾章,因为它是唯一一个「代码看起来完全正确、编译通过、单线程测试通过、只有在某个 CPU 和某个 JIT 组合下才出错」的典型。这一节不讲新 API,只回答三个问题:第一次检查凭什么不加锁、volatile 到底挡住了什么、为什么静态持有者比 DCL 更省心。

new 在字节码里是三件事

先看编译器给出的顺序。下面是一个标准 DCL:

public final class DclBytecode {
    private static DclBytecode instance;
    private final int port;

    private DclBytecode(int port) { this.port = port; }

    public static DclBytecode getInstance() {
        DclBytecode local = instance;
        if (local == null) {
            synchronized (DclBytecode.class) {
                local = instance;
                if (local == null) {
                    instance = local = new DclBytecode(8091);
                }
            }
        }
        return local;
    }
}

javap -c -p 出来,赋值那一行是三条指令:

21: new           #8        // class DclBytecode
24: dup
25: sipush        8091
28: invokespecial #17       // Method "<init>":(I)V
31: dup
32: astore_0
33: putstatic     #13       // Field instance:LDclBytecode;
指令做了什么此刻对象的状态
new分配内存,所有字段为默认值port = 0
invokespecial <init>执行构造方法,写入 port = 8091完整
putstatic instance把引用写进静态字段完整,且已发布

字节码里这三步是有序的,问题在于这个顺序只是字节码的顺序,不是内存系统承诺的顺序。JIT 内联构造方法之后,putstatic 可以被提到字段写入之前;即使编译器不动,AArch64 的存储缓冲区也允许后一条 store 先对其他核可见。两者都合法,因为 JMM 对普通字段只要求「同一线程内看起来有序」。

所以 DCL 的 bug 不是「可能创建两个对象」(synchronized 挡住了),而是另一个线程可能拿到一个 port = 0 的对象。它会正常返回,不会抛异常,直到某处用 0 去连端口。

第一次检查为什么可以不加锁

这是最容易被讲反的一点。第一次检查之所以能不加锁,不是因为它「读得快、读错了也没关系」,而是因为它的两种结果都有退路:

  • 读到 null:进锁,在锁里重新读一次。即使这个 null 是过期的,重读也会纠正,所以这次读错完全无害。
  • 读到非 null:直接返回,不进锁。这条路径的正确性完全依赖对象已经发布完整——这正是 volatile 要解决的问题。

换句话说,第一次检查是一个「提示」,它只决定要不要进锁;它不承担正确性。把第一次检查整个删掉、每次调用都 synchronized,代码依然正确,只是每次调用都要付一次监视器进入和退出的开销,高竞争时还要排队。

也正因为如此,第一次读在 JMM 里是一次数据竞争(没有 happens-before 边)。数据竞争本身不必然是 bug,但它把正确性押在了「写端发布完整」上。写端没做好,读端再怎么检查也没用。

volatile 挡的是哪一条重排

volatile 写在字段上,语义只有两条,但都正好卡在 DCL 的要害上:

屏障位置挡住的重排
StoreStorevolatile 写之前构造方法里的字段写入不能落到引用写入之后
LoadLoadvolatile 读之后后续读字段不能提到引用读之前

写线程执行 instance = new X() 时,volatile 写把「此前所有写」(包括构造方法里的 port = 8091)压在了发布之前;读线程第一次 getstatic 读到非 null,就建立了一条 happens-before 边,之后读 port 一定能看到 8091。这就是卷一 3.3 讲的安全发布,只是换成了单例场景。

反过来说,volatile 在这里不提供三样东西:它不防止对象被第二个线程修改,不让复合操作变成原子(count++ 加 volatile 照样丢更新,卷一 4.1),也不保证构造方法里逃逸出去的 this 是完整的——下一节的实验就是这件事。

JDK 25 的语义和 Java 5 没有区别。JSR-133 定下的模型二十年没动过,javap -v 看到的字段标志也还是同一个:

private static volatile SingletonForms$VolatileDcl instance;
  flags: (0x004a) ACC_PRIVATE, ACC_STATIC, ACC_VOLATILE

用 VarHandle 写出同样的保证

如果嫌 volatile 太重,可以用 9 章讲过的访问模式,把「释放」和「获取」显式写出来。字段本身不再是 volatile,排序由两次调用承担:

static final class ReleaseDcl {
    private static ReleaseDcl instance;
    final int port;
    private static final VarHandle INSTANCE;

    static {
        try {
            INSTANCE = MethodHandles.lookup()
                    .findStaticVarHandle(ReleaseDcl.class, "instance", ReleaseDcl.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    private ReleaseDcl() { this.port = 8091; }

    static ReleaseDcl get() {
        ReleaseDcl local = (ReleaseDcl) INSTANCE.getAcquire();
        if (local == null) {
            synchronized (ReleaseDcl.class) {
                local = (ReleaseDcl) INSTANCE.getAcquire();
                if (local == null) {
                    local = new ReleaseDcl();
                    INSTANCE.setRelease(local);
                }
            }
        }
        return local;
    }
}

javap -v 里这个字段只剩 ACC_PRIVATE, ACC_STATIC,没有任何 volatile 标志;保证来自 setRelease 与 getAcquire 的配对。为什么这样就够:setRelease 保证它之前的写(构造方法里的字段)不会被排到它之后,getAcquire 保证它之后的读不会被排到它之前——正好覆盖 DCL 需要的两条边,而不需要 volatile 写那条额外的 StoreLoad。

本机跑下来三种写法结果一致:

volatile dcl  -> 8091
release  dcl  -> 8091
holder idiom  -> 8091
enum          -> 8091

这里不比较耗时。本机 load average 长期在 100 以上,任何纳秒级对比都会被调度噪声吃掉,要看数字得上 JMH 并锁频,那是另一件事。

静态持有者为什么更优

初始化按需持有(initialization-on-demand holder)把「创建」交给 JVM 的类初始化机制:

static final class HolderIdiom {
    final int port;
    private HolderIdiom() { this.port = 8091; }
    private static final class Holder {
        static final HolderIdiom INSTANCE = new HolderIdiom();
    }
    static HolderIdiom get() { return Holder.INSTANCE; }
}

get() 的字节码只有两条指令:

static SingletonForms$HolderIdiom get();
  Code:
     0: getstatic  #13  // Field SingletonForms$HolderIdiom$Holder.INSTANCE
     3: areturn

它比 DCL 好在三点:

  1. 热路径没有任何同步原语。getstatic 读的是一个 final 静态字段,不需要 acquire 语义,因为 JVM 保证类初始化完成后的读一定能看到初始化结果(JVMS 5.5 的初始化锁本身建立了 happens-before)。
  2. 没有「发布」这一步可被重排。对象在 <clinit> 里构造并写入,写入和构造都在类初始化锁内完成,别的线程要么还没开始用这个类,要么已经等到初始化结束。
  3. 代码里没有双重检查,也就没有人会改错。DCL 的错误通常是「后来有人觉得 volatile 没用就删了」。

它的代价是失败不可重试。类初始化一旦抛异常,JVM 会把该类标记为 erroneous,后续访问直接抛 NoClassDefFoundError,不会再试一次。本机实测:

broken holder #1 -> java.lang.ExceptionInInitializerError
broken holder #2 -> java.lang.NoClassDefFoundError

如果单例的构造依赖远程配置、数据库连接这类可能暂时失败的资源,持有者模式会把一次瞬时故障变成进程级的永久故障。这种场景应该用带 volatile 的 DCL,让失败的那次不发布、下次调用重新尝试。

枚举单例的边界

枚举单例的写法最短,序列化和反射都替你想好了:

enum EnumSingleton {
    INSTANCE;
    final int port = 8091;
    int port() { return port; }
}

反射创建会被 JDK 直接拒绝,本机输出:

enum reflect  -> java.lang.IllegalArgumentException: Cannot reflectively create enum objects

边界有四个:

  • 不懒。枚举常量在类初始化时全部创建,INSTANCE 的构造发生在第一次触碰这个枚举类的时刻,通常就是应用启动时。构造里做重活会拖慢启动。
  • 构造失败同样是永久的,和持有者模式一样,因为也在 <clinit> 里。
  • 每个类加载器一个实例。插件容器、热部署环境里两个类加载器各自加载一次枚举类,就有两个「单例」。DCL 和持有者也一样,这是「单例」这个词的固有边界,不是枚举的缺陷。
  • 枚举不能被继承,也不能延迟到真正需要时再决定用哪个实现。需要按配置选实现时,枚举反而碍事。

稳定复现:this 逃逸比 DCL 更常见

DCL 的经典复现实验(非 volatile 字段 + 并发读)在本机没有跑出来。20 000 轮和 100 000 轮各一次,合计 120 000 次检查,全部读到完整对象:

rounds=20000 checks=20000 bad=0
rounds=100000 checks=100000 bad=0

原因是 HotSpot 在这台机器上没有把发布写提到构造之前;这不构成「DCL 不加 volatile 也对」的证据,只说明这个 bug 依赖 JIT 版本和 CPU,靠运气复现不可靠。构造不出稳定复现,就不能拿「我跑了一百万次没问题」当结论。

能稳定复现的是另一类发布错误——this 在构造完成前逃逸:

public final class UnsafePublication {
    static UnsafePublication instance;   // 非 volatile
    final int port;                      // 即使是 final 也不够

    UnsafePublication() {
        instance = this;                 // 构造没结束就发布
        sleepQuietly(50);                // 真实代码里是加载配置、建连接
        this.port = 8091;
    }
}

sleepQuietly 只是 Thread.sleep 的包装,完整代码在实验目录。本机连续 5 轮,读线程 5 次都读到了 0:

reader saw port=0  <-- 未初始化对象被发布
reader saw port=0  <-- 未初始化对象被发布
reader saw port=0  <-- 未初始化对象被发布
reader saw port=0  <-- 未初始化对象被发布
reader saw port=0  <-- 未初始化对象被发布

port 是 final 也没救,因为 JLS 17.5 的保证有一个前提:构造期间不得把 this 写到别的线程能看到的地方。这里的写发生在 final 冻结之前,冻结管不到它。

DCL 常犯的同类错误是:构造方法里把 this 注册进监听器列表、放进静态缓存、丢给线程池。修法不是加 volatile,而是把注册挪出构造方法:

static UnsafePublication create() {
    UnsafePublication p = new UnsafePublication();  // 构造内不发布
    instance = p;                                    // 构造结束后才发布
    return p;
}

在 libraryd-conc 里落地

借阅策略(配额 5、最多续借 2 次)是典型的「启动时读一次、之后只读」的配置。用持有者模式,热路径零同步:

package com.plume.conc.core;

public final class LoanPolicy {
    private final int quota;
    private final int maxRenewals;

    private LoanPolicy(int quota, int maxRenewals) {
        this.quota = quota;
        this.maxRenewals = maxRenewals;
    }

    public int quota() { return quota; }
    public int maxRenewals() { return maxRenewals; }

    private static final class Holder {
        static final LoanPolicy INSTANCE = load();
    }

    public static LoanPolicy get() { return Holder.INSTANCE; }

    private static LoanPolicy load() {
        int q = Integer.getInteger("libraryd.quota", 5);
        int r = Integer.getInteger("libraryd.maxRenewals", 2);
        if (q <= 0) throw new IllegalStateException("quota must be positive: " + q);
        return new LoanPolicy(q, r);
    }
}

参数非法时直接让类初始化失败,这是有意的:配额配错属于部署错误,应该在启动时就炸,而不是运行到第一次借书才发现。但如果这个值来自配置中心、允许启动时暂时不可达,就该换成 DCL 加 volatile,让失败的那一次不发布,下一次调用重新拉取。

这个设计的代价

单例是全局可变状态。 它让测试无法隔离,也让「谁在什么时候改了它」变得难查。上面的 LoanPolicy 是不可变对象,可以安全共享;如果单例里带可变字段,volatile 只保证引用可见,字段的并发修改还得自己管。

持有者的初始化时机不可控。 第一次调用 get() 的线程要承担全部初始化成本,包括可能的 I/O;如果它在关键路径上,就是一次尾延迟尖刺。需要预热的话,在启动流程里显式调用一次。

DCL 的 volatile 读不是免费的。 每次调用都有一次 acquire。绝大多数业务代码里这无所谓,但在每秒千万次调用的热点路径上,持有者的单条 getstatic 更划算。

什么时候不要用

不要用单例去装「需要按请求变化的东西」。把 Member、请求上下文塞进单例字段,是 7.1 讲过的 ThreadLocal 泄漏的变种,只是错得更彻底。

不要在构造方法里做任何形式的发布:注册监听器、启动线程、写静态字段、把自己传给别的对象。构造方法应该是「只写自己的字段」。

不要因为「枚举写法最短」就把需要读配置、可能失败、需要延迟初始化的东西做成枚举。它的失败是永久的,启动顺序也被类加载绑死。

小结

new 是分配、构造、发布三步,后两步之间没有屏障,这就是 DCL 需要一个 volatile 的全部原因。第一次检查只是提示,读到 null 就进锁重读,读到非 null 就必须保证对象已完整发布。JDK 25 的语义没有变,VarHandle 的 setRelease/getAcquire 用更窄的屏障给出同样的保证。静态持有者把发布交给类初始化锁,热路径只剩一条 getstatic,代价是失败不可重试;枚举的边界同样在 <clinit>。真正能稳定复现的不是 DCL 的重排,而是构造期间 this 逃逸——那类 bug 加 volatile 也修不好。

阅读导航:上一节 14.3 并发限流与信号量池 | 下一节 15.2 线程安全类的组合不安全

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收