深入理解 CAS 与 ABA 问题
CAS 是无锁并发的基石,靠一条 CPU 原子指令完成「比较并交换」。但它有三个绕不开的坑——自旋开销、单变量限制和 ABA 问题,这篇一次讲透。
一、先看一段有问题的代码
要给一个计数器加一,最直觉的写法是这样的:
public class Counter {
private int count = 0;
public void increment() {
count++; // 看着是一行,其实是三步
}
}
count++ 在字节码层面被拆成三步:
- 读取
count到寄存器 - 寄存器里的值加一
- 把结果写回
count
两个线程同时执行时,交错顺序可能是这样:
| 时刻 | 线程 A | 线程 B | count |
|---|---|---|---|
| T1 | 读取 count = 0 | 0 | |
| T2 | 读取 count = 0 | 0 | |
| T3 | 加一,写回 1 | 1 | |
| T4 | 加一,写回 1 | 1 |
两次自增,结果却是 1——丢了一次更新。这就是典型的竞态条件。
传统解法是加锁(synchronized),但悲观锁在线程等待时需要挂起和唤醒,涉及用户态与内核态的切换。在冲突并不激烈的场景下,这种开销显得很笨重。
CAS 是另一条路:不加锁,靠一次原子判断完成更新。
二、CAS 是什么
CAS 全称 Compare And Swap(比较并交换),是一种无锁的乐观锁实现。
核心逻辑只有三步:
- 拿到预期旧值和内存当前值做比较
- 如果相等:说明这段时间没人修改过,把内存更新成新值
- 如果不相等:说明已经被别的线程改过,更新失败,可以选择重试或者放弃
关键在于:
比较 + 交换是一条 CPU 原子指令,不会被打断,所以不需要加锁就能保证这组操作的原子性。
Java 里的 AtomicInteger 底层用的就是 CAS。
单条指令为什么就够
很多人会疑惑:比较和交换明明是两步,凭什么说它是原子的?
因为在硬件层面,它们不是两条指令。以 x86 为例,LOCK CMPXCHG 是一条指令,CPU 在执行期间会锁住总线(或缓存行),其他核心无法插入。所谓"原子",指的是这条指令执行期间外部观察不到中间状态。
这也是 CAS 比锁轻量的根本原因:没有线程阻塞,没有上下文切换,没有操作系统参与。
三、CAS 的三大缺点
CAS 有三个绕不开的缺点,前两个比较直白,第三个最容易踩坑:
1. 循环自旋消耗 CPU
CAS 失败后通常要重试,写法是一个 do-while 循环。竞争激烈时反复失败,一直空转,CPU 时间被白白烧掉。
线程数越多、冲突越激烈,这个缺点越明显。
2. 只能保证单个变量的原子性
CAS 只能对一个共享变量做原子更新。
如果需要同时更新两个变量(比如「余额」和「流水」),CAS 无能为力,只能:
- 把它们封装成一个对象,用
AtomicReference指向它 - 或者退回加锁
3. ABA 问题
这是最隐蔽、也最容易被忽略的一个,单独展开。
四、ABA 问题详解
问题是怎么发生的
假设变量初始值是 A:
| 时刻 | 线程 1 | 线程 2 | 内存值 |
|---|---|---|---|
| T1 | 读取值 → A | A | |
| T2 | (被挂起) | 把 A 改成 B | B |
| T3 | 又把 B 改回 A | A | |
| T4 | 比较:还是 A,认为没变 → 执行更新 | 新值 |
线程 1 在 T4 做 CAS 时,发现内存值还是 A,于是判定「这段时间没人动过」,放心地执行了更新。
但实际上中间数据被修改过了,只是改回了原样。
在只关心最终值的场景(比如单纯的计数器)里,这可能无害。但在关心状态变化过程的场景里,这就是灾难。看一个真实例子:
账户余额 100 元
线程 1: 读取余额 100,准备扣 50(判断:余额充足)
线程 2: 取出 100 → 余额变 0
线程 2: 又存入 100 → 余额变回 100(其实是另一笔钱)
线程 1: CAS 比较,发现还是 100 → 扣款成功
线程 1 以为自己扣的是原来那 100 元,实际上中间这笔钱已经被取空过一次了。
解决方案:加版本号
思路很直接:不只看值,还要看「这个值被改过几次」。
每次修改时同时递增版本号,CAS 比较时校验 **【值 + 版本号】**两个条件。只要中间发生过修改,版本号一定变了,就能感知到。
Java 提供的工具是 AtomicStampedReference:
// 初始化:值为 100,版本号为 0
AtomicStampedReference<Integer> ref =
new AtomicStampedReference<>(100, 0);
int[] stampHolder = new int[1];
Integer current = ref.get(stampHolder);
int oldStamp = stampHolder[0];
// CAS:值和版本号必须同时匹配才更新
boolean success = ref.compareAndSet(
current, // 期望的旧值
current - 50, // 新值
oldStamp, // 期望的旧版本号
oldStamp + 1 // 新版本号
);
AtomicStampedReference 内部维护了两个字段:一个存数据引用,一个存版本戳 stamp。每次 CAS 都会同时校验两者,所以中间只要改过,版本号必然变化,ABA 就无处遁形。
一个常见的误解
问:如果只用 AtomicInteger,能不能解决 ABA?为什么?
不能。
AtomicInteger 的 CAS 只比较变量本身的值。原始值是 A,线程 2 先把 A 改成 B、再改回 A,在线程 1 看来读到的仍然是 A,CAS 校验判定「数据没有修改」,直接执行更新。
它没有记录版本或者修改次数,识别不出中间发生过修改,所以无法解决 ABA 问题。
要解决 ABA,必须同时比较【值 + 版本号】,也就是 AtomicStampedReference。
五、CAS 和 synchronized 怎么选
| 维度 | CAS | synchronized |
|---|---|---|
| 思想 | 乐观:假设冲突少,先改再说 | 悲观:假设冲突多,先锁再改 |
| 加锁 | 无锁 | 加锁 |
| 失败行为 | 自旋重试,不阻塞线程 | 竞争大时线程阻塞挂起 |
| CPU 开销 | 冲突激烈时自旋烧 CPU | 线程等待时不占 CPU |
| 适合场景 | 冲突少的场景 | 冲突高的场景 |
一句话总结:冲突少用 CAS,冲突多用锁。
冲突少时,CAS 一次就成功,没有线程切换开销,性能远好于加锁;冲突多时,CAS 大量空转重试,反而不如让线程老老实实排队。
六、小结
- CAS 三步:比较预期旧值与内存当前值 → 相等则更新 → 不等则失败
- 原子性来自 CPU 的单条原子指令,不是靠锁
- 三大缺点:自旋烧 CPU、只能保证单个变量、ABA 问题
- ABA 的本质是「值改回来了,但修改过程被忽略了」,解法是值 + 版本号一起比较,用
AtomicStampedReference - 选型原则:冲突少用 CAS,冲突多用
synchronized
CAS 是理解 Java 并发包的钥匙——AtomicInteger、AQS、ConcurrentHashMap 的底层都能看到它的影子。把这三点讲清楚,并发这块的基础就算立住了。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。