JMM 与 volatile / CAS学习笔记:volatile 保什么、CAS 赢在哪、ABA 坑在哪
两个线程各把一个 int 加一万次,结果常常不到两万,这种并发 Bug 新手几乎都撞过。网上的答案很多,但能把 volatile 到底保证什么、CAS 靠什么赢过锁、ABA 怎么破讲顺的很少。这篇用一个计数器场景贯穿,把 JMM 拆到面试时能顺着讲出来的程度。
先复现:同一个 flag,一种写法卡死,一种正常
先跑一个复现可见性问题的程序。reader 线程空转等一个 flag,writer 线程 500ms 后把它改成 true。按直觉,reader 最多等 500ms 就该看到。
public class VisibilityDemo {
static boolean flag = false; // 改成 volatile 就是对照组
public static void main(String[] args) throws Exception {
Thread writer = new Thread(() -> {
try { Thread.sleep(500); } catch (InterruptedException e) {}
flag = true;
});
Thread reader = new Thread(() -> {
long spins = 0;
while (!flag) spins++;
System.out.println("reader saw flag, spins=" + spins);
});
reader.start();
writer.start();
reader.join(3000); // main 只等 3 秒
System.out.println("readerAlive=" + reader.isAlive()); // true=还在空转
System.exit(0);
}
}
本机 JDK 26.0.1 实测,默认参数跑两次都卡死:
readerAlive=true // reader 3 秒后还在空转,flag 改了你却看不见
把 flag 声明成 volatile 再跑,立刻正常退出。到这里,多数教程会告诉你原因:reader 把 flag 读进了 CPU 缓存,writer 改了主内存,reader 的缓存没失效,所以一直读旧值。
这个说法只对了一半,而且容易把你带偏。真正的机制是这样:JIT 编译器发现循环里没有任何代码改 flag,也没碰到锁或 volatile,它判断每次迭代都去内存读同一个值是浪费,于是把这次读提升到循环外,等价于先 boolean cached = flag; 再 while (!cached)。提升之后,reader 连读内存这个动作都没有了,你等的根本不是缓存同步,而是编译器把读放回去——它永远不会。
怎么验证是编译器的锅?加 -Xint 关掉 JIT、强制解释执行再跑,两次都正常退出:
java -Xint VisibilityDemo
reader saw flag, spins=67147418
reader saw flag, spins=68076368
解释执行不做循环提升,每轮都真读字段,x86 的缓存一致性协议(写会让其他核的缓存行失效)让 writer 的写在 500ms 内可见,所以退得出。同一个程序、同一个 CPU,差别只在编译器做不做那次提升——可见性问题的首恶是编译器/CPU 在 JMM 允许范围内重排并缓存共享变量的读,CPU 缓存不同步只是次要因素,还经常背锅。
volatile 不管原子性:计数器实验打脸
那把 volatile 当成万能并发修饰符?下一个实验立刻打脸。两个线程各把同一个 volatile int count 自增一万次,本机跑两次:
volatile count = 14249 // 第一次:丢了 20000 - 14249 = 5751 次
volatile count = 19042 // 第二次:又丢 958 次,两次都不一样
丢多少是随机的,因为 count++ 根本不是一步,而是三步:读 count → 算 count+1 → 写回 count。volatile 能保证你读到的和写出去的是最新值,但管不住这两次操作中间被别人插一脚:线程 A 读到 5,线程 B 也读到 5 并写回 6,线程 A 再把自己算的 6 写回——两次自增只加了一次,丢更新。
换成 AtomicInteger 再跑同样一万次两线程,两次都精确是 20000,一个不丢。它内部就是 CAS(见下节)。
到这里可以把并发安全拆成三件事,记成一句话:
- 原子性:一个操作要么做完要么没做,不能被拆开。锁、CAS 管这个;volatile 不管。
- 可见性:一个线程的写,另一个线程能看见。volatile、锁管这个。
- 有序性:读写不被乱排(或者说,乱排的结果不让你感知到)。volatile、锁管这个。
volatile 只覆盖后两项。它不产生互斥——两个线程可以同时进同一段代码,谁也不等谁。
JMM 不讲缓存,讲 happens-before
不同 CPU 的内存模型差别很大:x86 强,ARM/POWER 弱。如果 Java 直接承诺某一种硬件行为,换个 CPU 程序就错。所以 JMM(Java 内存模型)不承诺硬件,它承诺的是一组规则:只要你按 happens-before 规则写,结果就一定像所有操作按程序顺序执行一样,至于 JVM 用什么手段(缓存、重排、屏障)达成,是实现细节。
什么是 happens-before?记一条定义就够:
若操作 A happens-before 操作 B,则 A 对内存的写入在 B 执行前对 B 可见,且 A 不会被重排到 B 后面。若两个操作之间没有这条边,JMM 不保证任何顺序——两线程读写同一个普通变量,读到什么全看运气。
面试常用的规则就几条:同一个线程内,按书写顺序,前面的 happens-before 后面的(程序顺序规则);对同一个锁,解锁 happens-before 后面的加锁;对一个 volatile 变量的写,happens-before 之后任意线程对它的读;Thread.start() happens-before 新线程里的一切,线程里的一切 happens-before Thread.join() 返回。最后一条传递性:A→B 且 B→C,则 A→C。
看到这里的坑了:老教程喜欢把 JMM 画成主内存 + 工作内存,再配 read/load/use/assign/store/write/lock/unlock 八种操作,说 volatile 强制主内存和工作内存同步。这是 JSR-133 之前的旧模型,靠它理解可见性还算直观,但它已经不是现行规范了。现行 JLS 第 17.4 节用同步序 + happens-before + 因果性定义,而 happens-before 才是你现在写代码和面试都能用的语言。真到了 2026 年的面试,背八种操作反而暴露你在背旧书。
volatile 那条规则有个极其常用的推论,就是发布模式。因为 volatile 写 happens-before 读,而传递性又把它前面的普通写一起带过去——一次 volatile 写,等于把之前所有写入的东西一起发布出来;一次 volatile 读,等于把别人发布的都拿回来。看代码:
public class HBPublish {
static int payload; // 普通变量,不带 volatile
static volatile boolean ready; // volatile 闸门
public static void main(String[] args) throws Exception {
int bad = 0;
for (int i = 0; i < 10_000 && bad < 3; i++) {
payload = 0; ready = false;
final int v = i;
Thread w = new Thread(() -> { payload = v; ready = true; });
w.start();
while (!ready) { } // 自旋等闸门
if (payload != i) bad++; // 读到旧 payload = 顺序保证被破坏
w.join();
}
System.out.println("bad=" + bad);
}
}
按 JMM,writer 先写普通字段 payload、再写 volatile 的 ready;reader 读到 ready 为 true 后去读 payload,靠传递性必然读到新值。本机跑一万次,bad=0,一次都没破坏。这就是为什么状态标志 + 普通字段、以及双重检查锁里的单例,能用 volatile 安全发布:volatile 变量是闸门,闸门打开时,前面堆的货必须已经摆好。
顺带补一条不用 volatile 的安全发布路径——final 字段。JLS 第 17.5 节给 final 特殊保证:对象在构造器里写定的 final 字段,对之后拿到该对象引用的其他线程,无需任何同步就保证可见(前提是引用没有在构造完成前逸出)。所以 String、Integer 这类不可变对象天然安全发布——「不可变」不只意味着改不了,还附赠了免同步的可见性;自己写不可变类(比如把 DCL 单例的字段做成 final)也能吃到这条保证。
内存屏障:volatile 靠什么兑现
上面全是 JMM 层面的语义承诺。实现层,JVM 靠的是内存屏障——一种特殊的 CPU 指令,阻止它两侧的读写越过它乱排。屏障按方向分四类:
| 屏障 | 防止的重排 | x86 需要吗 |
|---|---|---|
| LoadLoad | 后面的读跑到前面读前面去 | 不需要 |
| StoreStore | 后面的写跑到前面写前面去 | 不需要 |
| LoadStore | 后面的写跑到前面的读前面去 | 不需要 |
| StoreLoad | 后面的读跑到前面的写前面去 | 需要(写 volatile 时补) |
网上流传的版本会说 volatile 写在前后各插 StoreStore 和 StoreLoad、volatile 读后插 LoadLoad 和 LoadStore,把四类照单全收。这是拿 JSR-133 cookbook 的弱内存序通用方案当成了所有平台的实际实现——x86 根本不是这么干的。x86 是强内存序(TSO),硬件本身就不允许前三种重排,只有 StoreLoad 这种 store buffer 造成的重排需要处理,所以 HotSpot 在 x86 上对 volatile 写只做一件事:在写后面跟一条带 lock 前缀的指令(经典是 lock addl $0x0,(%rsp))。lock 指令同时把写刷出 store buffer、让其他核的缓存行失效,一个顶全套。volatile 读在 x86 上不用加任何屏障,就是一次普通读。
这也解释了 x86 上的一个直觉现象:volatile 写比读贵,瓶颈在写。只有在弱内存序的 CPU(ARM、POWER)上,JVM 才会老老实实把四类屏障按需插全,那是另一套开销。所以正确说法是:JMM 只规定语义(禁止哪些重排),具体插几条屏障、插在哪,由目标 CPU 的内存模型决定,不是一张通用公式走天下。
CAS:把读改写合成一步,但它不阻止别人
回到计数问题。想让 count++ 原子,除了加锁,还有 CAS。CAS 是 compare-and-swap,一条 CPU 指令级的原子操作,三个操作数:V(要改的内存位置)、A(期望当前值)、B(想写成的新值)。它做的事是:如果 V 此刻确实是 A,就把它换成 B,返回成功;否则说明有人动过了,返回失败,什么都不改。
AtomicInteger 就是把 CAS 包装成好用的 API。JDK 26 上 javap 反编译 incrementAndGet(),底层就一行:
Unsafe.getAndAddInt(this, VALUE, 1)
getAndAddInt 的循环逻辑多年没变,等价于:
int cur;
do {
cur = 读 V 的当前值; // 读
} while (!CAS(V, cur, cur + 1)); // 期望还是 cur 才写 cur+1,否则重读重试
看到关键点了吗:CAS 并不阻止别人在中间修改——它阻止不了,也拦不住,它只是能发现别人改过,发现自己手里的 A 过期了,就重试。所以它不是悲观锁那种先把资源占住的思路,而是乐观地假设冲突少、先试、失败重来。想用 CAS 写并发代码,这三步(读期望值、CAS 比较、失败重试)的循环模式就是全部骨架。
版本相关,面试容易踩:JDK 8 里这套代码在 sun.misc.Unsafe,方法叫 compareAndSwapInt;JDK 9 起模块化后搬到 jdk.internal.misc.Unsafe,方法改名 compareAndSetInt,逻辑一字没变。说源码时按你环境标版本,别背成一套到处套。
为什么 CAS 不需要加锁还能原子?因为 x86 上它最终落到 lock cmpxchg 指令——cmpxchg 本身不保证跨核原子,必须加 lock 前缀锁住总线/缓存行,比较和交换才是一步完成、中间没有其他核能插进来。这句也可以当面试的收尾:CAS 的原子性是 CPU 给的,不是 JVM 给的。
那锁和 CAS 怎么选?锁是悲观策略:假设一定冲突,先阻塞别人,代价是线程挂起和唤醒两次上下文切换,很贵。CAS 是乐观策略:冲突少时一次 CPU 指令就完事,比上下文切换便宜一个量级;但冲突激烈时它会空转重试,白白烧 CPU。所以高频累加用 CAS(AtomicInteger),冲突真的很大时改用 LongAdder 这种分片累加,或者干脆上锁。
ABA:值改回去了,不等于没被改过
CAS 只看值,这带来一个经典陷阱。线程 1 读到 V=A,正打算 CAS;线程 2 抢先把 A 改成 B,又改回 A;线程 1 执行 CAS 时发现 V 还是 A,判定没人动过,于是成功——实际上中间被改了两次。如果被改的变量代表身份、版本这类语义(比如无锁栈的栈顶指针:A 出栈又进栈,指向同一块区域但内容已经换了),线程 1 就会拿着过期的判断做错事。
解法是给值配版本号:每次修改版本号 +1,CAS 时值和版本号一起比。Java 对应 AtomicStampedReference,它的 compareAndSet(期望引用, 新引用, 期望版本, 新版本) 两个一起比较,A→B→A 即使引用值回到 A,版本号也已经涨了两次,线程 1 一眼看穿。只想标记有没有被动过,用 AtomicMarkableReference 更轻。注意版本号用自增,别用会回绕的数(比如时间戳秒数就可能绕回)。
多数场景 ABA 无害——计数器从 1 变 2 再变 1,对只关心最终值的人没区别。它只在值承载身份/不可重复语义时才是真雷,面试能说出这句就比背结论高一级。
volatile、CAS、锁怎么选
- 状态标志、开关、配合普通字段做发布(shutdown 标志、双检锁单例、HBPublish 那种闸门)→ volatile,读多写少几乎零成本。
- 单变量的累加、计数、唯一自增 → CAS/AtomicInteger。
- 先查后改、跨多个变量的一致性、临界区里做一串事 → 锁(synchronized 或 ReentrantLock)。CAS 也能自己拼 check-then-act,但要手写循环 + 处理失败,容易错。
- 记住 volatile 的边界:它给不了你任何复合原子性。
volatile boolean也不能保证只有一个线程能把 false 改成 true,想独占这种判断得上锁或 CAS。
验证方法
本机 JDK 26.0.1,按顺序跑,每一步预期都写清楚:
javac -encoding UTF-8 VisibilityDemo.java && java VisibilityDemo # 卡死:readerAlive=true
java -Xint VisibilityDemo # 正常退出,spins 千万级
# 把 flag 改成 volatile 再 javac && java # 立即正常退出
java CountDemo # volatile count 两次都 < 20000(丢更新),AtomicInteger 两次都 = 20000
java HBPublish # bad=0(volatile 发布普通字段)
想亲眼看 volatile 写那条 lock 指令,可以用 -XX:+PrintAssembly 打印 JIT 汇编搜 lock 前缀,但需要 hsdis 插件,配置麻烦。看不到汇编不影响理解:记住语义上禁止哪些重排,实现上 x86 只在写后补一条 lock 指令就够。
面试速答
volatile 保证可见性和有序性,不保证原子性:禁止 JVM/CPU 把它的读写乱排,写操作在 x86 上通过一条 lock 前缀指令把值刷出去并失效别的核的缓存行,读操作强制回内存;配合 happens-before,一次 volatile 写能把之前所有普通写一起发布出来,这是双检锁和状态标志能安全工作的根因。CAS 是乐观的无锁原语,CPU 用 lock cmpxchg 把比较和交换做成原子的一步,三个操作数 V/A/B,失败就重试,它的原子性是 CPU 给的、但它不阻止别人改、只负责发现自己手里的期望值过期;坑是 ABA,用 AtomicStampedReference 加版本号解决。三者的分工一句话:读多写少的状态用 volatile,单变量高频累加用 CAS,复合操作和多变量一致性用锁。
本文关键词:JMM、volatile、CAS、内存屏障、ABA问题
核心收获一句话:并发安全拆成原子性、可见性、有序性三件事,volatile 只管后两件,CAS 用一条 CPU 指令把读改写做成原子的一步但躲不开 ABA。下一步可以回自己项目里找一个先查后改的代码(比如先判断再 put 的缓存),用 Atomic* 的 getAndUpdate/updateAndGet 重写一遍,跑并发压测对比丢更新和性能,比背结论有用得多。