资讯动态

CAS与ABA问题全解析:无锁栈丢数据的真相及AtomicStampedReference解法

发布时间:2026/10/15 15:30:36 来源:尧图企业网站定制
如果你写过并发代码一定听说过 CASCompare-And-Swap这个名词也可能知道它有个经典陷阱叫“ABA 问题”。我第一次真正被 ABA 问题坑到是在做一个并发组件压测的时候一个用 CAS 实现的无锁栈单线程怎么跑都正常换成多线程跑一会儿节点就像被凭空从栈里抽走一样数据莫名其妙消失。排查了整整两天最后才发现根子不在 CPU 指令也不在内存模型而在“我看到的 A并不是我记忆中的那个 A”。这篇文章我想用“前女友”的故事把 ABA 问题彻底讲明白CAS 到底在做什么ABA 问题为什么会出现它在什么场景下会真正咬人以及解决它的正路是什么。如果你准备写高并发组件、无锁数据结构或者已经在用 AtomicReference 这类工具这篇文章值得你花十分钟看完。1. CAS 的工作方式以及它到底“乐观”在哪里很多人把 CAS 当成一个“魔法操作”用 AtomicInteger 用得挺熟但回头问他 CAS 到底怎么保证原子性反而说不清楚。不把这一层弄明白后面理解 ABA 问题就会悬在半空。1.1 一条指令完成“比较 交换”三个语义CAS 是 Compare-And-Swap 的缩写名字已经把逻辑说得很直白先比较再交换。它的操作对象是三个东西内存位置 V期望值 E新值 N。执行时只有一种情况会成功当前 V 里的值恰好等于 E那就把 V 更新成 N返回成功如果 V 里的值不等于 E什么都不做返回失败。关键点在于“比较和交换”是一个不可分割的原子过程不是先读一次、再比一次、再写一次。如果是两个独立步骤中间就会被其他线程插进来那 CAS 就没有意义了。硬件层面靠的是 CPU 指令比如 x86 的cmpxchg、ARM 的ldxr/stxr这类隐式锁定缓存行的指令保证了一条 CAS 操作从读到写之间没有缝隙。1.2 从 synchronized 到 CAS锁的粒度变化理解 CAS 为什么流行最好跟 synchronized 对比着看。传统的 synchronized 是悲观锁我不管冲突多不多先进去把门锁上你们都在外面等着。这种方式的优点是简单、安全缺点是线程阻塞、上下文切换、锁的争抢在高并发下开销非常明显。CAS 走的是乐观路线我默认大家很少同时改同一个变量所以不加锁直接尝试更新。如果发现失败了说明有人抢先改了那我就重试一次。就好比进停车场不设栏杆进去的时候发现车位被别人占了就绕一圈重新找而不是在门口排队等着门卫给你发通行证。这种“失败就重试”的模型在竞争不激烈的场景下效率极高因为全程没有线程阻塞也没有内核态切换。Java 里 AtomicInteger、AtomicReference、原子队列、线程池里的 worker 计数底层都是这个东西。1.3 CAS 的“全或无”特性决定了它的适用边界很多人用 CAS 的时候会下意识以为“只要结果是期望值就万事大吉”这恰恰是 ABA 问题埋下的雷。CAS 只告诉你两件事这次比较时值是不是 E如果是这次更新成 N 成功。它不告诉你这个值在过去一段时间里经历了什么。也就是说CAS 是一个“无状态快照式”的操作它只对“现在”负责不对“历史”负责。一旦你的业务逻辑需要依赖“这个值在此期间没被别人改过”来做出判断单纯 CAS 就提供不了这个保证。ABA 问题的本质就是 CAS 只能看到值相等却看不到值的历史轨迹。2. 用“前女友”故事讲透 ABA 问题网上讲 ABA 问题最常见的就是“前女友”这个比喻。实话实说我第一次看这个比喻只觉得好笑后来真的踩坑之后才发现它把问题的精髓讲得比一堆技术术语清楚得多。2.1 故事线的完整版想象这样一个场景你的前女友叫 A。你们分手之后你心里一直默认“她还是单身还在等着复合”。某一天你在路上碰到她定睛一看站在面前的人还是那个 A你心里想她没变还是我认识的那个人那我可以放心去复合了。但故事的另一条线是在你没看见她的这段时间里她其实交了一个新男友 B两个人在一起生活过一段时间后来又分手了所以她再次出现在你面前时还是叫 A还是你熟悉的模样。问题来了你看到的“她还是 A”这件事本身没有错但她已经不再是当初那个“没谈过这段恋爱”的 A 了。如果你因为“她还是 A”就认定“她这期间什么都没发生过”进而做出复合的决策那你可能忽略了她跟 B 交往带来的那些改变和风险。2.2 映射到技术术语内存位置的故事把故事里的人物对应到程序里内存位置 V 就是你手机里的联系人“前女友 A”就是 V 里存的某个值“新男友 B”就是另一个线程把 V 改成了中间值 B“分手后回到你面前”就是另一个线程又把 V 改回成 ACAS 的判断逻辑就是只看眼前这个联系人是不是 A完全不管她中间换过几个男朋友。用代码写出来就是一个典型的时间线线程 T1 读取变量 V看到值是 A线程 T2 执行 CAS把 V 从 A 改成了 B线程 T3 再执行 CAS把 V 从 B 改回了 A线程 T1 终于执行自己的 CAS期望值还是 A发现自己想要的 A 还在CAS 成功。从 T1 的角度来看整个过程“看起来”没有任何变化别人告诉它“变量没被改过”它也会信。但事实上变量不仅被改过还被改了两个来回。2.3 为什么“两次都是 A”不等于“这期间没变化”这是 ABA 问题最反直觉的地方中间状态的“临时存在”本身可能就是问题。如果 V 是一个计数器那 A→B→A 和“从头到尾一直是 A”最终结果确实一样CAS 成功没有任何副作用。但如果 V 是一个链表头指针或者一个对象引用事情就没那么美了期间发生的“从 A 改到 B 再改回 A”可能意味着某个节点被弹出过、被释放过、被回收过甚至被另一个线程重新使用过。你手里的“同样一个 A”可能已经是被回收之后又被重新分配出来的对象只是内存地址恰好相同或者引用值相同。CAS 只认值不认身份。值相同不代表同一个状态、同一段历史。这就像你碰到一个长得和前任一模一样的人但人家已经不是当年那个人了。2.4 比喻的边界ABA 问题不是永远有害“前女友”比喻容易让人产生一个误解好像只要发生了 A→B→A程序就一定出错。其实不是。ABA 问题是否会造成实际危害取决于你的业务到底依赖什么如果只依赖“最终结果”比如计数器自增那么 A→B→A 之后 CAS 成功和直接成功没有差别如果依赖“过程无变化”比如链表操作中需要确认某个节点还在栈里、还没有被弹走那么 ABA 就会酿成大错。所以判断 ABA 问题要不要解决不看形式看语义。理解 ABA 问题的第一步是先问自己我这段代码的 CAS 成功究竟代表“值没变”还是代表“状态没变”如果是后者你就需要读下一节。3. ABA 问题的高危现场无锁栈为什么突然丢数据技术圈提到 ABA 问题必然会聊到一个经典案例无锁栈。因为你写并发代码时无处不在的 Atlas Scheduler 我不会讲但无锁栈是理解 ABA 杀伤力的最佳教材。3.1 一个经典的无锁栈假设我们用 AtomicReference 实现一个栈核心方法就是 Java 里常见的CAS循环public class LockFreeStackT { private static class NodeT { final T value; volatile NodeT next; Node(T value) { this.value value; } } private final AtomicReferenceNodeT top new AtomicReference(null); public void push(T value) { NodeT node new Node(value); while (true) { NodeT currentTop top.get(); node.next currentTop; if (top.compareAndSet(currentTop, node)) { return; } } } public T pop() { while (true) { NodeT currentTop top.get(); if (currentTop null) return null; NodeT next currentTop.next; if (top.compareAndSet(currentTop, next)) { return currentTop.value; } } } }这个代码单线程跑没有任何问题多线程在没有 ABA 破坏的情况下也没有问题。但一旦多个线程同时对栈做 push 和 popABA 的雷就会炸。3.2 丢数据的完整时间线下面这个场景是 ABA 导致丢数据的经典版本我叫它“幽灵节点被弹出”假设栈当前是A → B → C栈顶 top 指向节点 A。线程 T1 想 pop它读到的 currentTop 是 A然后读 next 是 B线程 T2 抢先成功执行了一次 pop栈顶变成 BA 被弹出了栈线程 T2 继续做一些业务操作把 A 节点的 next 改成了 null或者把 A 节点复用去 push 一个新链表线程 T2 又把 A 节点重新 push 回栈此时栈变成A → B → C也可能是A → Dtop 又回到了 A线程 T1 的 CAS 终于执行了它发现 top 还是 A跟自己一开始读的一样于是 CAS 成功把 top 改成了 next也就是它一开始读到的 B但如果期间 A 的 next 已经被 T2 改成 nullT1 就会把 top 改成 null整个栈从 B 开始全部丢失。看到没有线程 T1 只认“top 的值是 A”它完全不知道这个 A 已经被弹出过、修改过、再塞回来。它以为自己在弹出一个节点实际上它可能把一个已经“死掉”的节点残留信息写回了栈顶。3.3 为什么垃圾回收环境也躲不开 ABA有人会说既然 Java 有 GC对象不会被真的回收掉那 A 节点即使被 pop 过引用地址也不会变CAS 还是有意义这个想法非常危险。GC 确实保证了 A 这个对象不会被物理销毁但对象的“身份不变”不代表“状态不变”。上面例子里的 A 节点被 pop 之后它的 next 字段被修改了甚至它的 value 也可能被清空复用。你拿旧引用去 CAS匹配的只是“Address 相等”但对象内部的数据已经不是当初那个 A。所以 ABA 问题在 Java 里同样存在只是表现形式不一样不是指针悬挂、内存踩踏而是旧引用 新状态被你误当成“旧状态”使用。如果代码里把 A 节点的内部状态当成“没被人动过”的稳定数据来读同样会出错。3.4 带状态复用的对象另一种“假 A”更隐蔽的情况是ABA 问题不一定发生在链表指针上也可能发生在对象本身的复用上。假设你有一个对象池线程 A 拿到一个对象看到它处于“未初始化”状态于是准备做一些赋值操作。此时另一个线程把这个对象借走改完状态又还回来重置成“未初始化”。线程 A 醒过来检查发现对象还是“未初始化”于是放心写入——结果可能覆盖掉别的线程刚写好的数据。这种场景跟链表丢节点长得完全不一样但骨架一模一样你基于“值没变”推断“状态没变”结果中间历史被打断。识别 ABA 问题不要只看数据结构是不是链表要看你的判断依据是不是“仅凭值相等就认为无人动过”。4. 解决 ABA 问题版本戳、标记位和 CAS2既然 ABA 的本质是“只认值、不认历史”解法也很直接给这个值附带一个能反映“历史变化”的信息CAS 的时候把两个信息一起比较。这样即使值变回 A只要历史版本对不上CAS 依然失败。4.1 AtomicStampedReference给引用加版本号Java 里直接干这件事的工具是AtomicStampedReference。它的底层维护了一个Pair包含引用和整数戳CAS 时要求引用和戳同时匹配更新时也同时更新。你可以把戳当版本号每次栈顶变化就加一。public class LockFreeStackWithStampT { private static class NodeT { final T value; volatile NodeT next; Node(T value) { this.value value; } } private final AtomicStampedReferenceNodeT top new AtomicStampedReference(null, 0); public boolean push(T value) { NodeT node new Node(value); while (true) { int[] stampHolder new int[1]; NodeT currentTop top.get(stampHolder); int currentStamp stampHolder[0]; node.next currentTop; // 只有引用和版本号都相同才执行更新 if (top.compareAndSet(currentTop, node, currentStamp, currentStamp 1)) { return true; } } } public T pop() { while (true) { int[] stampHolder new int[1]; NodeT currentTop top.get(stampHolder); if (currentTop null) return null; int currentStamp stampHolder[0]; NodeT next currentTop.next; if (top.compareAndSet(currentTop, next, currentStamp, currentStamp 1)) { return currentTop.value; } } } }上面这个改造核心变化就一点“期望值”从“top 引用”变成了“top 引用 当前版本戳”。就算另一个线程把 top 从 A 改成 B 再改回 A版本戳也已经从 n 变成了 n2T1 拿 n 去 CAS 就会失败循环重试问题自然消失。4.2 AtomicMarkableReference不关心改了几次只关心改没改有时候我们并不需要精确的版本号只需要知道“这个引用有没有被修改过”。这种场景AtomicMarkableReference更合适。它维护的是一个boolean标记CAS 时要求引用相等和标记同时相等更新时也同时更新。典型的用法是用它表示“这个节点是否处于有效状态”。比如一个缓存条目想更新时把标记从 false 改成 true另一个线程想复用该条目时CAS 要求标记还是 false 才会成功。只要曾经被改过标记变了CAS 就不会误判。对比一下这两个工具什么时候用工具适合场景粒度AtomicStampedReference需要知道修改次数、精确版本一次修改一个版本号AtomicMarkableReference只需要判断是否改过、是否可用一次修改一个布尔位如果你的场景只需要“从不可用变为可用”这种二值状态用 stamped 反而笨重如果你需要严格防止“两次操作之间的历史穿插”只靠一个 boolean 不够必须上版本号。4.3 CAS2 / 双重CAS压缩空间打包比较在 Java 里用 AtomicStampedReference 算是最便捷的方案但在更底层的 C/C 世界里人们经常用另一种思路把“指针 版本号”打包成一个整体然后对这个整体做 CAS。比如在 64 位系统上指针本身只用了 48 位你可以把高位空出来的位塞进一个 ABA 计数器变成一个 64 位的“指针 版本”。CAS 对象从“指针”变成“整个 64 位值”。这样一次 CAS 就同时比较了两块信息效率和内存占用都兼顾。在一些更复杂的无锁数据结构里还有“双重 CAS”技术把两个独立的内存位置打包成一个 CAN 字或者使用特殊的 CPU 指令一次性比两个位置。好处是一次性保证多个变量的原子更新坏处是实现复杂度极高普通业务场景不建议自己造轮子。Java 里的AtomicReferenceObject[]把两个变量塞进数组再 CAS本质也是一种“打包比较”只是性能不如真正的 CAS2。4.4 用不可变对象和结构性更新来避开 ABA最后一个思路是很多经验丰富的开发者会采用的不修改现有对象而是每次更新都创建一个新对象并让引用指向新对象。这样即使业务上“状态”回到 A对象的引用也已经是全新的不会和旧引用相等CAS 自然不会成功。这个思路相当于把 ABA 的根基拆了ABA 之所以能发生是因为同一个“值”反复出现如果每次修改都换一个新值新对象引用那“变回 A”这件事在引用层面就不成立。代价是对象创建更频繁GC 压力变大所以它适合那些“状态天然不可变”的数据结构不适合每秒钟千万次更新的计数热点。4.5 最优解不是无脑加版本戳有些人看完上面的方案会把所有 AtomicReference 都换成 AtomicStampedReference。我的建议是别。版本号方案并不是免费的。一次 ABA 防护的 CAS 往往意味着更宽的缓存行占用、更复杂的配对逻辑还有可能把本来就低频的热点操作降温变成中频。正确姿势是两问这个并发场景CAS 成功代表“值没变”就够了吗如果值变了再变回原样我的后续逻辑会出错吗如果两个答案都是“不会”那 ABA 问题只是理论概念不用专门处理。真正需要防护的是那些“判断值相等之后还要依赖历史状态”的结构比如无锁栈、无锁队列、无锁链表。很多人把“输出看得见”当成“没有 bug”这个习惯在并发世界里很致命。5. 比 ABA 问题更容易踩的 CAS 相关坑解决完 ABA 问题不代表无锁并发就万事大吉了。CAS 是一把好武器但用不好照样翻车。最后这部分我聊几个我实际排查中见过的高频坑它们跟 ABA 一样容易让人摸不着头脑。5.1 CAS 只保证“一步操作”的原子性不保证复合操作CAS 再好它也只能保证某一个变量的“比较并交换”是原子操作。你要是把它跟别的逻辑拼在一起比如“先读 A再读 B再根据 A 和 B 计算再 CAS A”这整个过程不是原子的。两个线程可能同时完成“先读 A/B”然后各自 CAS结果就是各自基于同一份旧数据计算产生覆盖。这种问题跟 ABA 没有直接关系但都是从“CAS 误用”里长出来的。解决办法要么把数据和状态打包成一个变量再 CAS要么缩小临界区老老实实加锁。不要觉得用了 CAS 就等于整个方法线程安全。5.2 自旋在低竞争和无竞争下的开销CAS 失败后一般会循环重试这在竞争不激烈时非常高效。可一旦多个线程同时争抢同一个变量CAS 频繁失败线程会一直空转消耗 CPU。简单算一笔账如果 8 个线程同时抢一个计数器的更新权胜利者只能有一个另外 7 个线程都要自旋等待期间 CPU 全被空转吃掉。在一些高并发场景尤其锁争抢严重时CAS 自旋反而比 synchronized 更浪费。现代 JDK 里的 synchronized 已经做了偏向锁、轻量级锁、重量级锁的升级竞争激烈时的表现未必比自旋差。选型的时候别迷信“无锁一定快”要压测说话。5.3 CAS 的可见性与内存屏障CAS 本身带有完整的内存屏障语义这在 Java 里是好事但容易让人产生误解以为“用 CAS 把所有变量都包起来线程安全问题就全解决了”。实际上CAS 刷新的可见性只针对被 CAS 操作的那个变量和它依赖的 volatile 引用。如果线程 A 在 CAS 成功后去写一些普通变量而线程 B 只 CAS 成功一次就立刻读那些普通变量也不一定能看到最新值。无锁编程的内存序是个独立学问光靠“我 CAS 了一下”补不了所有洞。5.4 伪共享CAS 藏起来的性能杀手还有一个坑和 ABA 一样“看不见摸不着”叫伪共享。多个线程如果频繁 CAS 修改同一个缓存行里的不同变量哪怕它们互相毫无逻辑关系CPU 缓存行会被反复失效性能可以差出一个数量级。我在某次优化一个并发计数器的时候就遇到过这种情况把多个 AtomicLong 放在同一个类里结果本以为是并发问题实测发现其实是伪共享。解决办法要么给变量补位到不同缓存行要么用LongAdder这类分段计数工具。这里想提醒你遇到 CAS 相关性能异常别只盯着算法也要看内存布局。5.5 一次 ABA 问题排查的完整思路最后分享一段我的实际排查过程给你当参考。当时做一个并发无锁栈的压测现象是并发越高丢失节点越多但单线程稳定加锁稳定。我的排查链路是这样的先怀疑 CAS 写错反复读代码发现 compareAndSet 的期望值和新值逻辑没问题再怀疑内存可见性给相关字段加了 volatile问题依旧用断言和日志记录每次 CAS 前后的 top 引用发现同一个 top 引用在极短时间内被“弹出又压回”对比时间线后确认是 A→B→A 的经典路径改造为 AtomicStampedReference给栈顶记录版本戳问题消失。整个过程最有价值的一步是第三步不要靠推理要靠观测。ABA 问题像鬼一样推理半天不如装上“版本计数器”看一眼数据流。只要你在 CAS 调用点打印引用和时间戳A→B→A 的轨迹会非常明显。如果你也遇到“无锁代码偶尔丢数据、单线程又复现不了”的诡异现象我的建议就是先别急着怀疑硬件和编译器冷静问自己一句我的 CAS 匹配的是值还是状态然后给它挂一个版本号答案往往立刻浮出水面。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