承接的问题
卷一第 8 章已经把 ABA 讲清楚了:CAS 只比较值,值回到原样它就分不出"没变过"和"变了又变回来"。那里给的是概念和 AtomicStampedReference 的用法。这一节往前一步,只回答三个实现层面的问题:
- 怎么把 ABA 稳定复现出来?并发 bug 最难的地方是跑十次只错一次,看不见就没法验证修复。
AtomicStampedReference到底怎么把"引用 + 版本"做成一次 CAS?代价是什么?- Java 有垃圾回收,节点不会被 free 掉再 malloc 到同一个地址,为什么 ABA 还是会发生?
把一次 pop 拆成两步,ABA 就能稳定复现
真正的 pop 是原子的,ABA 窗口藏在"读"和"CAS"之间,靠多线程去撞概率很低。我的做法是把这一步拆开,暴露成两个方法:snapshot() 只读栈顶和它的 next,commit() 只按旧快照提交。然后在一个线程里,按剧本把两个"线程"的动作交错执行——结果每次都一样。
static final class AbaStack {
volatile Node top;
Node snapshot() { return top; } // 读快照
boolean commit(Node expected, Node update) { // 按旧快照提交
return TOP.compareAndSet(this, expected, update);
}
int pop() { // 正常的一次性 pop
for (;;) {
Node t = top;
if (t == null) return Integer.MIN_VALUE;
if (TOP.compareAndSet(this, t, t.next)) return t.value;
}
}
void pushNode(Node n) { // 压入指定节点(可复用)
for (;;) {
Node t = top;
NEXT.set(n, t);
if (TOP.compareAndSet(this, t, n)) return;
}
}
}
剧本是这样:栈里是 1 -> 2 -> 3。T1 读到 top=N1、N1.next=N2,准备把栈顶改成 N2。就在这一刻,T2 连做三件事——弹出 1、弹出 2、把同一个 N1 对象重新压回去。最后 T1 拿着旧快照提交:
AbaStack s = new AbaStack();
Node n3 = new Node(3, null), n2 = new Node(2, n3), n1 = new Node(1, n2);
s.pushNode(n3); s.pushNode(n2); s.pushNode(n1); // [1 -> 2 -> 3]
Node observed = s.snapshot();
Node observedNext = observed.next; // 记下 N1 -> N2
s.pop(); // T2: 弹出 1
s.pop(); // T2: 弹出 2
s.pushNode(n1); // T2: 把 N1 原对象压回
boolean ok = s.commit(observed, observedNext); // T1 提交旧快照
本机输出(java -cp out jucch10.AbaDemo,每次运行完全一致):
## A. 朴素 Treiber 栈
初始栈 [1 -> 2 -> 3]
T1 读到 top=N1 next=N2(准备 CAS 成 N2)
T2 pop()=1 [2 -> 3]
T2 pop()=2 [3]
T2 push(N1) 复用原节点 [1 -> 3]
T1 commit(N1->N2)=true [2 -> 3]
T1 pop()=1(它交付的 N1)
最终栈 [2 -> 3],交付序列 [1, 2, 1]
证据 1:T2 刚压入的 N1 被 T1 的 CAS 覆盖,这次 push 丢失
证据 2:N2 在第二次 pop 时已交付,现在又回到栈顶,会被再交付一次
账本:push 4 次,交付 3 次,栈内应剩 1 个,实际剩 2 个
注意 commit 返回的是 true。CAS 完全"成功",栈顶确实是 N1,没有什么可指责的——但 N1 已经被 T2 弹过一次又压回来,它代表的已经不是 T1 读到那一刻的状态。结果是两条独立的破坏:T2 刚压进去的 N1 被覆盖(丢失更新),以及早已交付过的 N2 回到栈顶(重复交付)。这就是 ABA 最难查的地方:没有异常、没有超时、没有死锁,只有账对不上。
再看不复用节点的情况。如果每次 push 都 new Node(...),被弹出的对象再也回不到 top,ABA 就构造不出来。这不是运气好,是 Java 的栈结构在"节点不复用"前提下的一个真实性质:top 只会在互不相同的对象之间移动。JDK 的 ConcurrentLinkedQueue 源码注释把这一点写得很直白——在带 GC 的系统里不存在因节点回收导致的 ABA,所以不需要计数指针。问题出在你自己复用对象。
AtomicStampedReference:把版本和引用塞进同一个对象
要让 CAS 区分"没变过"和"变了又变回来",就得把版本号一起比较。两次比较必须是一次原子操作,所以版本不能是另一个字段,只能和引用打包进一个不可变对象,再对整个对象做 CAS:
// JDK 25 源码摘录,已删减
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair;
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
private static final VarHandle PAIR = MhUtil.findVarHandle(
MethodHandles.lookup(), "pair", Pair.class);
private boolean casPair(Pair<V> cmp, Pair<V> val) {
return PAIR.compareAndSet(this, cmp, val);
}
}
javap -p 确认字段名和句柄名,避免照着旧文章写错:
public class java.util.concurrent.atomic.AtomicStampedReference<V> {
private volatile java.util.concurrent.atomic.AtomicStampedReference$Pair<V> pair;
private static final java.lang.invoke.VarHandle PAIR;
public boolean compareAndSet(V, V, int, int);
public boolean attemptStamp(V, int);
private boolean casPair(java.util.concurrent.atomic.AtomicStampedReference$Pair<V>,
java.util.concurrent.atomic.AtomicStampedReference$Pair<V>);
}
三个实现细节值得注意。
第一,compareAndSet 有一个短路快路径:如果新旧引用、新旧戳都相同(newReference == current.reference && newStamp == current.stamp),直接返回 true,既不分配 Pair 也不做 CAS。这解决了一个实际问题:往 AtomicStampedReference 里反复写同一个值不应该产生垃圾。
第二,weakCompareAndSet 在 JDK 25 里就是一句 return compareAndSet(...)。javadoc 保留了"可能伪失败、不提供排序保证"的措辞,但实现没有走弱版本。以 javadoc 为准写代码是对的——你不能依赖它现在恰好是强实现。
第三,attemptStamp(expectedReference, newStamp) 只改戳不改引用,同样带"引用必须匹配"的前置条件。它适合"我已经确认引用是对的,现在只想推进版本号"的场景,比如把快照标记为已发布。
代价也很直接:每次更新分配一个新 Pair(两个字段,对象头加字段约 24 字节),并且 CAS 的对象从"引用"变成了"引用 + 版本"两段比较。对一个每秒几百万次的栈顶来说,这是实打实的额外开销——这也是为什么 ConcurrentLinkedQueue 不用版本戳,而选择"节点不复用"这条路。
修复后的对照,同一个剧本换成 AtomicStampedReference 包装的栈:
static final class StampedStack {
record Snapshot(Node ref, int stamp) {}
private final AtomicStampedReference<Node> pair = new AtomicStampedReference<>(null, 0);
Snapshot snapshot() {
int[] stamp = new int[1];
Node ref = pair.get(stamp);
return new Snapshot(ref, stamp[0]);
}
boolean commit(Snapshot snap, Node update) {
return pair.compareAndSet(snap.ref(), update, snap.stamp(), snap.stamp() + 1);
}
}
## B. 带版本戳的栈
初始栈 [1 -> 2 -> 3] stamp=3
T1 读到 top=N1 stamp=3
T2 pop()=1 [2 -> 3] stamp=4
T2 pop()=2 [3] stamp=5
T2 push(N1) 复用原节点 [1 -> 3] stamp=6
T1 commit(旧戳)=false → 重新读栈
T1 重读 top=N1 stamp=6 再 commit=true [3]
最终栈 [3],交付序列 [1, 2, 1]
账本:push 4 次,交付 3 次,栈内剩 1 个,合计 4,账对上
T1 的旧戳提交被拒,重读后看到的是 stamp=6 的新世界,它这次的 pop 交出的是真正在栈顶的 N1,最终栈 [3] 与账本一致。注意修复的形态:不是让 CAS 变聪明,而是让失败的 CAS 能重来。 所有无锁结构的重试循环都必须能安全重入,包括重读快照这一步。
stamp 是 int,2^31 次更新后回绕。对一个每天几千万次更新的结构,理论上几十天就会绕一圈;实践里几乎碰不到,因为一次绕圈要求同一个对象存活期间恰好发生 2^31 次"变回原值"。如果你的结构真的会跑那么久,正确做法不是换 long(没有 AtomicLongStampedReference),而是根本不要复用节点。
有 GC 为什么还会 ABA
C/C++ 里 ABA 有两个来源:一是 free 之后 malloc 拿到同一块地址,二是一个值本身就回到了原样。GC 消灭了第一个——只要还有引用指着对象,它就不会被回收,所以"地址相同但对象不同"在 Java 里构造不出来。
但第二个来源和内存管理无关。前面那段输出里 top 从头到尾指向的都是同一个 N1 对象,没有任何地址复用。改变的是"这个对象代表的状态":它曾经是栈顶,被弹出,又被压回。引用相等,语义不等。
现实里会复用节点的地方比想象中多:
| 场景 | 复用什么 | 后果 |
|---|---|---|
| 节点池 / 对象池 | 弹出的节点放回池,下次取出复用 | 池化本身就把 ABA 变成了常态 |
| 重试逻辑重推同一节点 | 失败后把同一个节点再压一次 | 与本节剧本完全一致 |
| 链表内的原地移动 | 摘下一个节点挂到别处(LRU、跳表) | 同一节点在两个位置之间往返 |
| 树的旋转 | 节点在父子之间换位 | 引用不变、结构已变 |
反过来,不复用就一定安全吗?对 Treiber 栈是的,对 MS 队列不是——队列的 head 和 tail 会同时指向 dummy 结点,出队后 dummy 被换掉,但 tail 可能还指着旧的 dummy。这种"两个指针指向同一结点"的关系靠"不复用"解决不了,要靠 10.1 讲的帮助推进和自环。所以结论不是"Java 不需要 ABA 防护",而是"Java 的 ABA 防护需求由你的对象生命周期策略决定"。
至于 C++ 里的 Hazard Pointer、epoch-based reclamation,在 Java 里没有对应问题——被摘掉的节点只要没有强引用就该被回收。Java 的对应问题变成了"内存滞留":一个出队节点如果还挂着整条链,会把后面所有结点一起钉在堆里。ConcurrentLinkedQueue.updateHead 用 NEXT.setRelease(h, h) 把旧 head 自环,顺手就把这条链断开了。
AtomicMarkableReference 的边界
如果只关心"有没有被标记过",不需要精确计数,AtomicMarkableReference 比版本戳便宜——它把版本换成一个 boolean:
// JDK 25 源码摘录,已删减
public boolean compareAndSet(V expectedReference,
V newReference,
boolean expectedMark,
boolean newMark) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedMark == current.mark &&
((newReference == current.reference &&
newMark == current.mark) ||
casPair(current, Pair.of(newReference, newMark)));
}
结构和 AtomicStampedReference 完全同构,只是 int stamp 换成 boolean mark。它够用的场景有一个共同特征:只需要区分两态,且不关心回到了哪一态。
- 逻辑删除标记。节点被删除时标一下,遍历时跳过;重复标记无害。
- 一次性开关。比如"这个资源已经被某个线程认领",标记之后不再回退。
- 配合
attemptMark做 CAS 之外的"确认后打标"。
不够用的场景同样明确:
第一,需要区分"变了 1 次"和"变了 3 次"。一个 bit 只能表达两态,A→B→A 和 A→B→C→A 在它眼里完全一样。这正是 ABA 的核心诉求,所以用 AtomicMarkableReference 修 ABA 是错的,它只能修"是否被标记过"这一类。
第二,需要多个并发状态。两个线程都想标记,先到的成功、后到的看到 mark=true 就放弃——这个语义对"删除"够用,对"我要记录是谁删的、什么时候删的"就不够了。
第三,需要回退。compareAndSet(ref, ref, true, false) 可以把标记清掉,但这一操作和"引用变了又变回来"组合起来,仍然分不清中间发生过什么。
一句话:AtomicMarkableReference 是"带标记的引用",不是"带版本的引用"。选型时先问自己"我需要知道变化了几次吗",答案是需要,就只能上 AtomicStampedReference 或者干脆不复用节点。
在 libraryd-conc 里落地
库存快照的"试算—校验"流程天然需要版本判断。借书时先拍一份快照,算完库存后提交,提交时必须确认"这份快照还是基于最新库存算出来的"——这正是戳的用途,而不是 ABA 的兜底:
package com.plume.conc.core;
import java.util.concurrent.atomic.AtomicStampedReference;
/** 库存快照的发布点:版本号必须和引用一起原子更新。 */
public final class StockPublisher {
public record Snapshot(String isbn, int stock) {}
private final AtomicStampedReference<Snapshot> current =
new AtomicStampedReference<>(null, 0);
/** 试算:读当前快照和版本。 */
public Snapshot read(int[] stampHolder) {
return current.get(stampHolder);
}
/**
* 提交:只有当当前快照仍是我读到的那个版本时才写入。
* 返回 false 表示期间有人改过,调用方必须重读重算。
*/
public boolean publish(Snapshot expected, int expectedStamp, Snapshot next) {
return current.compareAndSet(expected, next, expectedStamp, expectedStamp + 1);
}
public int stamp() {
return current.getStamp();
}
}
这个类里戳的作用是乐观锁的版本号,和 ABA 无关——即使快照内容恰好变回原样,版本不同就应该拒绝。这带来一个额外好处:publish 返回 false 的语义非常清晰(“你基于旧版本算的,重来”),不需要调用方去比较库存数字。
真正需要 ABA 防护的是"节点被复用"的那一半:库存快照对象如果来自对象池,expected == next 的引用比较就会失真。所以这里的 Snapshot 是 record,每次发布都是新对象,从设计上消灭了复用。这比加版本戳便宜得多。
常见错误与边界
第一,用 AtomicMarkableReference 修 ABA。它只有一个 bit,区分不了变化次数。
第二,以为"Java 有 GC 所以没有 ABA"。GC 只解决了地址复用,逻辑 ABA 依然存在,是否发生取决于你是否复用对象。
第三,在重试循环里不重读快照。CAS 失败后拿旧快照再试一次,等于把 ABA 窗口自己撑大;正确的重试必须从 get(stampHolder) 重新开始。
第四,用 stamp 做业务版本号。AtomicStampedReference 的戳是给 CAS 用的单调计数,会回绕,也没有"跳号"语义。业务版本号该用 AtomicLong(8.2 里已经把这个边界写过)。
第五,给 AtomicStampedReference 每次写入都构造新 Pair。往同一个值反复写会一直分配;虽然 compareAndSet 有空写快路径,set 没有——set 只在值真的变化时才写,但仍然会分配。
第六,在热路径上用版本戳。每次更新一个 24 字节的 Pair,对每秒千万次的结构是灾难。先问"能不能不复用节点",不能,再上戳。
小结
把 pop 拆成"读快照"和"按快照提交"两步,ABA 就从概率问题变成了确定性剧本:T2 弹出 1、弹出 2、把 N1 原对象压回,T1 的旧快照提交依然成功,于是 T2 的 push 被覆盖、N2 被重复交付,账本上多出一个元素。AtomicStampedReference 把引用和 int 版本打包进 Pair,用 VarHandle PAIR 对整个 Pair 做 CAS,compareAndSet 里还有一条"新旧完全一样就直接返回 true"的空写快路径。Java 的 GC 消灭了地址复用这一类 ABA,但节点池、重试重推、原地移动这些主动复用依然会制造逻辑 ABA。AtomicMarkableReference 只适合两态标记,不能拿来计数版本。
下一节换到测量侧:既然无锁结构会重试,那重试到底要付多少代价,在什么竞争度下它会输给一把锁。
阅读导航:上一节 10.1 无锁栈与 Michael-Scott 队列 | 下一节 10.3 无锁 vs 有锁实测
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。