volatile 不是锁:管得住可见,保不住 ++——Java 工兵连第 3 篇
「Java 工兵连」第 3 篇,讲 volatile——被误解最多的关键字,没有之一。误解的根源是把「线程安全」当成一个开关,以为加了 volatile 就安全了。这篇的目标是读完你能一句话说清它的辖区:volatile 管可见性和有序性,不管原子性——它是「一写多读」的通信原语,不是锁。
一、先建直觉:白板与便签
Java 内存模型(JMM)规定:所有变量存在主内存,每个线程有自己的工作内存(可粗略对应 CPU 缓存)。线程读写的是自己工作内存里的副本:
- 主内存 = 办公室的白板,所有人共享;
- 工作内存 = 每个人的私人便签,改动不会自动同步。
没有 volatile 时,线程 A 改了便签,可能很久都不抄到白板;线程 B 也可能一直看自己那张旧便签——这就是可见性问题:改了 ≠ 别人看得见。
volatile 给变量立了两条规矩:
- 写即刷板:写完立刻把值刷到主内存,并广播「其他人的便签作废」;
- 读必重看:每次读都撕掉本地便签,从主内存重新看一眼。
二、它真正的辖区:两个保证 + 一个不管
保证一:可见性
上面的白板模型就是全部。最经典的正确用法是状态标志位(一写多读):
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 的最佳教材。)
五、什么时候用它:三条判据
- 一写多读的状态位——停止标志、开关、配置刷新标记:✓ 正选;
- 一次性安全发布——初始化完成后只读的引用(配合 final 或 DCL):✓ 可以;
- 任何「读改写」或多线程都写同一个变量(计数器、累加器、状态=a+b 的复合判断):✗ 换锁或
Atomic*/LongAdder。
判断只问一个问题:「这个变量会被多个线程写吗?」 多写就不是 volatile 的辖区,不用纠结。
老兵点评
- volatile 的本质是「通信」,锁的本质是「独占」。什么时候需要通信?一方的决定要让另一方知道——停止位、发布、刷新标记。什么时候需要独占?资源一次只许一人动——计数、转账、复合状态。把问题翻译成「通信还是独占」,选型永远不纠结。
- 面试里 90% 的 volatile 题在考「不管原子性」。count++ 丢数据、DCL 半初始化,两案例吃遍。剩下 10% 考 happens-before——能说出「volatile 写之前的写,对读之后的读可见」这句传递规则,就是区分度。
- 别滥用,也别怕用。有些团队规范「禁用 volatile」,多半是被滥用吓的(拿它当锁救不了的事都赖它)。真实项目里优雅停机、配置热更新、DCL,这三个场景它都是标准答案——关键是写代码时能说清「谁写谁读」。
带走三句话
- volatile = 写即刷板,读必重看:管可见、禁重排,不管原子(count++ 照丢不误)。
- 判据一句话:一写多读找 volatile,读改写找锁或原子类。
- DCL 必须加 volatile,因为
new是三步,重排会发布半初始化对象——口诀「锁住创建,volatile 管发布」。
本篇是「Java 工兵连」第 3 篇。→ 上一篇:第 2 篇 · HashMap 的扩容与树化 | 继续阅读:第 4 篇 · JVM 分代与 G1 一次讲透。
❦ 终 ❦