☰
深入理解CAS与ABA问题:从CPU指令到AtomicStampedReference的并发编程实战
2026/10/14 1:39:43 网站建设 项目流程

1. 从一次并发扣款说起:CAS是什么,为什么绕不开

做过多线程开发的朋友,基本都撞见过这种场景:一个账户要被扣款,两个线程同时扣,不加锁吧,结果不对,加锁吧,锁竞争又让性能掉得难看。刚上手并发编程的时候,我第一反应就是synchronized,项目里到处加锁,后来线上出现锁竞争导致的线程阻塞,再后来开始接触 CAS 这套无锁方案,才明白之前的很多问题其实有更轻量的解法。

先说结论:CAS 是 Compare And Swap 的缩写,翻译过来叫比较并交换。它的核心思想非常直白:更新一个变量之前,先看看这个变量当前是不是还是我以为的那个值,如果是,就把它替换成新值;如果不是,说明在我读取之后别人已经改过了,那这次更新就失败,重新读、重新试。整个过程不需要加锁,却能保证数据不被并发地写坏,这就是它在高并发场景下被大量使用的原因。

这篇内容要解决的问题很具体:

  • CAS 底层到底是怎么工作的,CPU 为什么能保证“比较+交换”这两步在硬件层面是原子的;
  • Java 里 Atomic 系列工具类的源码是怎么利用 CAS 的;
  • 并发编程里著名的 ABA 问题是怎么回事,为什么有时候它“没事”,有时候它是致命的;
  • 怎么用AtomicStampedReference这类方案彻底解决 ABA 问题,代码要怎么改。

适合谁来看?如果你正在用 Java 做多线程开发,或者准备技术面试被追问过 CAS 和 ABA,再或者项目里用了AtomicInteger但只知道它能自增,这文章基本就是为你准备的。

2. 拆开 CAS 的底牌:三个操作数和一个原子指令

2.1 从读改写三步到一步交换

我们已经知道传统并发下修改一个共享变量要经历三个步骤:读旧值、做计算、写回新值。多线程下这三步一旦交错执行,后写的覆盖先写的,数据就丢了。加锁的本质是对“读-算-写”这整个时间段做互斥,保证同一时间只有一个线程进来操作。

CAS 的思路完全不同。它把“比较”和“交换”绑定成一个不可分割的操作,操作数是三个:

  • V:内存里的变量值;
  • E:我期望它当前的值;
  • N:我要写入的新值。

执行逻辑用伪代码写就是:

boolean compareAndSwap(V, E, N) { if (V == E) { V = N; return true; } return false; }

光看这句话,很多人会觉得这有什么稀奇的,不就是if判断加赋值吗?关键在于“这个 if 判断加赋值”在硬件层面是原子的。CPU 保证执行这段比较交换逻辑的时候,其他线程插不进来,要么整段做完,要么一整段都不做。

这个设计解决的是“并发修改”的核心矛盾:我不锁住整个计算过程,我只在最后落笔的那一刻校验一次,值没变就写,变了就放弃这次写入,让调用方去重试。乐观锁的思想在这里体现得很典型。

2.2 CPU 层面的一条指令

硬件上,x86 体系里有CMPXCHG(Compare and Exchange)指令,这条指令会做比较和交换,并且可以通过加LOCK前缀来保证在多核 CPU 环境下的原子性。加了这个前缀之后,CPU 会锁住总线或者锁住缓存行,防止其他核心在比较和交换之间插一脚。

所以 CAS 的原子性不是 Java 虚拟机层面实现的,也不是我们写出来的if判断能做到的,它依赖的是 CPU 指令。这也是为什么说 CAS 是“硬件级的同步原语”。

Java 层为了让开发者使用这个能力,通过Unsafe类暴露了底层操作。Unsafe类里有一堆名字带compareAndSwap的方法,比如:

public final native boolean compareAndSwapInt( Object o, long offset, int expected, int x );

参数里那个offset是属性在对象内存布局里的偏移地址。有了偏移量,CAS 指令才能定位到变量在对象里的位置。正因为Unsafe太底层、太危险,官方不允许业务代码直接用,Java 9 之后提供了VarHandle作为更安全的标准替代品,后续的Atomic系列也有逐步向VarHandle迁移的趋势。

2.3 CAS 在 Java 并发工具里无处不在

