上个月排查一个偶发丢消息的线上问题,整整折腾了三天。最终根因落在一行看似再正常不过的AtomicReference.compareAndSet上,而这背后正是无锁编程里最经典的隐形陷阱:CAS 的 ABA 问题。如果你平时写多线程代码,或者维护过无锁队列、自旋锁、并发计数器这类基础组件,建议把这篇读完。我会从 CAS 的底层原理开始,把 ABA 发生的完整调度、经典的无锁栈翻车案例、Java/C++ 两种主流语言下的破解手段,以及我实际排查时的复现方法,全部讲透。这不是那种“比较-交换”四字科普,而是一条能直接拿去用的避坑路线。
如果你目前只有“听说过 CAS”的程度,也完全跟得上;如果你已经写过不少原子变量,后半部分反而更值得细看。唯一建议是,边读边写点测试代码,ABA 问题光看不练,很容易理解成“值相等就行”,那就把真正危险的场景给漏掉了。
1. 先说基础:CAS 到底做了什么,为什么它撑得起无锁编程的地基
1.1 CAS 的语义是“确认没人改过,我才动手”
CAS,全称 Compare And Swap,中文一般叫“比较并交换”。它做的事情非常朴素:给三个参数,一个是内存位置 V,一个是预期值 E,一个是新值 U。只有当 V 里当前存的值等于 E 时,才把 U 写到 V 里;如果 V 当前值不等于 E,就什么都不做。
这里面的关键是“检查当前值 + 写入新值”这两个动作必须是原子的。也就是说,在硬件层面,CPU 保证这条指令执行的过程中,不会出现“检查完别人又改了”的夹心状态。用生活里的话讲,CAS 就像一道带条件的授权指令:我拿着一张旧照片去门卫对脸,只有照片里的人和我一模一样,门卫才放我进去改门牌号;只要有一丁点差别,门卫就拒绝执行,还会告诉我现在门里是谁。
在 Java 里,java.util.concurrent.atomic包下的原子类基本都建立在这样的 CAS 原语上。AtomicInteger、AtomicLong、AtomicReference这些类,底层的核心方法就是compareAndSet以及对应的compareAndSwapInt、compareAndSwapLong、compareAndSwapObject。C++ 里则有std::atomic<T>::compare_exchange_strong和compare_exchange_weak。
很多程序员把“无锁编程”理解成“没有锁”,这其实是混淆了概念。无锁编程真正放弃的是操作系统级别的互斥锁,而不是放弃“并发控制”。CAS 把并发控制下沉到了 CPU 指令这一层,让线程不用因为竞争锁而进入睡眠、唤醒的调度流程,吞吐和延迟在低冲突场景下自然更漂亮。
1.2 原子性来自 CPU,不是来自 JVM
既然 CAS 要求“比较 + 交换”原子完成,那这套保证从哪来?在 x86 平台上,核心指令是带LOCK前缀的CMPXCHG。LOCK前缀会锁住总线或者锁住缓存行,让这条比较交换指令在执行期间,其他核心无法对这个内存位置产生有效的读写。现代 CPU 还依赖缓存一致性协议,比如 MESI 协议,让多个核在同一缓存行上看到一致的数据状态。
Java 里的Unsafe.compareAndSwapInt实际上是调用了 JVM 针对当前平台生成的内联底层操作,最终落到cmpxchg这类指令。要注意的是,CAS 能保证的是“单条指令的原子性”,但它不负责“线程不被抢占”。高并发下两个线程同时看到旧值 A,其中只有一个线程的 CAS 成功,另一个会失败,失败方要么循环重试,要么做其他处理。所以 CAS 并不是万能的并发银弹,它只是给了你一个更精细的原子工具。
我在实际项目中见过不少团队,把“用了原子类”当成“这个组件已经并发安全”,这是很危险的。CAS 只是保证了单个步骤的原子性,整个算法是否正确,还得看你对 CAS 结果如何使用。
1.3 循环 CAS:原子变量的自增原来是个循环
看一个最典型的例子,AtomicInteger.incrementAndGet的简化逻辑:
public final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) { return next; } } }每次循环都是“读取当前值 -> 计算新值 -> CAS 尝试更新”。一旦 CAS 失败,说明在读取和提交之间,已经有其他线程改过这个变量,于是重头再读、再算、再尝试。这种模式在工程上叫循环 CAS,或者叫自旋重试。
这里出现了第一个值得注意的细节:重试时一定要重新get()当前值,而不是拿上一次的current继续拼。因为在失败那一刻,内存里的值已经和你预期的不一样了,拿旧的预期值再 CAS 只会再次失败,甚至可能踩中更隐蔽的问题。我见过有人为了省一次读,直接把失败后的next当成新的预期值去重试,这种写法在某些场景会陷入长时间 CAS 失败,表现就是“并发一高,接口延迟飙起来”。
理解了循环 CAS,我们才方便进入正题。因为 ABA 问题恰恰就藏在“我以为预期值没变,但历史已经变了”这个信任假设里。
2. ABA 问题的完整剖析:它证明不了“历史未被修改”,只能证明“现值等于预期值”
2.1 从定义到触发条件
ABA 问题的定义听起来很简单。共享变量 V 的初始值是 A;线程 T 读取到 A,并打算基于这个 A 做一次 CAS;在 T 执行 CAS 之前,另一个线程 U 先把 V 改成 B,然后又改回 A;最终 T 去 CAS,发现 V 依然是 A,于是 CAS 成功。
问题就出在这:T 本来是想要确认“我出发前的状态没人动过”,CAS 却只能确认“现在这一刻的值还是 A”。它无法证明 A 到 A 之间没有经历过 B。这就是 ABA 的名字由来——A 变 B 再变回 A。
真正会让 ABA 在工程里发作的,并不是简单数值变化,而是“引用被复用”。假如无锁数据结构里的对象节点 A 被某个线程弹出去之后丢回内存池,另一个线程随后又从内存池里申请到同一块地址,把新数据填进去,再把这个地址重新压回栈顶。此时从地址角度看,栈顶引用确实“重新出现了 A”,但这个 A 已经不是你认识的那个 A 了,它的内部字段、链接关系,甚至业务含义都可能完全不同。
之前读过一篇讲并发安全的文章,作者用了“古代驿站换马”的比喻:你以为还是原来那匹马,实际上却被中途换过,外观虽然相似,体力和习惯已经变了。这个比喻挺贴切,尤其理解指针复用时很形象。
2.2 经典翻车现场:无锁栈的引用回收与断裂
要说 ABA 问题最著名的案例,非无锁栈莫属。下面是一个用AtomicReference实现的简化版无锁栈:
public class LockFreeStack<T> { private final AtomicReference<Node<T>> top = new AtomicReference<>(null); public void push(T value) { Node<T> newNode = new Node<>(value); while (true) { Node<T> curTop = top.get(); newNode.next = curTop; if (top.compareAndSet(curTop, newNode)) { return; } } } public T pop() { while (true) { Node<T> curTop = top.get(); if (curTop == null) { return null; } Node<T> nextTop = curTop.next; if (top.compareAndSet(curTop, nextTop)) { return curTop.value; } } } private static class Node<T> { T value; Node<T> next; } }这个版本在“没有节点复用”的前提下是对的,但在生产环境里,节点常常来自对象池或者会被 GC 后重新分配。一旦内存地址可复用,ABA 就会咬人。
我画一下灾难发生的完整时间线。假设现在栈顶往下是 A -> B -> C,注意这里 A 是栈顶节点。
- 线程 P 执行
pop(),先读到curTop = A,又读到nextTop = A.next = B。就在这个时刻,P 被调度器挂起了。 - 线程 Q 执行了一次完整的
pop(),成功把top从 A 改成了 B。此时节点 A 已经被弹出栈,业务代码处理完数据后,把 A 对象放回了对象池。 - 线程 R 执行
push(X),申请内存时拿到了对象池里的 A,于是把新值 X 填进 A.value,并把 A.next 指向了当前栈顶 B,然后 CAS 成功,top变成了 A。注意,这个 A 已经不是当初那个 A 了,它现在是存放 X 的新节点。 - R 又执行了一次
push(Y),栈顶从 A 变成新节点 Y,Y.next 指向当前的 A。此时栈里 Y -> A -> B -> C,一切看起来正常。 - 线程 P 终于醒过来了。它之前记录的
curTop = A,nextTop = B。现在它执行top.compareAndSet(A, B)。 - 比较发现,栈顶确实是 A(地址没变),所以 CAS 成功,
top被更新成了 B。问题瞬间爆炸:栈里刚压入的 Y 和 X 两个节点,全部从栈顶链中退出了,任务队列直接丢数据。
为什么会出现这种结果?因为 P 做 CAS 时判断的是“栈顶是不是 A”,它想要的效果是“我看到的这段栈结构没有被别人动过”。可实际上 A 已经被复用,A.next 早就被改成了别的东西。P 却拿着旧nextTop = B直接覆盖了top。一步之差,两个新节点消失得无影无踪。
现实中的掩盖方式比这个例子还要隐蔽。因为大多数无锁栈不会自己维护对象池,而是靠 JVM 的 GC。GC 虽然不会立刻回收节点,但高并发下对象分配地址复用是真实存在的。特别是在小对象、高频分配回收的无锁队列和无锁栈场景里,ABA 出现的概率一点都不低。
2.3 为什么计数器里通常遇不到 ABA 翻车
聊到 ABA,常有人问:那AtomicInteger.incrementAndGet()也会遇到 A 变 B 变 A,为什么没出问题?
区别在于“你拿 CAS 的意图”。计数器场景里,CAS 的完整动作是:把内存当前值读出来,加一,再写回去。哪怕中间经历过 A -> B -> A,只要最后一次读到的是 A,算出来的A+1也是当下的正确结果。计数器不关心“谁动过这个值”,它只关心“我是不是把最新值加了 1”。如果超卖场景里依赖“先读到库存 1,再 CAS 成 0”,这时候 ABA 同样会出问题——因为你把所有判断押在“这个 1 一直没被人改过”上。
一句话总结:如果 CAS 的目标是“拿最新值做幂等更新”,历史变化通常无所谓;如果 CAS 的目标是“从某个合法状态迁移到下一个状态,且这一步只能发生一次”,那历史变化就是命脉。无锁栈、自旋锁、状态机迁移、带持有权的资源切换,都属于后者。
3. 破解第一板斧:给每次改动盖个版本戳——AtomicStampedReference 实操详解
3.1 版本戳的思路:让“值相等”和“历史相同”不再混淆
既然问题的根源是 CAS 只比较了“值”,我们就给它加上一个能反映历史的“戳”。最朴素有效的办法:让数据对象关联一个单调递增的版本号,每次 CAS 成功都把版本号加一。这样即使引用变了又变回 A,版本号也从 1 变成了 2。旧线程手里攥着的是“A + 版本1”,自然对比失败。
Java 为此专门提供了AtomicStampedReference。它内部并不是简单地把“引用”和“戳”拆成两个字段,而是用一个不可变对象把两者打包,保证每次读写都是整体操作。你调用一次compareAndSet(expectedReference, newReference, expectedStamp, newStamp),这四者是作为一个原子整体去比较和更新的。
原理上它有点像存档系统:你只管比较“reference + stamp”这个二元组,戳不对,引用再像也不认账。
3.2 用版本戳改造无锁栈
把上面那个无锁栈改成AtomicStampedReference版本,逻辑主线不变,重点是每次成功修改都必须更新戳:
public class StampedLockFreeStack<T> { private final AtomicStampedReference<Node<T>> top = new AtomicStampedReference<>(null, 0); public void push(T value) { Node<T> newNode = new Node<>(value); while (true) { int[] stamp = new int[1]; Node<T> curTop = top.get(stamp); int curStamp = stamp[0]; newNode.next = curTop; if (top.compareAndSet(curTop, newNode, curStamp, curStamp + 1)) { return; } } } public T pop() { while (true) { int[] stamp = new int[1]; Node<T> curTop = top.get(stamp); if (curTop == null) { return null; } int curStamp = stamp[0]; Node<T> nextTop = curTop.next; if (top.compareAndSet(curTop, nextTop, curStamp, curStamp + 1)) { return curTop.value; } } } }注意top.get(stamp)会同时取出引用和版本号。这里的int[] stamp不是用来装数据,而是充当“返回参数”,因为 Java 没有多返回值语法,用一个单元素数组接收是比较常见的写法。每次 push 或 pop 成功,curStamp都会变成curStamp + 1。所以无论栈顶引用是否重新指向曾经的 A,版本号一定不一样,P 线程那个旧版本的 CAS 会失败,然后进入下一轮循环重新读取拓扑。
有个新手极易踩的坑:只调用getReference()获取引用,再另外调一个方法获取 stamp。这两个调用间存在时间窗口,可能取到的引用和戳根本不是同一时刻的快照。使用AtomicStampedReference时,务必用get(int[] stampHolder)这种“一次性取对”的方式。
我见过一个团队给无锁队列修 ABA,代码里getReference()和getStamp()分开取,修了一周没效果。根因就是时间窗口:线程 A 已经改了 top,线程 B 取 stamp 时拿到的是新戳,偏偏引用还是旧引用,CAS 总是歪打误撞地失败或成功。所以再次强调,读和写都要保证“引用 + 戳”统一读、统一写。
3.3 戳该怎么选,AtomicMarkableReference 又适合什么场景
版本戳类型上,Java 的AtomicStampedReference直接用int。有人说 int 会溢出,确实极端情况下会,但一个变量要被成功修改 21 亿多次才会出现戳回绕,绝大多数业务系统根本没有这种量级,可以忽略不计。比 int 更小的类型,比如手动封装时用低 16 位做戳,就要小心了,65535 次其实很容易到达,用之前先算清楚业务上限。
另一点,不要拿时间戳当版本戳。有人图省事,把System.nanoTime()塞进去当戳。这有两个问题:一是两个线程如果修改时间落在同一个纳米刻度内,时间戳可能相同;二是时间戳和修改次数没有单调绑定关系,你无法判断“这是第几次修改”。版本戳要的是“每次成功修改都严格加一”,而不是“一个可能重复的时钟读数”。
Java 还提供了一个AtomicMarkableReference,它只有 1 位标记,用来回答“这个引用是否被改过”,而不是“被改了多少次”。适合的场景是:一条状态只允许从有效变成无效一次,比如“节点已被删除”“任务已被领取”。这种场景下 1 位标记可能比 int 更贴切,语义上不会有过期戳回绕的论题。如果你的代码需要精确知道修改次数,或者数据结构会被反复更新,老老实实用AtomicStampedReference。
4. 破解第二板斧:在设计层面让 ABA 物理消失——不可复用引用、索引化结构与所有权设计
4.1 放弃“对象复用”,引用就不会轮回
版本戳是给 CAS 加了一层判断,但并没有消除 ABA 的根源。真正的根源是“同一个引用值可以再次出现”。如果对象生命周期里,某块内存地址一旦释放就永远不再以同一个身份重新进入共享结构,那 ABA 就从物理上消失了。
这个思路在很多高并发基础组件里非常常见。具体做法有三种:
- 节点不回收:pop 出来的节点直接丢给 GC,或者放进一个永不复用的退役链表;
- 延迟回收:等所有可能读到该节点的线程确认退出后,才真正释放内存;
- 内存池按代管理:老一代节点不被新一代使用。
听起来简单,做起来难。难点在于,无锁数据结构里怎么判断“所有线程都退出了对某节点的引用”?这就引出了无锁编程里另一个话题:内存回收,或者叫 Safe Memory Reclamation。业界有 Hazard Pointer、Epoch Based Reclamation、RCU 等方案。它们本质上是同一件事:把节点的回收延后到“没有读者可能再看到它”的时刻。这样即使 old A 被弹出栈,它也不会在回收后又被分配成 new A;新节点的地址必然不同。
我们在生产里有一个无锁任务队列,最初用对象池缓存节点,ABA 高发。后来改成“pop 出来的节点进入一段延迟回收队列,由后台线程在低峰期统一释放”,问题就再没出现过。这比给每个节点加版本戳性能更好,因为版本戳会让 CAS 从单一指针宽度的比较变成双字宽度的比较,在 C++ 里甚至会触发非无锁回退。
4.2 用索引代替指针:身份的唯一性不由地址决定
另一个思路是,不要用真实内存地址作为比较对象,而是用数组索引、ID 序号这类带有“代际信息”的值。只要设计得当,同一个槽位在不同代际使用不同的 ID,ABA 就没有表演空间。
举个例子。某个并发队列底层用固定数组,head和tail是不断递增的序号,而不是循环复用的下标。每次入队,元素写入tail % capacity槽位,然后tail++。由于tail是单调递增的,即使后来写入的某个值恰好落在同一个物理槽位,对应的序号也完全不同。CAS 比较的是“序号 + 引用”的复合状态,类似于自带版本戳。
这类设计的优势是简洁直观,缺点是数组容量要有上限,需要处理好扩容和元素替换。不过当你面对的是一个“地址复用频繁、业务生命周期又长”的场景时,索引化抽象往往比纯指针方案更稳。
4.3 把写者聚到单线程,减少共享变量的历史波动
ABA 问题还有一个很容易被忽略的工程化解法:减少竞争面。与其让好多线程无锁地争一个栈顶,不如让每个线程维护自己的局部栈,再定期合并。LongAdder就是这么做的:你调用increment()时,当前线程只修改自己独有的Cell,不会和其他线程打起来;合并时再逐一把 Cell 的值加进总和。
这个思路在通用无锁栈里不一定适用,但给了一个很好的启发:很多并发问题的最优解,不是提高 CAS 的正确性,而是让 CAS 根本不需要发生在一个共享变量上。我在实际业务里,凡是能用 ThreadLocal 或者分段结构解决的,原则上不引入共享 CAS。每次设计评审看到有人打算用全局AtomicReference管理一堆状态,我都会先问一句:这个状态真的要全局共享吗?
5. 底层玩家的选择:C/C++ 里没有现成的 AtomicStampedReference,怎么自己造
5.1 把版本号藏在指针的低位空白区
如果你在 C/C++ 里写无锁代码,就会遇到一个现实问题:标准库没有现成的AtomicStampedReference。你需要自己想办法把“引用”和“戳”塞进一个原子量里。
最容易想到的方案是“低 bit 借用”。malloc返回的堆地址通常会按 8 字节或 16 字节对齐,也就是说一个合法指针的低 2 到 3 位永远是 0。这些空白位可以拿来当版本号。比如一个按 8 字节对齐的对象,指针低 3 位都是 0,你可以把版本号塞进去,读指针时再掩码还原。
// 低 3 位存版本号,按 8 字节对齐 static constexpr uintptr_t kFlagMask = 0x7ULL; Node* readPtr(uintptr_t packed) { return reinterpret_cast<Node*>(packed & ~kFlagMask); } uint64_t readStamp(uintptr_t packed) { return packed & kFlagMask; } bool compareAndSwap(uintptr_t& packed, Node* expectPtr, uint64_t expectStamp, Node* newPtr, uint64_t newStamp) { uintptr_t expect = reinterpret_cast<uintptr_t>(expectPtr) | expectStamp; uintptr_t desired = reinterpret_cast<uintptr_t>(newPtr) | newStamp; return std::atomic_ref<uintptr_t>(packed) .compare_exchange_strong(expect, desired); }这个方案的问题是低 3 位版本号只有 8 个值,修改 8 次就会回绕。所以它只适合“修改次数极少”的场景。如果要求版本号不回绕,就得拿高位当版本号。比如 64 位系统上,对象地址往往只在低 48 位有意义,你可以把高 16 位拿出来当戳。但要注意,合法性取决于目标平台的内存布局,不是所有系统都允许随意使用高位。最好在启动时用static_assert检查指针最高位是否真的是 0,否则会有地址截断的灾难。
5.2 double-width CAS:把引用和版本打包成 16 字节
如果你既想要完整 64 位指针,又想要一个不轻易回绕的 64 位版本号,那么单个机器字已经放不下了,你需要 16 字节的原子比较交换。x86 平台上这就是cmpxchg16b指令。
C++ 标准里,你可以尝试std::atomic<__int128>或者用一个包含指针和 uint64 的结构体,比如:
struct PtrStamp { Node* ptr; uint64_t stamp; };但是,std::atomic<PtrStamp>并不保证无锁。很多平台上它内部会退化成互斥锁,这会让你的无锁编程前功尽弃。正确做法是先检查编译器和目标架构,使用编译器内建函数:
- MSVC:
_InterlockedCompareExchange128 - GCC/Clang:
__sync_val_compare_and_swap_16或__atomic_compare_exchange_n(..., 16),但需要注意 32 位模式下不一定支持。
我在做跨平台无锁队列时就踩过坑:同一段代码在 x64 Linux 上跑得好好的,换到 ARM64 上发现std::atomic<__int128>的is_always_lock_free()是 false,底层悄悄加了锁,性能直接崩掉。所以凡是依赖 16 字节 CAS 的代码,启动时一定要断言std::atomic<PtrStamp>::is_always_lock_free,或者直接使用平台内建函数,别让标准库替你决定实现。
另外要提醒一点:double-width CAS 并不便宜。相比 8 字节 CAS,cmpxchg16b在高并发下可能造成更多的缓存行竞争和地址总线开销。能用“低 bit 版本号”解决问题时,别一上来就上 16 字节,CPU 不会因为你代码写得优雅就容忍一条更贵的指令。
5.3 不同破解方案的选型对比
下面的表格是我自己在技术方案评审时常用的对照参考。
| 方案 | 核心原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 版本戳(ref + stamp) | CAS 同时比较引用和版本号 | 直观,不依赖内存管理 | 需要考虑 16 字节 CAS 或借用指针位 | 通用无锁数据结构,尤其是栈、队列 |
| 不可复用引用 / 延迟回收 | 旧地址永远不会再次进入共享结构 | 从源头消除 ABA,还能解决内存回收 | 需要实现 Hazard Pointer / EBR 等机制 | 高吞吐无锁容器,节点生命周期长 |
| 索引 / ID 替代指针 | 用单调递增序号代替内存地址 | 天然带代际信息,逻辑清晰 | 数组容量有限,扩容复杂 | 有界队列、环形缓冲、对象槽位 |
| 分段 / 单写者设计 | 减少共享变量,让 CAS 少发生 | 性能和正确性同时提升 | 需要重构数据流 | 计数器、指标统计、高并发读多写少 |
选型的核心判断不是“哪种最安全”,而是“哪种代价你付得起”。如果只是修复一个偶发丢数据的无锁栈,加版本戳是最快的;如果你要设计一个要跑五年、允许节点复用千万次的底层队列,那延迟回收可能是更根本的解法。
6. 光学理论不够,陪你把一次实际排查链路走完——从偶发丢数到根因坐实
6.1 怀疑 ABA 的特征长什么样
我遇到的真实问题,是一套无锁任务队列在双十一压测时偶尔出现“任务丢失”。现象很蹊跷:日志里没有异常,队列容量没有溢出,但消费者拉到的任务总数比生产者压入的少了几个。偶尔一天能出一次,复现概率低到让人怀疑是网络丢消息。
这样的问题很难靠肉眼抓。ABA 问题的特点是“偶发、瞬态、无异常”。与死锁、活锁不同,它不会阻塞任何线程,所有线程都在继续工作,只是某个 CAS 在错误的时刻成功了。要判断一个并发问题是不是 ABA,可以先看几个特征:
- 代码里有无锁数据结构、
AtomicReference,尤其是栈顶/队首/链表头这些“引用型共享变量”; - 对象是否来自内存池,或者高频分配释放后地址复用;
- 业务最终结果依赖“某个状态只能迁移一次”,比如任务只能被消费一次、节点只能被删除一次;
- 把共享变量改成加锁版本后,问题消失,说明高度怀疑无锁实现本身。
6.2 三步复现法:把偶发问题变成必然问题
复现 ABA 的难点在于它需要“运气”。但既然根源是地址复用,我们就可以人为提高复现概率。
第一步,把无锁栈封装成可注入内存分配器的形式。正常代码里new Node()改成从对象池申请,对象池用 ThreadLocal 缓存,保证同一块地址会被频繁分配。这样 A 被 pop 之后,很快就会有新的 push 拿到同一地址。
第二步,写一个带线程屏障的小测试。一个线程 P 先执行到 CAS 前一步,然后阻塞;另外两个线程 Q、R 按预演的顺序完成 pop、push,再用CountDownLatch唤醒 P。测试里可以给节点打上递增 ID,最后校验栈里所有节点的 ID 集合是否和 push 过的 ID 集合一致。
第三步,加一个“影子校验”做实时检查。每成功 push 一个任务,影子集合里加一个;每成功 pop 一个任务,影子集合里减一个。ABA 一旦触发,你会看到栈顶链中的节点数量和影子集合对不上。这一步非常关键,因为单纯看任务数量可能被重复消费抵消,影子集合能精确定位到“哪个节点被错误切出了链表”。
我复现这个问题的实际记录是:第一次跑没有触发;把压测线程数从 4 个调成 8 个,对象池复用频率提高后,第二个小时就抓到了一次栈顶引用相同、版本号不同但 CAS 成功的现场。
6.3 修复落地与回归验证
修复方案选的是版本戳,原因很简单:改动范围最小,且我们的栈顶更新频率并不高。改完后我把AtomicReference<Node>换成AtomicStampedReference<Node>,并在pop()里用get(stampHolder)一次性读取。
回归验证时,我保留了原来的无锁栈作为对照组。两组同时跑一样的对象池复现测试:
- 对照组:一小时左右出现任务 ID 丢失;
- 修复组:连续跑了 12 小时,影子校验一直通过。
更严格一点,我在修复组里加了 CAS 失败次数的统计指标。修复后,CAS 失败次数明显上升,这其实是好事,说明版本戳确实拦截了一部分“值相同但历史不同”的错误更新。如果你修完之后发现 CAS 失败率一点没变,那要怀疑是不是根本没修对地方。
6.4 很多人在修补时会翻车的点
第一,只在读的时候取版本号,写的时候却不递增。版本戳必须是“每次成功修改都同步递增”,否则根本没起到记录历史的作用。第二,使用了getReference()和getStamp()分开读取,造成戳和引用不匹配,这个问题前面已经说过了。第三,在 C++ 里把std::atomic<Node*>直接替换成std::atomic<__int128>却不检查is_lock_free(),结果无锁悄悄变了有锁,性能掉得惨不忍睹。第四,修完不回归,压测用例还是原来那套低概率复现场景,改了等于没改。
踩过几次坑之后,我对 ABA 问题的判断准则越来越粗,也越来越实用:如果 CAS 的结果是要参与“从当前状态迁移到下一个状态”的决策,那就要认真检查历史是否会被篡改;如果 CAS 只是用来“拿最新值再覆盖”,通常可以暂时不管。见过很多事故,不是没有版本戳,而是设计阶段根本没意识到自己的算法对“历史”有依赖。希望大家下次写完无锁代码,先问自己一句:我允许这段历史被改吗?