CAS 的原理和 ABA 问题怎么解决?

2 阅读5分钟

可以把它拆成两部分理解:CAS 是怎么做到“无锁更新”的,以及 ABA 为什么会让 CAS 误判成功


一、CAS 的原理

CAS 全称是 Compare And Swap,也叫 Compare And Set

它的核心逻辑很简单:

拿内存中的当前值 V,和预期值 A 比较;如果相等,就把它改成新值 B;如果不相等,就什么都不做。

可以理解成下面这个伪代码:

if (value == expected) {
    value = newValue;
    return true;
} else {
    return false;
}

但重点在于:

1)这是一个原子操作

这个比较 + 更新,不是两步分开执行,而是 CPU 指令级别保证原子性
也就是说,中间不会被别的线程插进来修改。

Java 里常见的 CAS 类,比如:

  • AtomicInteger
  • AtomicLong
  • AtomicReference

底层最终都会依赖 JVM 和 CPU 提供的原子指令来完成。


2)CAS 的工作流程

AtomicInteger.incrementAndGet() 为例,本质上通常是这样的“自旋”逻辑:

for (;;) {
    int oldValue = get();
    int newValue = oldValue + 1;
    if (compareAndSet(oldValue, newValue)) {
        return newValue;
    }
}

含义是:

  1. 先读取当前值
  2. 基于旧值计算新值
  3. 尝试 CAS 更新
  4. 如果失败,说明有别的线程先改了,重新再来一次

这种不断重试的方式,就叫 自旋


3)CAS 为什么高效

synchronized 相比,CAS 的优势是:

  • 不会阻塞线程
  • 不会发生线程挂起和唤醒的上下文切换
  • 低竞争场景下性能很好

所以它常被称为一种 乐观锁 思想:

我先假设没有人跟我冲突,更新失败了再重试。


二、CAS 的问题

CAS 虽然高效,但不是万能的,常见有三个问题:

1)自旋开销大

如果竞争很激烈,一个线程可能一直 CAS 失败,不断重试,浪费 CPU。

2)只能保证一个共享变量的原子操作

如果要同时更新多个变量,单纯 CAS 不够,需要:

  • 加锁
  • 或把多个字段封装成一个对象,用 AtomicReference
  • 或使用 AtomicStampedReference / AtomicMarkableReference

3)ABA 问题

这是最经典的问题。


三、什么是 ABA 问题

现象

CAS 只会判断:

“当前值是否还等于我最初看到的值?”

不会关心这个值中间有没有变过

举个例子:

  • 线程1读取到变量值是 A
  • 在线程1准备 CAS 之前
  • 线程2把值从 A 改成了 B
  • 然后又从 B 改回了 A
  • 线程1再去 CAS,发现值还是 A
  • 于是 CAS 成功

线程1会以为:

“这个值一直没变过”

但实际上它已经被别人改过两次了。

这就是 ABA 问题


四、ABA 为什么有问题

如果只是普通整数计数,有时 ABA 未必造成严重影响。
但在一些场景下会出大问题,比如:

  • 无锁栈
  • 无锁队列
  • 链表节点更新
  • 对象引用更新

因为这里关心的不只是“值相等”,还关心:

这个对象 / 节点是否还是原来那个状态

例如栈顶节点原来是 A,后来被弹出又重新压回,虽然“看起来还是 A”,但它的 next 指针、上下文状态可能已经变了。
这时 CAS 误判成功,就可能导致数据错乱。


五、ABA 怎么解决

方案1:加版本号 / 时间戳

最常见、最标准的方案就是:

比较时不仅比较值,还比较版本号。

每次修改时:

  • 值更新
  • 版本号也 +1

这样即使值从 A → B → A,只要版本号变了,就能识别出“它被改过”。

Java 里的实现

Java 提供了:

  • AtomicStampedReference

它内部维护:

  • 引用值 reference
  • 版本号 stamp

示意:

AtomicStampedReference<String> ref =
    new AtomicStampedReference<>("A", 1);

更新时不仅要传值,还要传版本号:

int stamp = ref.getStamp();
String value = ref.getReference();

boolean success = ref.compareAndSet("A", "B", stamp, stamp + 1);

如果中间有人改过,即使值又变回 "A",版本号也对不上,CAS 就会失败。


方案2:使用 AtomicMarkableReference

如果你不需要精确版本号,只需要知道:

“这个对象是否被动过 / 是否被标记过”

可以用:

  • AtomicMarkableReference

它维护的是:

  • 引用值
  • 一个 boolean 标记位

适合某些“逻辑删除”场景,但它没有版本号那么强。


方案3:直接加锁

如果场景复杂,且对一致性要求很高,直接用:

  • synchronized
  • ReentrantLock

有时候反而更稳妥。
因为不是所有问题都适合靠 CAS 无锁解决。


六、一个 ABA 的经典例子

假设共享变量初始值为 100

线程1:

读取到值为 100,准备改成 200

线程2:

先把 100 改成 101,再改回 100

这时线程1执行:

compareAndSet(100, 200)

会成功,因为当前值确实还是 100
但线程1不知道这个值中间被别人改过。


七、面试回答

CAS 是一种基于硬件原子指令实现的无锁并发机制。它会比较内存位置当前值是否等于预期值,如果相等就更新为新值,否则更新失败。Java 中 AtomicInteger 等原子类底层就大量使用了 CAS,并结合自旋重试来实现线程安全。

CAS 的优点是避免了线程阻塞和上下文切换,在低竞争场景性能较好;缺点是高竞争下自旋开销大、只能保证单变量原子性,而且存在 ABA 问题。

ABA 的意思是一个值原来是 A,中间被改成 B,后来又改回 A,CAS 只判断当前值是否等于 A,因此会误以为它没有被改过。这个问题在链表、栈等基于引用的无锁结构里比较严重。

解决 ABA 的常见方式是给变量增加版本号或时间戳,Java 中可以使用 AtomicStampedReference,在比较值的同时比较版本号,这样即使值回到了原值,也能知道它中间发生过变化。


八、总结

CAS 原理

比较当前值和预期值,相等才原子更新,不相等就失败重试。

ABA 问题

值虽然回到了原值,但中间其实被改过,CAS 看不出来。

解决办法

给值加版本号,常用 AtomicStampedReference