VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

volatile 不是锁:管得住可见,保不住 ++——Java 工兵连第 3 篇

「Java 工兵连」第 3 篇,讲 volatile——被误解最多的关键字,没有之一。误解的根源是把「线程安全」当成一个开关,以为加了 volatile 就安全了。这篇的目标是读完你能一句话说清它的辖区:volatile 管可见性和有序性,不管原子性——它是「一写多读」的通信原语,不是锁。

一、先建直觉:白板与便签

Java 内存模型(JMM)规定:所有变量存在主内存,每个线程有自己的工作内存(可粗略对应 CPU 缓存)。线程读写的是自己工作内存里的副本:

  • 主内存 = 办公室的白板,所有人共享;
  • 工作内存 = 每个人的私人便签,改动不会自动同步。

没有 volatile 时,线程 A 改了便签,可能很久都不抄到白板;线程 B 也可能一直看自己那张旧便签——这就是可见性问题:改了 ≠ 别人看得见。

左:volatile 的白板便签模型;右:count++ 交错丢失的时序

volatile 给变量立了两条规矩:

  1. 写即刷板:写完立刻把值刷到主内存,并广播「其他人的便签作废」;
  2. 读必重看:每次读都撕掉本地便签,从主内存重新看一眼。

二、它真正的辖区:两个保证 + 一个不管

保证一:可见性

上面的白板模型就是全部。最经典的正确用法是状态标志位(一写多读):

private volatile boolean running = true;   // 没有 volatile:A 改了,B 可能永远看不到,停不下来

public void stop() { running = false; }    // 线程 A 调用

public void run() {
    while (running) { /* 工作 */ }          // 线程 B 检查——加了 volatile 才保证能停下
}

保证二:禁止重排(有序性)

编译器和 CPU 是个爱优化顺序的厨师:只要单线程结果不变,就敢把指令换序执行。单线程下这是美谈,多线程下就是事故。volatile 在读写点插入内存屏障,立下「屏障两侧不许越过」的规矩。

关键在于 JMM 的 happens-before 传递规则:volatile 写之前的所有普通写,对随后 volatile 读之后的读都可见。这句话是双重检查锁(DCL)单例能成立的法理依据,后面第四节展开。

一个不管:原子性

count++ 看着是一步,字节码上是读 → 加一 → 写回三步。volatile 只保证每步读写的是最新值,挡不住两线程的这三步互相插队(见 hero 图右半):两个线程都读到 5、都加成 6、都写回 6——两次自增只涨了 1。这就是丢失更新,volatile 管不了,答案只有两个:锁(synchronized/Lock)或原子类(AtomicLong/LongAdder)。

一个冷门但真实的加分点:JMM 允许非 volatile 的 long/double 读写拆成两次 32 位(JLS 17.7);给它们加 volatile,64 位读写就是原子的。这是 volatile「保原子」的唯一例外场景。

三、volatile vs synchronized:一张表分辖区

能力 volatile synchronized
可见性 ✓ ✓
有序性 ✓(本变量的屏障) ✓(临界区串行)
原子性 ✗(64 位读写除外) ✓
阻塞 不阻塞,读就是读 抢不到锁就阻塞
开销 轻量(内存屏障) 较重(锁升级全流程)
适用 一写多读的状态位、发布 读改写、多写共享、复合操作

记忆钩子:volatile 是广播站,synchronized 是独木桥。广播站让所有人听到同一条消息,但不保证没人同时发言;独木桥保证同一时刻只有一个过,但过桥要排队。

四、DCL 单例:volatile 最著名的应用

public class Singleton {
    private static volatile Singleton instance;   // ← 去掉这个 volatile,这段代码就是错的

    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查:没锁,快路径
            synchronized (Singleton.class) {
                if (instance == null) {            // 第二次检查:拿到锁后再确认
                    instance = new Singleton();    // 雷区在这一行
                }
            }
        }
        return instance;
    }
}

为什么必须 volatile?因为 new Singleton() 不是一步,是三步:① 分配内存 → ② 初始化对象 → ③ 引用指向内存。CPU 可能重排成 ①③②。没有 volatile 时,线程 A 执行到 ③(引用非 null 了)但 ② 还没做,线程 B 在第一次检查处看到非 null 直接返回——拿到一个半初始化对象,字段全是默认值。volatile 禁止这个重排,并让「初始化完成」这件事对 B 可见(happens-before 传递)。口诀:锁住创建,volatile 管发布。

(JDK 5 之前的旧实现里这条路径理论上就是坏的——所以老代码里的 DCL 不加 volatile 不是「风格问题」,是历史 bug。如今更省心的替代是静态内部类或枚举单例,但 DCL 仍是理解 volatile 的最佳教材。)

五、什么时候用它:三条判据

  1. 一写多读的状态位——停止标志、开关、配置刷新标记:✓ 正选;
  2. 一次性安全发布——初始化完成后只读的引用(配合 final 或 DCL):✓ 可以;
  3. 任何「读改写」或多线程都写同一个变量(计数器、累加器、状态=a+b 的复合判断):✗ 换锁或 Atomic*/LongAdder。

判断只问一个问题:「这个变量会被多个线程写吗?」 多写就不是 volatile 的辖区,不用纠结。

老兵点评

  1. volatile 的本质是「通信」,锁的本质是「独占」。什么时候需要通信?一方的决定要让另一方知道——停止位、发布、刷新标记。什么时候需要独占?资源一次只许一人动——计数、转账、复合状态。把问题翻译成「通信还是独占」,选型永远不纠结。
  2. 面试里 90% 的 volatile 题在考「不管原子性」。count++ 丢数据、DCL 半初始化,两案例吃遍。剩下 10% 考 happens-before——能说出「volatile 写之前的写,对读之后的读可见」这句传递规则,就是区分度。
  3. 别滥用,也别怕用。有些团队规范「禁用 volatile」,多半是被滥用吓的(拿它当锁救不了的事都赖它)。真实项目里优雅停机、配置热更新、DCL,这三个场景它都是标准答案——关键是写代码时能说清「谁写谁读」。

带走三句话

  1. volatile = 写即刷板,读必重看:管可见、禁重排,不管原子(count++ 照丢不误)。
  2. 判据一句话:一写多读找 volatile,读改写找锁或原子类。
  3. DCL 必须加 volatile,因为 new 是三步,重排会发布半初始化对象——口诀「锁住创建,volatile 管发布」。

本篇是「Java 工兵连」第 3 篇。→ 上一篇:第 2 篇 · HashMap 的扩容与树化 | 继续阅读:第 4 篇 · JVM 分代与 G1 一次讲透。

❦ 终 ❦

← 回到首页全部归档 →