☰
CAS与ABA问题详解:从原理到AtomicStampedReference解决方案
2026/10/14 5:59:16 网站建设 项目流程

做并发编程的,几乎没人没听过CAS(Compare-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. 用“前女友”的故事看懂ABA

2.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 AtomicReference<Node> 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; } }

这个栈的初始状态是:栈顶是A,A下面是B。现在线程1准备执行pop,它先读到了oldTop=A,newTop=B,就在它准备执行CAS的前一刻,被操作系统挂起了。线程2在这段时间里连续做了三件事:第一次pop,把A弹出去,栈顶变成B;接着push了一个C,栈顶变成C;再push了一个A,注意这个A和初始的A是同一个对象,栈顶又变成了A,结构变成A→C→B。

这时候线程1醒过来了,继续执行它的CAS。它一看,栈顶确实是A,跟自己的预期一模一样,于是CAS成功,把栈顶指向了B。但问题是,现在的栈结构是A→C→B,B在栈底,而且B的next已经是null。线程1这么一改,整个栈就变成了top指向B,A和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 AtomicStampedReference<Node> 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_exchange,Go里用atomic.CompareAndSwapPointer,底层同样存在ABA的可能。只要语言提供了CAS原语,就绕不开这个问题。所以这个知识点不光面试要用,实际工程选型的时候,也值得放进你的checklist里。每次看到“无锁”两个字,都多问自己一句:这个值的中间变化,我真的不在乎吗?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询