多线程程序里最难调的 bug 往往不是逻辑错,而是"明明赋了值,另一个线程就是读不到"或者"代码顺序我写得好好的,运行起来却像被打乱了"。这背后是 Java 内存模型(JMM) 在起作用。volatilehappens-before 正是 JMM 给程序员的两个核心抓手。本文讲清它们到底保证了什么、又靠什么底层机制实现。

场景:一个永远停不下来的循环

1
2
3
4
5
boolean running = true;     // 普通字段
// 线程 A
while (running) { /* do work */ }
// 线程 B
running = false; // 期望停止线程 A

这段代码在多核机器上可能永远不停。原因不是 B 没改成功,而是:线程 A 可能一直读自己工作内存(寄存器/CPU 缓存)里的 running 副本,看不到 B 对主内存的修改;甚至 JIT 编译器看到循环体内不改 running,直接优化成 while(true)。这就是可见性问题。把 running 加上 volatile 就能解决。

机制一:JMM 的三个保证维度

JMM 是一套抽象规则,规定了多线程下共享变量读写的行为。它要保证三件事:

  • 原子性:一个操作不可分割。基本类型读写大多原子(long/double 在某些平台例外),但 i++ 这种"读-改-写"不是。
  • 可见性:一个线程的写,何时对另一个线程可见。
  • 有序性:为了性能,编译器和 CPU 会重排序指令(只要不改变单线程语义)。多线程下这种重排可能导致灾难。

volatile 解决可见性和有序性,但不解决原子性——这是最关键的认知。volatile int count; count++ 仍然是线程不安全的。

机制二:volatile 的两条语义

volatile 给变量赋予两条保证:

  1. 可见性:对 volatile 变量的写,会立即刷回主内存;对它的读,会直接从主内存读(使本地缓存失效)。所以一个线程写,其他线程立刻能读到最新值。

  2. 禁止重排序:通过插入内存屏障(Memory Barrier) 实现。规则是:

    • volatile 写之前的所有读写,不能重排到写之后(StoreStore + 写前屏障)。
    • volatile 读之后的所有读写,不能重排到读之前(LoadLoad + LoadStore)。

在 x86 上,volatile 写大致对应一条带 lock 前缀的指令(如 lock addl),它既保证写立即可见(刷缓存、使其他核缓存行失效),又充当全屏障禁止跨越它的重排。这就是为什么 volatile 写比普通写贵。

机制三:happens-before——JMM 的"承诺契约"

光记 volatile 屏障规则太累。JMM 给了一组更高层的规则:happens-before。它的含义不是"时间上先发生",而是:如果操作 A happens-before 操作 B,那么 A 的结果对 B 一定可见,且 A 在 B 看来是排在 B 之前的。只要你能用这组规则推导出一条 happens-before 链,就不用关心底层屏障细节。核心规则:

  • 程序顺序规则:单线程内,前面的操作 happens-before 后面的操作(单线程语义不被破坏)。
  • volatile 规则:对 volatile 变量的写 happens-before 后续对它的读。
  • 锁规则:对一个锁的 unlock happens-before 后续对同一个锁的 lock
  • 传递性:A hb B 且 B hb C,则 A hb C。
  • 线程启动规则:thread.start() happens-before 该线程内的任何操作。
  • 线程终结规则:线程内所有操作 happens-before 其他线程检测到该线程终止(join() 返回)。

volatile 的"搭便车"效应最妙:因为有传递性,volatile 变量的写不仅让自己可见,还把它之前写的所有普通变量一起"打包"刷新可见了。这正是双重检查锁单例为什么 instance 必须 volatile:

1
2
3
4
5
6
7
8
9
10
11
12
class Singleton {
private static volatile Singleton instance; // volatile 不可省
static Singleton get() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) // 第二次检查
instance = new Singleton(); // 关键
}
}
return instance;
}
}

instance = new Singleton() 不是原子操作,它含三步:(1) 分配内存,(2) 调用构造器初始化对象,(3) 把 instance 指向该内存。第 2、3 步可能被重排。若没有 volatile,线程 B 可能在第一次检查时看到一个"非 null 但还没初始化完"的对象,拿去用就是访问半成品,可能 NPE 或读到默认值。volatile 禁止了 (2)(3) 的重排,保证别人看到 instance 非 null 时,对象已经构造完毕。

工程权衡与边界

  • volatile 不能替代锁:它只管可见性和有序性,不管复合操作的原子性。计数器、状态翻转这类"读改写"必须用 synchronizedAtomicInteger(底层 CAS)或 LongAdder

  • 性能成本:volatile 读在多数平台几乎和普通读一样便宜;但 volatile 因为要刷缓存 + 内存屏障,开销明显高于普通写,且会让相关缓存行在多核间来回失效(伪共享放大这一问题)。读多写少的标志位是 volatile 的甜点场景。

  • 64 位的 long/double:JMM 允许非 volatile 的 long/double 写被拆成两个 32 位写(撕裂读)。声明为 volatile 可保证其读写原子。现代 64 位 JVM 实践中通常已是原子,但规范层面加 volatile 才稳妥。

  • DCL 之外别滥用:不要试图用一堆 volatile 拼出复杂的无锁算法,极易出错。优先用 java.util.concurrent 里已经被验证过的工具。

常见误区

  • “volatile 保证线程安全”:只对"一写多读"或"写不依赖旧值"的标志位成立。volatile count++ 仍会丢更新。
  • “加了 volatile 就一定比锁快”:写密集场景下,缓存行频繁失效可能让 volatile 比想象中贵。
  • “happens-before 等于时间先后”:它是可见性 + 有序性的偏序关系,与物理时间无关。两个没有 happens-before 关系的操作,谁先谁后、能否互相看见,都是不确定的。

小结

JMM 用原子性、可见性、有序性三个维度刻画多线程行为。volatile 通过"刷主内存 + 内存屏障"保证可见性和禁止重排,但不保证原子性happens-before 是 JMM 给你的高层契约,只要能推导出 hb 链就能放心断言可见性,而 volatile 的写借助传递性能把它之前的普通写一并"捎带"可见——这正是双重检查锁、状态发布等模式的理论根基。记住一句话:volatile 管"看得见、不乱序",锁和 CAS 才管"改得对"。