既然打开了java.util.concurrent的源码,你会发现 CAS 几乎渗透到了每个角落:AtomicInteger、AtomicLong、AtomicLongArray、AtomicReference这些原子类底层是 CAS,ConcurrentHashMap扩容和插入时大量用 CAS 更新状态位,ReentrantLock里获取锁也依赖一段著名的 CAS 操作。

举一个很常见的例子,数据库连接池里的状态管理。我以前在一个模拟项目里帮团队优化数据库连接池,里面用了AtomicInteger记录当前活跃连接数,每次申请连接就自增,释放就自减,这几个操作如果加锁会非常频繁抢占锁,换成 CAS 自旋之后,高并发下几乎没什么阻塞成本。

这个“自增”就是getAndIncrement(),下面一节我们从源码级别细看。

3. Java 里怎么用 CAS:AtomicInteger 源码拆解

3.1 从 valueOffset 说起

打开AtomicInteger的源码,第一行的核心信息是:

private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset( AtomicInteger.class.getDeclaredField("value") ); } catch (Exception ex) { throw new Error(ex); } } private volatile int value;

这里有两个关键点:

  • value用了volatile,保证变量在多线程之间的可见性;
  • valueOffset是value字段在AtomicInteger对象里的内存偏移量。

为什么需要volatile?因为 CAS 操作只能保证比较和交换是原子的,但另一条线程读到的最新值从哪里来?如果变量被线程A修改后,线程B的本地缓存里还是旧值,CAS 就会一直误判。volatile解决的是可见性,它让每次读写都直接跟主内存打交道,CAS 才能拿到最新值、把旧值换掉。

getAndAddInt是常用方法的底层实现,源码长这样:

public final int getAndAddInt(Object o, long offset, int delta) { int v; do { v = getIntVolatile(o, offset); } while (!weakCompareAndSetInt(o, offset, v, v + delta)); return v; }

这个do-while就是自旋:先读当前值,再尝试用 CAS 把新值写进去,失败就重新读、再试。直到某一次 CAS 成功为止。

3.2 用一个计数器实操一下

我写了一个非常简单的多线程计数器,来演示 CAS 的实际效果。100个线程,每个线程对同一个计数器累加10000次,预期结果是1000000。用普通int和加锁实现都能写,但为了验证 CAS,我用AtomicInteger:

public class CasCounterDemo { private static final AtomicInteger counter = new AtomicInteger(0); private static final int THREAD_COUNT = 100; private static final int TIMES = 10000; public static void main(String[] args) throws InterruptedException { List<Thread> threads = new ArrayList<>(); for (int i = 0; i < THREAD_COUNT; i++) { Thread t = new Thread(() -> { for (int j = 0; j < TIMES; j++) { counter.incrementAndGet(); } }); threads.add(t); t.start(); } for (Thread t : threads) { t.join(); } System.out.println("final value = " + counter.get()); } }

跑完结果是1000000,一点不多一点不少。如果改成普通的volatile int做count++,结果一定小于这个数,因为count++整体不是原子操作,中间会被其他线程插队。CAS 把“读当前值、加1、写回”中的“比较+写”变成了原子,再加上循环重试,最终能保证结果正确。

3.3 自旋的代价不是零

CAS 看着完美,但如果你觉得它一定比锁快,那就天真了。自旋有一个隐藏成本:线程在循环里空转时,CPU 一直在执行轮询指令,如果锁竞争非常激烈,大量线程同时自旋,CPU 占用率会直接飙升,甚至可能出现“用 CAS 反而比用锁还慢”的情况。

有人会问,既然失败了就重试,那不就跟忙等差不多么?对,CAS 本质就是忙等,只不过它不会让出 CPU,也不会发生线程切换。所以 CAS 适合用在临界区很小、竞争不是特别激烈的场景。真遇到高竞争,LongAdder这类分段方案反而更合适,它把单点热点分散到多个单元里,降低 CAS 失败的次数。

这个细节后面单独拿出一节详细讲,因为“无锁并不等于免费”是我踩过最多坑的地方。

4. ABA 问题的真相:看起来没变,其实已经沧海桑田

4.1 一个经典的无锁栈案例

前面讲的 CAS 逻辑里有一个隐含假设:只要 CAS 比较发现内存值等于期望值,就说明变量没被别人改过。但这个假设在特殊时间序列下会失效,ABA 问题就是这么来的。

