2026/10/14 5:59:18

CAS与ABA问题详解:从原理到AtomicStampedReference解决方案

CAS与ABA问题详解:从原理到AtomicStampedReference解决方案 做并发编程的几乎没人没听过CASCompare-And-Swap比较并交换。它在无锁并发里是绝对的明星高性能、非阻塞、乐观判断。但光环背后藏着一个著名的坑就是ABA问题。我当年刚听到“ABA问题”这个术语的时候完全没法从字面理解它到底在说什么——A和B是什么为什么中间夹个B就会出问题后来一位老同事用一个“前女友”的比喻花了两分钟就让我彻底明白了。今天就把这套逻辑完整讲一遍顺便把复现实验、踩坑记录和解决方案全部整理出来。如果你正在用AtomicInteger、AtomicReference这些无锁类或者写过并发栈、并发队列、乐观锁相关的业务那这篇文章应该能帮你省掉不少弯路。不用怕这个故事一点也不抽象看到最后你甚至会觉得有点扎心。1. 先搞明白CAS到底在做什么1.1 CAS的三方博弈CAS操作本质上就干一件事拿“内存里的当前值”和“我预期的值”做比较相等就把新值写进去。它一共涉及三个参数内存地址V、预期值A、新值B。执行的时候如果V位置的值确实等于A就把V位置改成B返回true如果不等什么都不做返回false。关键点在于这个比较和交换的过程是原子完成的中间不会有其他线程插进来。你可以把它理解成“看一眼再动手”的操作。但这里有一个很容易忽略的细节CAS里的预期值A是线程自己在某个时刻读出来的。它天然带着一个假设——只要现在内存里的值还是A就说明在我读取之后没人动过它。这个假设在大部分场景下成立但恰恰是它埋下了ABA问题的种子。CAS这个词本身是Compare-And-Swap的缩写有些资料也写成Compare-And-Set意思一样。Java里对应的是Atomic类底层的compareAndSet方法再往下是Unsafe类暴露的compareAndSwapInt、compareAndSwapObject这些本地方法最终在CPU层面通常对应的是带lock前缀的CMPXCHG指令。这一串链路你不用全记只要知道一件事CAS确实是硬件层面支持的一条高效原子操作。1.2 CAS为什么这么受欢迎CAS之所以被用得这么广核心原因是它不需要加锁。一说到加锁就意味着线程可能在锁上阻塞。阻塞背后是线程上下文切换可能还要从用户态切到内核态这几样都是高成本的。CAS倒好全程就是一条CPU指令级别的原子操作在多数平台上几乎没有锁竞争带来的调度开销所以性能非常好。典型的应用场景包括AtomicInteger的原子自增、数据库乐观锁的版本判断、无锁队列的节点更新、以及Java里ConcurrentHashMap的一些内部操作。这些场景里CAS大幅减少了锁竞争带来的损耗让程序在高并发下依然能保持不错的吞吐。不过“快”不代表“可以无脑用”。CAS的性能优势建立在“值没变就等于状态没变”这个假设上。这个假设一旦被打破就会出现逻辑层面的bug而且是非常隐蔽的bug——程序不会崩也不抛异常就是行为错得离谱。这就是下面要讲的ABA问题。2. 用“前女友”的故事看懂ABA2.1 前女友的比喻假设你等在咖啡店门口约好了前任见面。你记忆里她穿着一件红色外套我们管这个状态叫A。你低头看了一眼手机再抬起头时正看到门口站着一个穿红色外套的人你的第一反应是“她还在什么都没发生。”于是你走过去准备开口。但事实可能是这样的就在你低头看手机的几分钟里她其实已经离开了咖啡店状态变成B到旁边接了个电话然后又回来了变回A。在你的视角里“红色外套站在店门口”这个事实从头到尾没变过但你不知道的是中间她已经经历了一整轮“离开再回来”的变化。问题出在下一步动作上。你因为“她还在门口”这个判断走上去说了一大堆早就准备好的话。可对面这个人已经不是两分钟前那个她了她的状态、情绪、对这件事的感知都已经变了。你基于“她没离开过”所做的判断自然就产生了偏差。这就是ABA问题——A代表最初看到的状态B代表中间被改成的状态最后又回到A。CAS的判断方式是“当前值等于预期值A”它根本不知道中间已经兜了一圈。2.2 从比喻回到代码用代码理解会更直白。假如有个AtomicInteger初始值是100线程1准备执行compareAndSet(100, 999)意思是“只要当前值还是100就把它改成999”。在线程1真正执行之前线程2把值从100改成了200然后又从200改回了100。等线程1开始执行CAS的时候它看到的当前值还是100等于预期值于是CAS成功返回true值变成了999。从结果上看CAS成功了它认为自己的判断没问题。但实际上这个值已经被别人动过一轮了。对于“把100改成999”这个单一操作来说最终结果可能是对的但在很多场景里“中间是否被人改过”这个信息恰恰是业务逻辑必须依赖的这时候CAS的成功就变成了一种误判。这里必须澄清一个经常被误解的点ABA问题未必每次都会引发错误甚至大多数时候是安全的。它只有在业务逻辑把“值未变”当作“状态未变”的依据时才会真正变成坑。CAS本身不会说谎它说的是“当前值等于你预期值”但“当前值等于预期值”并不等于“这个过程没人动过”。3. ABA问题最容易爆发的场景3.1 无锁栈的经典翻车现场要说ABA问题最经典的案例非无锁栈Lock-Free Stack莫属。网上几乎所有讲ABA的资料都会用这个例子因为它是真实会翻车的场景。我写一个简化版的无锁栈用AtomicReference来指向栈顶节点。public class LockFreeStack { private static class Node { int value; Node next; Node(int value) { this.value value; } } private AtomicReferenceNode top new AtomicReference(); public void push(Node node) { Node oldTop; do { oldTop top.get(); node.next oldTop; } while (!top.compareAndSet(oldTop, node)); } public Node pop() { Node oldTop; Node newTop; do { oldTop top.get(); if (oldTop null) { return null; } newTop oldTop.next; } while (!top.compareAndSet(oldTop, newTop)); return oldTop; } }这个栈的初始状态是栈顶是AA下面是B。现在线程1准备执行pop它先读到了oldTopAnewTopB就在它准备执行CAS的前一刻被操作系统挂起了。线程2在这段时间里连续做了三件事第一次pop把A弹出去栈顶变成B接着push了一个C栈顶变成C再push了一个A注意这个A和初始的A是同一个对象栈顶又变成了A结构变成A→C→B。这时候线程1醒过来了继续执行它的CAS。它一看栈顶确实是A跟自己的预期一模一样于是CAS成功把栈顶指向了B。但问题是现在的栈结构是A→C→BB在栈底而且B的next已经是null。线程1这么一改整个栈就变成了top指向BA和C两个节点等于从栈的入口直接被切掉了没有任何引用能触达它们。这还只是“丢节点”问题更严重的是语义错乱。线程2本来就通过正常的pop和push维护好了栈线程1的一个延迟CAS把整个结构打乱了。这就是ABA问题在无锁数据结构里的典型破坏力。3.2 除了栈还有哪些地方容易踩无锁栈是教科书级案例但实际项目中ABA问题还可能出现在几个别的地方。无锁队列链表实现是重灾区头尾指针更新时如果节点被弹出后又被重新加入队列CAS判断就可能出问题。内存回收和指针复用也是高风险场景比如某个节点被释放或归还到对象池之后又被分配出来地址可能还是同一个但语义已经完全变了。JVM里有GC帮忙兜底很多C/C项目还得自己处理内存回收ABA叠加上悬挂指针问题会更麻烦。数据库乐观锁也值得注意。乐观锁通常靠版本号字段的对比来实现如果版本号被人为改回去就会产生类似ABA的误判。虽然数据库里很少有人会把版本号改回旧值但业务上真有可能出现“先更新再回滚再更新”的操作流。分布式锁续期是另一个典型场景基于CAS实现的锁续期逻辑如果锁的token被重置就可能让一个已经过期的锁被误判成仍然有效。看到这些场景你会发现ABA问题最危险的地方在于它不会直接抛异常不会崩溃只是在逻辑上出现了“你以为状态没变其实变过一轮”的偏差。这种偏差在并发量小的测试环境里几乎不会暴露一旦上了生产环境数据错乱才会慢慢浮现。4. 解决ABA问题的三大方案4.1 版本号方案给值加一道“状态指纹”既然ABA问题的根源是“无法区分A曾经离开过”那最直接的思路就是给值配一个版本号或者叫标记让每一次修改都产生一个不同的版本。CAS的时候不仅要比对值还要比对版本号。只要版本号不一样就算值相同也能判断出中间有修改。Java里对应这个思路的工具是AtomicStampedReference。注意它不是拿一个int当作普通值而是用一个整数stamp来充当标记。每次成功更新之后stamp就自增一。stamp是单调递增的所以中间只要发生过一次修改即使引用值变回了原来的对象stamp也已经对不上了ABA就暴露出来了。public class StampStack { private static class Node { int value; Node next; Node(int value) { this.value value; } } private AtomicStampedReferenceNode top new AtomicStampedReference(null, 0); public void push(Node node) { int[] stamp new int[1]; Node oldTop; do { oldTop top.get(stamp); node.next oldTop; } while (!top.compareAndSet(oldTop, node, stamp[0], stamp[0] 1)); } public Node pop() { int[] stamp new int[1]; Node oldTop; Node newTop; do { oldTop top.get(stamp); if (oldTop null) { return null; } newTop oldTop.next; } while (!top.compareAndSet(oldTop, newTop, stamp[0], stamp[0] 1)); return oldTop; } }用AtomicStampedReference解决无锁栈的问题时思路很简单每次更新栈顶节点时把stamp也加一。线程1在读取时同时记录栈顶引用和stamp执行CAS时不仅要比较引用相等还要比较stamp相等。中间只要栈顶被动过stamp就会变化CAS就会失败线程1只能重新读取再试一次。这样虽然多了一次失败重试但栈结构永远不会被破坏。这里有个实现细节值得留意AtomicStampedReference底层其实是一个Pair对象把引用和stamp绑在一起。CAS比较的时候是比较整个Pair的引用是否相等不是拆开来分别比较。所以即使引用相同stamp不同Pair对象的引用也必然不同CAS必然失败。理解这一层你就能明白为什么AtomicStampedReference能如此干净地解决ABA问题而不只是多了一个字段。4.2 布尔标记方案只要知道“有没有被改过”就够了还有一种场景你只需要知道“中间有没有被人动过”不需要知道具体动了多少次。这种情况可以选AtomicMarkableReference。它内部用了一个boolean作为标记每次更新之后把标记置为true表示这个值已经被修改过了。AtomicMarkableReference和AtomicStampedReference的区别在于版本号能区分“改了一次”和“改了一百次”布尔标记只能区分“没改过”和“改过”。如果你的业务逻辑只需要判断“是否被改过”用布尔标记就足够还能省一点内存。但实际开发中需要“只关心是否被改过”的场景相对较少大多数情况下直接上版本号更保险毕竟版本号多一个整数成本几乎可以忽略。4.3 加锁兜底别死磕无锁前两个方案是“无锁框架内的解法”最后一个方案最简单也最容易被忽略直接放弃CAS改用加锁。很多人听到“无锁”两个字就上头觉得加锁就是菜鸡不如无锁高级。这种观点真的没必要。ABA问题本质上是CAS在“比较语义”上的缺陷。CAS告诉你的只是“当前值等于预期值”它没法告诉你“这个值在你读取之后没有被改过”。如果你的场景对“未被修改”这个判断要求极高同时又很难保证值不会被改回原样那老老实实加锁反而能用更简单的方式保证正确性。我并不是说无锁不好。高并发下无锁确实有性能优势但你要清楚无锁的优势来自于减少阻塞和上下文切换而不是来自于“天然能解决所有并发问题”。有没有必要承担ABA的风险完全取决于你业务的容忍度。我自己做方案选型的时候一般会先问一句这个值真的会被改回原样吗如果大概率不会就放心用CAS如果会而且后果严重那就别硬撑直接上锁少折腾。5. 实操演示与踩坑排查实录5.1 一个可以复现ABA的实验先给一个直接能跑的Java例子不需要装任何额外框架有JDK就行。这个例子用来感受“值被改回原样”的整个过程。public class ABADemo { private static AtomicInteger count new AtomicInteger(100); public static void main(String[] args) throws Exception { Thread t2 new Thread(() - { count.compareAndSet(100, 101); count.compareAndSet(101, 100); System.out.println(线程2执行完毕count count.get()); }); Thread t1 new Thread(() - { try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); } boolean result count.compareAndSet(100, 999); System.out.println(线程1 CAS结果 result , count count.get()); }); t1.start(); t2.start(); t1.join(); t2.join(); } }运行结果一般是线程2执行完毕后count还是100线程1执行compareAndSet(100, 999)返回true。很多人第一次跑这个实验会觉得这不是挺好吗确实把100改成了999没毛病啊。单看这一段确实没毛病。所以我才反复强调ABA问题需要结合业务语义才看得出危害。它真正恶心的地方在于它给线程1造成一种“我一直拥有这个值”的错觉。如果线程1接下来要做的事情是基于“这个值从我开始操作到现在都没变过”来推导的那后面就会出大问题。建议把这个小实验和无锁栈的例子放在一起理解一个是值的层面一个是数据结构的层面合起来你就能彻底看清ABA的全貌。5.2 排查ABA问题的五个步骤如果怀疑线上遇到的是ABA问题建议按下面的方向排查。第一步先确认底层是不是依赖CAS。可以搜索代码里有没有Atomic开头的类、compareAndSet方法、以及自己封装的CAS工具。如果完全没有那大概率不是ABA问题。第二步确认值的类型和变化规律。如果是个计数器而且只增不减一般不会回到旧值ABA概率很低如果值是状态码、对象引用、指针这类可能被复用或往返变化的数据风险就高得多。第三步看有没有“节点复用”的情况。无锁栈、无锁队列、对象池、内存池都是重灾区打开代码重点查这些模块。第四步可以临时把实现换成AtomicStampedReference或AtomicMarkableReference做实验性修复观察stamp冲突次数和线上日志是否明显变化。第五步如果业务场景非常复杂别死磕无锁直接加锁重构大概率能更快解决。我把常见问题整理成一张表方便对照问题现象可能原因排查动作无锁栈偶尔丢节点栈顶引用被复用后又被CAS更新打印每次CAS的旧值、新值和stamp队列偶尔重复消费节点被弹出后又重新入队给节点加全局唯一ID对比消费日志乐观锁更新“莫名失败”版本号疑似被重置或回滚检查业务里有没有手动改版本号的逻辑高并发下数据错乱多个线程对同一引用做CAS重点审查节点复用和对象池场景5.3 我在实际项目里的真实体会写到最后说一点我自己的真实经历。很早以前我也觉得ABA问题就是个面试题工作中根本遇不到。后来在一个系统里排查无锁队列偶尔丢消息的问题查了整整一天最后才发现症结就是ABA。当时项目里用了AtomicReference来维护队列头节点节点在被弹出之后又被快速重新入队引用一样但链表的整体结构已经变了导致一次延迟CAS把整条链表的入口给带偏了。换成AtomicStampedReference之后问题再也没有出现过。从那以后我在任何涉及“节点复用”的并发设计里都会默认带上版本号。再分享一个小技巧。如果不想把整个项目都从AtomicInteger换成AtomicStampedReference也可以自己写一个轻量包装类内部维护一个long类型的版本号每次更新版本号自增。这样改动面小逻辑也更直观。但要注意包装类本身的更新动作还是需要用AtomicReference或加锁来保证原子性别把并发问题变成另一个并发问题。还有一点值得强调ABA问题不是Java独有的。C里用atomic_compare_exchangeGo里用atomic.CompareAndSwapPointer底层同样存在ABA的可能。只要语言提供了CAS原语就绕不开这个问题。所以这个知识点不光面试要用实际工程选型的时候也值得放进你的checklist里。每次看到“无锁”两个字都多问自己一句这个值的中间变化我真的不在乎吗