并发编程

深入理解 CAS 与 ABA 问题

CAS 是无锁并发的基石,靠一条 CPU 原子指令完成「比较并交换」。但它有三个绕不开的坑——自旋开销、单变量限制和 ABA 问题,这篇一次讲透。

2026-02-02 约 7 分钟 2903 字 Java 并发编程 锁

一、先看一段有问题的代码

要给一个计数器加一,最直觉的写法是这样的:

public class Counter {
    private int count = 0;

    public void increment() {
        count++;      // 看着是一行,其实是三步
    }
}

count++ 在字节码层面被拆成三步:

  1. 读取 count 到寄存器
  2. 寄存器里的值加一
  3. 把结果写回 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(比较并交换),是一种无锁的乐观锁实现。

核心逻辑只有三步:

  1. 拿到预期旧值和内存当前值做比较
  2. 如果相等:说明这段时间没人修改过,把内存更新成新值
  3. 如果不相等:说明已经被别的线程改过,更新失败,可以选择重试或者放弃

关键在于:

比较 + 交换是一条 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_* 四个变量后自动开启。