多线程程序里最难调的 bug 往往不是逻辑错,而是"明明赋了值,另一个线程就是读不到"或者"代码顺序我写得好好的,运行起来却像被打乱了"。这背后是 Java 内存模型(JMM) 在起作用。volatile 和 happens-before 正是 JMM 给程序员的两个核心抓手。本文讲清它们到底保证了什么、又靠什么底层机制实现。
场景:一个永远停不下来的循环
1 | boolean running = true; // 普通字段 |
这段代码在多核机器上可能永远不停。原因不是 B 没改成功,而是:线程 A 可能一直读自己工作内存(寄存器/CPU 缓存)里的 running 副本,看不到 B 对主内存的修改;甚至 JIT 编译器看到循环体内不改 running,直接优化成 while(true)。这就是可见性问题。把 running 加上 volatile 就能解决。
机制一:JMM 的三个保证维度
JMM 是一套抽象规则,规定了多线程下共享变量读写的行为。它要保证三件事:
- 原子性:一个操作不可分割。基本类型读写大多原子(long/double 在某些平台例外),但
i++这种"读-改-写"不是。 - 可见性:一个线程的写,何时对另一个线程可见。
- 有序性:为了性能,编译器和 CPU 会重排序指令(只要不改变单线程语义)。多线程下这种重排可能导致灾难。
volatile 解决可见性和有序性,但不解决原子性——这是最关键的认知。volatile int count; count++ 仍然是线程不安全的。
机制二:volatile 的两条语义
volatile 给变量赋予两条保证:
-
可见性:对 volatile 变量的写,会立即刷回主内存;对它的读,会直接从主内存读(使本地缓存失效)。所以一个线程写,其他线程立刻能读到最新值。
-
禁止重排序:通过插入内存屏障(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 后续对它的读。
- 锁规则:对一个锁的
unlockhappens-before 后续对同一个锁的lock。 - 传递性:A hb B 且 B hb C,则 A hb C。
- 线程启动规则:
thread.start()happens-before 该线程内的任何操作。 - 线程终结规则:线程内所有操作 happens-before 其他线程检测到该线程终止(
join()返回)。
volatile 的"搭便车"效应最妙:因为有传递性,volatile 变量的写不仅让自己可见,还把它之前写的所有普通变量一起"打包"刷新可见了。这正是双重检查锁单例为什么 instance 必须 volatile:
1 | class Singleton { |
instance = new Singleton() 不是原子操作,它含三步:(1) 分配内存,(2) 调用构造器初始化对象,(3) 把 instance 指向该内存。第 2、3 步可能被重排。若没有 volatile,线程 B 可能在第一次检查时看到一个"非 null 但还没初始化完"的对象,拿去用就是访问半成品,可能 NPE 或读到默认值。volatile 禁止了 (2)(3) 的重排,保证别人看到 instance 非 null 时,对象已经构造完毕。
工程权衡与边界
-
volatile 不能替代锁:它只管可见性和有序性,不管复合操作的原子性。计数器、状态翻转这类"读改写"必须用
synchronized、AtomicInteger(底层 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 才管"改得对"。