承接的问题
第 4 章到第 7 章讲的是「让别人看不见中间状态」:要么用锁把临界区围起来,要么用 volatile 发布快照,要么把状态绑到作用域上(7.3 的 ScopedValue)。前两者的共同点是阻塞——抢不到锁就挂起,挂起就要切线程;ScopedValue 则解决的是「隐式传参」而不是互斥,它本身也不做原子更新。
CAS 走另一条路:不阻塞,失败就重试。它把「读-改-写」压成一条带条件的指令,由硬件保证「比较」与「写入」之间插不进别人。这条路带来两个新问题:失败重试要付多少成本,「值仍然是那个值」为什么不等于「状态没有变过」。前者是性能问题,后者是正确性问题,也就是 ABA——它不会让程序变慢,只会让程序悄悄给出错误结果。
先把结论放在前面:
| 问题 | 结论 |
|---|---|
| CAS 的原子性来自哪 | 硬件指令(x86 lock cmpxchg,ARM LL/SC 或 LSE casal) |
| CAS 的内存语义 | volatile 读 + volatile 写,一次完整的 acquire/release |
| 失败重试的成本 | 与竞争线程数成正比;本机 8 线程下 80 万次自增伴随约 67 万~72 万次失败 CAS |
| ABA 在 Java 里何时出现 | 节点被复用(对象池),或状态本身是值(AtomicInteger 状态机) |
| 修复手段 | 给引用配一个版本戳(AtomicStampedReference)或一个标记位(AtomicMarkableReference) |
一条指令,两种失败
AtomicInteger.compareAndSet 在 JDK 25 里的实现只有一行:
public final boolean compareAndSet(int expectedValue, int newValue) {
return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
}
U 是 VarHandle 的门面,VALUE 是类初始化时通过 MethodHandles.lookup().findVarHandle(...) 拿到的字段句柄。HotSpot 认识这个调用,会把它内联成目标平台的一条指令:
| 平台 | 指令 | 说明 |
|---|---|---|
| x86-64 | lock cmpxchg | lock 前缀锁缓存行,同时充当全屏障 |
| AArch64 | ldaxr / stlxr 循环 | LL/SC,失败由 stlxr 返回非零 |
| AArch64 + LSE | casal | ARMv8.1 的单条原子指令 |
本机没有安装 hsdis,拿不到反汇编,所以这里只给指令名不给机器码。语义不依赖具体指令,这也是第 2.3 节用 JCStress 做行为级验证、而不是比对汇编的原因。
CAS 有两种失败,必须分开:值不匹配是正常的竞争失败,重读重试即可;虚假失败是值明明匹配、指令仍然返回失败——LL/SC 架构上,ldaxr 与 stlxr 之间只要发生过任何写(哪怕写的是别的地址),stlxr 就会失败。这就是 weakCompareAndSet 存在的原因,第 8.2 节展开。
Java 的 compareAndSet 承诺「不虚假失败」,所以循环可以只依赖返回值:读当前值、算新值、CAS,失败就整段重来。但这不是无代价的:每次失败都要重新读、重新算、再发一条原子指令,而原子指令会锁缓存行,失败重试把这条缓存行在核间来回弹。
实测:8 个线程各做 10 万次手写 CAS 自增,统计失败次数:
AtomicInteger counter = new AtomicInteger();
AtomicInteger failures = new AtomicInteger();
// 8 个线程,每个线程
for (int k = 0; k < 100_000; k++) {
for (;;) {
int prev = counter.get();
if (counter.compareAndSet(prev, prev + 1)) break;
failures.incrementAndGet();
}
}
本机两次运行:
manual CAS loop: counter=800000 (expect 800000), failed CAS=674249
manual CAS loop: counter=800000 (expect 800000), failed CAS=720863
结果是正确的 800000,但大约每 1.2 次成功就伴随 1 次失败。失败次数会随线程数超线性增长:线程越多,读到同一个旧值的线程越多,被浪费的 CAS 越多。这就是高竞争下 CAS 循环的吞吐上限,也是第 8.3 节 LongAdder 要解决的问题。
CAS 的内存语义
compareAndSet 不是一个「只保证原子」的操作,它的内存效果等价于一次 volatile 读加一次 volatile 写:
- 读
expectedValue时是 volatile 读,能看到之前所有已经发布的写; - 成功写入时是 volatile 写,写入之前的所有写对读到新值的线程可见;
- 失败时也完成了一次 volatile 读,所以能看到别人写入的最新值。
这条语义有两个直接推论。一是 CAS 可以当发布手段:用 CAS 把构造完成的不可变对象写进字段,读到新引用的线程能看到构造器里的全部写入。二是 CAS 只保护它自己那一个变量:用 CAS 扣库存、再用另一个 CAS 扣配额,中间可能被别的线程看到「库存已减、配额未减」的半成品状态。
getAndUpdate、accumulateAndGet 看起来是「一次调用」,内部仍是 CAS 循环(第 8.2 节给源码)。传入的 lambda 可能被执行多次,所以函数必须是纯的:不要在 lambda 里打日志、计数、发消息,否则重试会重复执行。
Treiber 栈:ABA 的最小舞台
无锁栈是讲 ABA 最合适的载体,因为它的状态只有「栈顶引用」一个变量,逻辑短到可以把交错画出来。
final class Node<T> {
final T value;
Node<T> next;
Node(T value) { this.value = value; }
}
final class TreiberStack<T> {
private final AtomicReference<Node<T>> top = new AtomicReference<>();
void push(T value) { pushNode(new Node<>(value)); }
void pushNode(Node<T> n) {
n.next = top.get();
while (!top.compareAndSet(n.next, n)) {
n.next = top.get();
}
}
Node<T> peekNode() { return top.get(); }
boolean casTop(Node<T> expected, Node<T> update) {
return top.compareAndSet(expected, update);
}
T pop() {
for (;;) {
Node<T> old = top.get();
if (old == null) return null;
Node<T> next = old.next;
if (top.compareAndSet(old, next)) return old.value;
}
}
List<T> snapshot() {
List<T> out = new ArrayList<>();
for (Node<T> n = top.get(); n != null; n = n.next) out.add(n.value);
return out;
}
}
pushNode 是给「复用节点」留的入口:对象池把弹出来的节点还回去时走这里。casTop 暴露了「先读、后算、再 CAS」这个可被拆开的窗口——真实代码里这个窗口由两条指令之间的距离决定,这里用门闩把它拉到无限长,让交错百分之百发生。
ABA 复现:2000 次,2000 次损坏
场景:栈里是 n3 -> n2 -> n1(值 3、2、1)。
popper读到栈顶n3和n3.next == n2,然后停住。helper连续弹三次,拿到 3、2、1,栈变空;再把n3这个同一个节点对象压回去。popper用陈旧快照(expected = n3, update = n2)做 CAS。
此时栈顶确实是 n3——同一个对象引用、同一个值。CAS 成功,栈顶被写成 n2。
Thread popper = new Thread(() -> {
Node<Integer> old = stack.peekNode();
Node<Integer> next = old.next;
staleNode.set(old);
read.countDown();
await(go);
if (stack.casTop(old, next)) out.returned.add(old.value);
});
Thread helper = new Thread(() -> {
await(read);
out.returned.add(stack.pop()); // 3
out.returned.add(stack.pop()); // 2
out.returned.add(stack.pop()); // 1
stack.pushNode(staleNode.get()); // 复用节点,把 n3 压回
go.countDown();
});
本机输出:
== plain Treiber stack, one interleaving ==
returned=[3, 2, 1, 3] rest=[2, 1]
helper 已经把栈弹空,只放回了 3,所以正确终态应该是 [3]。实际终态是 [2, 1]:n2 和 n1 已经被弹出、从来没有被重新压入,却重新出现在栈里,下一次 pop 会把它们再交出去一次。在节点池里,这意味着同一个节点被分配给两个使用者;在任务队列里,这意味着一个任务被执行两遍。
这个交错不是碰运气:把同一段代码跑 2000 次,每次都用门闩固定顺序,统计终态不等于 [3] 的次数:
rounds=2000 plainBad=2000 stampedBad=0
stampedBad=0 是修复后的结果,下一节说。先说 2000/2000 这个数字意味着什么:ABA 一旦条件成立,就是确定性的错误,不是概率问题。线上难复现,是因为「另一个线程恰好弹空并复用同一个节点」这个窗口通常极短。
Java 里 ABA 从哪来
C/C++ 里 ABA 的经典来源是内存分配器:节点被 free 之后,下一次 malloc 可能拿回同一个地址。Java 有 GC,对象地址不会被这样复用,所以很多人以为「Java 没有 ABA」。这个判断是错的,只是来源变了:
来源一:显式复用对象。 对象池、ThreadLocal 缓存、手写的空闲链表,都会把同一个对象重新放回结构里。只要 compareAndSet 比较的是引用,ABA 就成立。
来源二:状态本身是值。 这时连复用都不需要。AtomicInteger 做状态机,0 -> 1 -> 0 走完之后,compareAndSet(0, 2) 依然成功:
AtomicInteger state = new AtomicInteger(0);
System.out.println("baseline CAS(0,2): " + state.compareAndSet(0, 2));
state.set(0);
state.compareAndSet(0, 1);
state.compareAndSet(1, 0);
System.out.println("after 0->1->0, CAS(0,2): " + state.compareAndSet(0, 2));
本机输出:
baseline CAS(0,2): true, state=2
after 0->1->0, CAS(0,2): true, state=2
两次都返回 true,但中间状态完全不同。如果这个状态机表示「订单未支付 → 支付中 → 未支付(支付失败回滚)」,那么第二行的 CAS 会把一个已经失败过的订单推进到「已支付」。这类 bug 在代码评审里几乎看不出来,因为两行 CAS 长得一模一样。
判断标准很简单:CAS 的期望值能否区分「没变过」和「变了又变回来」。不能区分,就有 ABA 风险。
修复:给引用配一个版本戳
AtomicStampedReference 把「引用 + 整数版本戳」打包成一个整体,compareAndSet 要求两者同时匹配,成功时版本戳加一。任何一次成功写入都会让旧版本戳失效,ABA 里的「变回来」于是变成「版本戳对不上」。
final class StampedStack<T> {
private final AtomicStampedReference<Node<T>> top =
new AtomicStampedReference<>(null, 0);
void pushNode(Node<T> n) {
int[] stamp = new int[1];
for (;;) {
Node<T> cur = top.get(stamp);
n.next = cur;
if (top.compareAndSet(cur, n, stamp[0], stamp[0] + 1)) return;
}
}
T pop() {
int[] stamp = new int[1];
for (;;) {
Node<T> cur = top.get(stamp);
if (cur == null) return null;
Node<T> next = cur.next;
if (top.compareAndSet(cur, next, stamp[0], stamp[0] + 1)) return cur.value;
}
}
Node<T> peekNode() { return top.getReference(); }
int stamp() { return top.getStamp(); }
boolean casTop(Node<T> expected, Node<T> update, int expectedStamp) {
return top.compareAndSet(expected, update, expectedStamp, expectedStamp + 1);
}
}
注意 int[] stamp = new int[1] 这个别扭的写法:get(int[]) 需要一个出参容器,因为 Java 不能返回两个值。不要用 getReference() 再 getStamp() 拼一个「快照」——两次调用之间版本戳可能已经变了,拼出来的组合从来没有真实存在过。要读就一次 get(int[]) 读全。
同样的交错,同样的门闩,只把栈实现换掉:
== stamped stack, same interleaving ==
returned=[3, 2, 1, 3] rest=[]
popper 的 CAS 因为版本戳对不上而失败,走重试分支重新读:此时栈顶是 n3,但 n3.next 已经是 null(pushNode 重新设置过),于是正确地弹出 3,栈变空。2000 轮里 0 次损坏。
代价要说清楚:每个节点多一个 int 版本戳,每次 CAS 多比较一个字段,且版本戳会回绕——int 在 20 亿次更新后回到原值,理论上 ABA 会重新出现。多数业务的生命周期远短于这个数字;对每秒千万次更新的热点结构,要么自己封装 long 版本戳,要么换一种不需要版本戳的算法。
AtomicMarkableReference:只需要「一次」的场景
有些场景不关心「变过几次」,只关心「有没有被标记过」。AtomicMarkableReference 把版本戳换成一个 boolean,语义是「引用 + 标记位」。
AtomicMarkableReference<String> claim =
new AtomicMarkableReference<>("T-2002", false);
boolean first = claim.compareAndSet("T-2002", "T-2002", false, true);
boolean second = claim.compareAndSet("T-2002", "T-2002", false, true);
本机输出:
claim once 1st=true, 2nd=false, marked=true
这是「只允许领取一次」的标准写法:compareAndSet(ref, ref, false, true) 只有在标记位为 false 时才成功,第二次因为标记已是 true 而失败。整个判断和置位是一次原子操作,不需要额外的锁或 AtomicBoolean。
| 需求 | 选择 | 理由 |
|---|---|---|
| 检测「变过又变回来」 | AtomicStampedReference | 版本戳能区分次数 |
| 只关心「是否被处理过」 | AtomicMarkableReference | 一个 bit 足够,内存更省 |
但 attemptMark 不是这个语义。本机实测 attemptMark("T-1001", true) 连续两次都返回 true:它只比较引用是否等于 expectedReference,然后把标记设成 newMark,完全不管当前标记是什么。想让「重复标记」失败,必须用四参数的 compareAndSet,把期望标记写进去。
在 libraryd-conc 里落地
借书扣库存是 CAS 的典型场景:库存是一个整数,扣减是「读-改-写」,而且必须防止扣成负数。
public final class AtomicLoanService implements LoanService {
private final BookRepository books;
private final LoanRepository loans;
private final ConcurrentMap<String, AtomicReference<Book>> slots = new ConcurrentHashMap<>();
public AtomicLoanService(BookRepository books, LoanRepository loans) {
this.books = books;
this.loans = loans;
}
private AtomicReference<Book> slotOf(String isbn) {
return slots.computeIfAbsent(isbn, key -> new AtomicReference<>(
books.findByIsbn(key).orElseThrow(
() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, key))));
}
/** delta 为 -1 时要求库存足够;返回 true 表示槽位已更新。 */
private boolean tryAdjust(String isbn, int delta) {
AtomicReference<Book> slot = slotOf(isbn);
for (;;) {
Book cur = slot.get();
if (cur.stock() + delta < 0) return false;
Book next = new Book(cur.isbn(), cur.title(), cur.stock() + delta);
if (slot.compareAndSet(cur, next)) {
books.save(next);
return true;
}
}
}
@Override
public Reservation reserve(String isbn, String memberId) {
if (!tryAdjust(isbn, -1)) {
throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
}
return new Reservation("L-" + isbn + "-" + memberId, isbn, memberId,
Instant.now().plusSeconds(30L * 24 * 3600));
}
@Override
public boolean returnBook(String loanId) {
return loans.findById(loanId).map(loan -> tryAdjust(loan.isbn(), 1)).orElse(false);
}
}
这段代码有两个刻意的设计。
其一,CAS 的对象是不可变的 Book 记录。 AtomicReference 存的是整个 Book,扣减是「造一个新 Book 再整体替换」。没有「先改 stock 再发布」的窗口,读到引用的线程看到的一定是自洽的库存。
其二,库存和配额不是一次原子操作。 真实实现还要扣会员配额,那是另一个 CAS(或另一把锁)。两个 CAS 之间可以被别的线程看见,第 8.2 节会把这两个状态合并成一个可 CAS 的槽位。这一节先只保证「库存不为负」这一条不变量。
想验证这段代码,把 compareAndSet 换成 slot.set(next),再把并发压上去,就能看到超卖——那是第 2.1 节的形态在无锁版本里的复现。
常见错误与边界
| 写法 | 问题 | 修复 |
|---|---|---|
get() 后无条件 set() | 读和写之间没有原子性 | 用 compareAndSet 循环 |
在 getAndUpdate 的 lambda 里打日志 | 重试会重复执行副作用 | 把副作用移到 CAS 成功之后 |
用 getReference() + getStamp() 拼快照 | 两次读之间可能被改 | 用 get(int[]) 一次读全 |
attemptMark 当作「只标记一次」 | 它不检查当前标记 | 用四参数 compareAndSet |
| 复用节点却不加版本戳 | ABA 必然发生,且不可见 | AtomicStampedReference 或禁止复用 |
| 用 CAS 保护两个变量 | 两次原子操作之间可被观察 | 合并成不可变对象,或改用锁 |
| 把 CAS 循环当万能锁 | 长临界区下重试成本爆炸 | 长临界区用 ReentrantLock |
什么时候不要用 CAS:临界区超过十几条指令时,失败重试的浪费会超过锁的挂起成本;需要等待条件成立时,CAS 只会空转而不会让线程睡眠,应该用 Condition 或 park/unpark(第 9.1 节);需要保护多个字段时,一个 CAS 只能原子地改一个位置;竞争极其激烈又只需要计数时,那是 LongAdder 的领域。
小结
compareAndSet的原子性来自硬件指令,内存语义等价于一次 volatile 读加一次 volatile 写;失败重试是固有成本,本机 8 线程 80 万次自增伴随约 67 万~72 万次失败 CAS。- ABA 的本质是「期望值无法区分没变过与变了又变回来」。Java 里它来自节点复用或值状态机,一旦触发就是确定性错误:用门闩固定交错后 2000/2000 次复现。
AtomicStampedReference用「引用 + 版本戳」把 ABA 压到 0 次;读版本戳必须用get(int[]),不能分两步取。AtomicMarkableReference适合「只处理一次」,但attemptMark不检查当前标记,正确写法是四参数compareAndSet。AtomicLoanService用「不可变Book+ CAS 整体替换」保证库存不为负;库存与配额的一致性需要另想办法,下一节给出。
下一节把原子类家族铺开:基本类型、引用类型、数组、字段更新器各自解决什么问题,以及 weakCompareAndSet 在 JDK 25 里到底是什么语义。
阅读导航:上一节 7.3 ScopedValue:JDK 25 的作用域值 | 下一节 8.2 原子类家族与字段更新器
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。