ABA 的意思如下:线程1读到变量值是 A;线程2把值改成 B,然后又改回 A;线程1再次做 CAS 时发现内存值还是 A,于是认为没人动过,CAS 成功。可实际上变量已经经历了 A → B → A 的完整变化。值一样,不代表过程一样。

用一个经典的无锁栈来演示这个问题的危害,这是并发教科书级的例子。

假设栈的情况是:栈顶head指向节点 A,A 的next指向 B。线程1要执行弹栈,也就是把栈顶从 A 换成 B。它的逻辑是:

  • 读oldHead = head,得到 A;
  • 读oldNext = A.next,得到 B;
  • 执行CAS(head, A, B),只要栈顶还是 A 就替换成 B。

正常情况下线程1的做法没问题。但加入线程2插队:

  • 线程2先把 A 弹出,栈顶变成 B;
  • 线程2在 B 前面压入一个 C,然后又弹出 C;
  • 最后线程2又把 A 压回去,栈顶重新变成 A。

注意此时 A 的next已经被线程2改过了,不再是原来的 B。但线程1并不知道,它手里拿的oldNext还是老的 B。线程1执行 CAS 时,发现内存里的head确实等于期望值 A,于是替换成功,栈顶变成 B,但 B 背后原本被线程2维护的老链表结构已经被破坏,那个 C 直接丢失了。

这就不是数值对不对的问题了,它直接把整个链表结构搞坏,属于结构性破坏。

4.2 数值统计场景里 ABA 为何常被忽略

很多人一开始会困惑:自己用了很久AtomicInteger做计数器,也没遇到过 ABA 出问题,是不是 ABA 只是面试题而已?

这里要区分两个概念:ABA 的发生和ABA 造成的破坏。ABA 每天都可能在发生,但大多数计数器场景根本不关心中间过程。比如统计接口调用次数,只要最终数字对,没人关心它是 1 → 2 → 1 走了一圈再回到 1 还是从头到尾 1 → 1。这个过程没有中间状态需要被观察,ABA 不是一个问题。

真正会被 ABA 坑到的场景有共同特征:CAS 的比较对象不只是一个数值,而是代表某种具备结构含义的引用或状态。无锁栈、无锁队列、资源池状态回收这一类场景,比较的“值”往往关联着指针、对象状态、资源归属,中间任何一次变化都可能改变对象的内部关系,仅仅凭“值相等”就信任状态没有变,就危险了。

拿资源池举个例子。某个连接池的回收逻辑用CAS把连接状态从 IDLE 改成 BUSY,如果某个连接被取出、又因为异常被清理,最后又恢复成 IDLE,另个线程发现它是 IDLE 就以为还是原先那个对象,拿过去用,结果拿到的可能已经是对象内容被改掉的新状态了。这种用着用着发现对象行为异常的情况,排查起来极其隐蔽。

4.3 复现 ABA 的实测代码

为了验证 ABA 确实会发生,我自己写过一个复现 demo。用AtomicReference模拟一个二元状态的变量,线程1期望看到初始值,线程2在中间做两次修改再改回去。

import java.util.concurrent.atomic.AtomicReference; public class AbaDemo { private static final AtomicReference<String> ref = new AtomicReference<>("A"); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { String current = ref.get(); System.out.println("t1 read value = " + current); // 模拟线程1被切换出去 sleep(500); boolean success = ref.compareAndSet(current, "B"); System.out.println("t1 cas result = " + success + ", current value = " + ref.get()); }, "t1"); Thread t2 = new Thread(() -> { sleep(100); ref.compareAndSet("A", "B"); System.out.println("t2 change A -> B"); ref.compareAndSet("B", "A"); System.out.println("t2 change B -> A"); }, "t2"); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("final value = " + ref.get()); } private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }

执行结果中,线程1的compareAndSet返回true,因为它比较时值确实还是A,但A已经被线程2改成 B 再改回来了。这个 demo 就清晰地展示了 ABA 发生的完整路径:读、改回、成功替换。

5. 对症下药:AtomicStampedReference 的版本号思路

5.1 为什么加个版本号就能破局

ABA 问题的本质是“值相同”不等于“状态没变”。那我们只要在状态里塞一个每次修改都会变化的附加标记,就能把它和“值相同但过程不同”的情况区分开。

AtomicStampedReference就是基于这个思路设计的。它内部维护的其实是一个四元组:(reference, stamp),reference是对象引用,stamp是版本号,类似于乐观锁里的版本字段。每次执行 CAS 时,它要求引用和版本号同时匹配,才允许替换;替换的时候,新版本号要显式传入。

源码里关键结构是一个Pair:

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;

CAS方法的签名是:

public boolean compareAndSet( V expectedReference, V newReference, int expectedStamp, int newStamp )

它要求“期望引用”和“期望版本号”同时满足,两个条件缺一不可。这样即便引用值绕了一圈变回来了,只要版本号变了,CAS 依然会失败。

5.2 用版本号修复无锁栈案例

回到前面那个无锁栈丢数据的场景,解决办法就是不给节点直接做 CAS,而是维护一个AtomicStampedReference<Node>,每次栈发生变化,都同时改变stamp。线程1除了持有head引用,还要持有对应的版本号。执行替换时,必须确保引用相同并且版本号也相同,才会把 head 改成新值、把版本号加 1。

用代码表示就是:

import java.util.concurrent.atomic.AtomicStampedReference; public class FixAbaStack { static class Node { final int value; Node next; Node(int value) { this.value = value; } } private final AtomicStampedReference<Node> top = new AtomicStampedReference<>(null, 0); public void push(Node node) { while (true) { Node currentTop = top.getReference(); int[] stampHolder = new int[1]; int currentStamp = top.get(stampHolder); node.next = currentTop; if (top.compareAndSet(currentTop, node, currentStamp, currentStamp + 1)) { break; } } } public Node pop() { while (true) { Node currentTop = top.getReference(); int[] stampHolder = new int[1]; int currentStamp = top.get(stampHolder); if (currentTop == null) { return null; } Node next = currentTop.next; if (top.compareAndSet(currentTop, next, currentStamp, currentStamp + 1)) { currentTop.next = null; return currentTop; } } } }

关键变化在于:操作者持有版本号,替换时必须传入currentStamp + 1。就算节点 A 被弹出又压回,top的引用值回到 A,但它的版本号已经和线程1刚读出来时完全不同了,CAS 会直接失败,线程1只能从循环里重新读取最新的 head 和版本号,从而避免基于过期状态做错误操作。

这个思路本质上和数据库乐观锁一模一样,只是 CAS 把“版本号判断+数据更新”变成了一个原子操作。

5.3 AtomicStampedReference 和 AtomicMarkableReference 怎么选

AtomicMarkableReference是AtomicStampedReference的简化版,它只维护一个boolean类型的标记,表示这个引用是否“被修改过”。它没有精确的版本计数,只区分两个状态:改过、没改过。

实际项目里要根据语义选:

  • 如果中间状态不重要,只关心引用有没有被“动过”,用AtomicMarkableReference就够,它更轻量。
  • 如果必须知道被改了多少次,或者状态会反转多次(比如 A → B → A → B → A),boolean标记无能为力,必须用AtomicStampedReference的 int 版本号。

我踩过的一个具体选择是:某个模拟项目里要跟踪缓存对象的“回收标志”,只需要判断“从创建到现在是否被改过”,我一开始用了两个变量分别保存引用和 boolean,再加锁维护一致性,不仅代码丑,逻辑还绕。后来换成AtomicMarkableReference,一次调用搞定引用和标记的双重校验,代码干净很多。

6. 实战心得:CAS 选型、性能与常见坑

6.1 CAS 循环导致 CPU 飙高怎么处理

自旋的本质是忙等,低竞争场景下几乎无感,一旦并发量大起来,CPU 可能直接在compareAndSet的循环里绕不开。我之前在某个线上服务里看到过诡异现象:处理器占用高得离谱,但业务线程几乎都在同一个栈位置空转,jstack一抓,线程全卡在AtomicInteger.getAndAddInt的do-while里。

处理这类问题有几个思路:

  • 给自旋设置上限,超过重试次数就转为加锁或让出 CPU;
  • 在高竞争场景改用LongAdder,它内部对一个热点拆成多个段,每个段单独维护自己的计数,读取时才汇总,将单点 CAS 竞争分散掉;
  • 如果 JDK 版本在 9 以上且没有迁移约束,可以关注Thread.onSpinWait()方法,JVM 会在硬件层面降低自旋时的功耗和开销,配合循环重试有一定帮助。

后来我把AtomicLong换成了LongAdder,同样业务量下 CPU 占用掉了一大截。这算是一个印象比较深的教训:CAS 不是免费的,竞争激烈的场景要把它当作“优先考虑的锁替代”,而不是“无脑使用的银弹”。

6.2 CAS 一次只能保护一个变量,多字段怎么办

很多代码一写就嗨,用AtomicReference把对象整个包住,试图用 CAS 更新对象里的两个字段。理论上可行,但它有一个死角:AtomicReference比较的是“对象引用”是否相等,不是对象内容是否相等。如果两个线程持有同一个对象的不同副本,引用比较永远失败;如果用同一个对象修改内部字段,CAS 会比较引用,发现引用相等就会直接替换,那你怎么知道内部字段是什么时候被改的?

稳妥的做法有两种:

  • 把要保护的多个字段包装成一个不可变对象(例如State),每次修改都构造一个新对象,通过AtomicReference<State>做整体替换。这要求State是 immutable 的,否则形同虚设。
  • 如果业务无法轻易转成不可变对象,或者多个字段之间有复杂一致性要求,老老实实回到synchronized/Lock。CAS 在这种复杂一致性场景下写起来巨难维护,不如一把锁简单干脆。

我在某网关路由配置的模拟项目里就遇到过这种选择:节点列表、权重、启停状态三个字段必须同时更新,引入AtomicReference<Config>之后,每次变更都要 clone 一份完整配置,代码绕了一圈。后来评估下来,这个配置更新频率极低,完全不值得用 CAS,直接加synchronized方法,读写锁都省了。工具是拿来用的,不是拿来炫技的,这个道理越写代码体会越深。

6.3 面试和设计评审里怎么把 CAS 讲出深度

很多人介绍 CAS 只会说一句“比较并交换,可以不加锁保证原子性”,但面试官真问下去,往往撑不住三连问:底层指令是什么?ABA 是什么?怎么解决?

我总结自己面试别人和被面试的经验,一条清晰的链路是:

  • 先说明无锁更新的痛点:读改写三步非原子的问题;
  • 再给出 CPU 指令层的支撑:CMPXCHG+LOCK,说明 CAS 是硬件原语而非 java 语法;
  • 引入Unsafe/VarHandle,说明 Java 层如何调用,以及Atomic系列源码的自旋逻辑;
  • 顺势抛出 ABA 问题,用一个无锁栈举例说明构造性危害;
  • 最后讲AtomicStampedReference和AtomicMarkableReference的取舍,甚至可以现场手写一个简化版。

这条逻辑链能展示你对一个技术点从硬件到源码、从应用到边界问题的完整理解。

6.4 日常编码中的选型建议

做了这么多年的并发编程,我给自己定的选型标准很简单:

场景选用方案理由
计数器、统计量、序号生成AtomicLong/LongAdderCAS 自旋开销低,语义清晰
单一引用状态的原子更新AtomicReference简单值替换场景足够
需要感知中间被改过AtomicStampedReference版本号精确解决 ABA
只关心是否被改过AtomicMarkableReferenceboolean 标记足够
多字段强一致更新Lock/synchronized避免不切实际的 CAS 改造
高竞争热点更新LongAdder分散单点热点

7. 关于版本号的一个小技巧

最后分享一个我在实战里累积的小经验:用AtomicStampedReference时,版本号的初始值不要从 0 开始优化成“无意义”的历史数字,而是尽量从业务语义里选一个能区分的起点。

比如状态机的初始状态是 CREATED,那版本号可以从 1 开始,每次状态迁移加 1。这样版本号实际上成了“状态变迁的代数”,排查问题时能通过版本号大致推断对象经历过多少次变化,对做线上问题复盘非常有帮助。我见过不少代码把 stamp 随便传、甚至一直传固定值,那就等于没解决 ABA,只是把问题藏得更深了。

再有一个操作上的坑:更新时忘记把newStamp递增。compareAndSet要求传入新旧两个版本号,如果你传入的newStamp和expectedStamp相同,那这个 stamp 就没有意义了,因为 ABA 场景里绕完一圈后版本号可以回到原值的那一版。我自己曾经就因为新版本号传成了固定常量,排查了很久才意识到,版本号根本没起到“标记过程变化”的作用。

说到底,ABA 问题的解法思路并不复杂,难的是想明白“值相等”和“状态等价”之间的差别。只要把这一点想透了,不管是在无锁栈里修复结构破坏,还是在资源池里避免状态错配,都会顺畅很多。

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

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

立即咨询