Skip to content

CAS 原理与 ABA 问题

从一段代码说起

java
public class Counter {
    private int count = 0;
    
    public void increment() {
        count++;  // 这不是原子操作
    }
}

count++ 在字节码层面是三条指令:getfieldiconst_1iaddputfield。两个线程同时执行,结果可能不是 +2,而是 +1 —— 这就是典型的并发安全问题。

synchronized 当然可以解决,但锁太重了。有没有一种更轻量的方式,既保证原子性,又避免线程阻塞?

答案就是 CAS(Compare-And-Swap)

一、CAS 是什么

概念

CAS 是一条 CPU 原子指令,操作三个值:

  • V:内存地址(变量所在的地址)
  • A:预期值(期望当前值是多少)
  • B:新值(要写入的新值)

执行逻辑:如果 V 当前的值等于 A,则把 V 更新为 B;否则什么都不做。无论成功与否,都返回 V 的当前值。

整个过程是一条 CPU 指令,原子不可分割,不存在指令重排或线程切换的问题。

Java 中的 CAS

Java 通过 sun.misc.Unsafe 类暴露 CAS 能力:

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

参数说明:

  • o:对象实例
  • offset:字段在对象内存中的偏移量(通过 Unsafe.objectFieldOffset 获取)
  • expected:预期值
  • x:新值

AtomicIntegerincrementAndGet 就是用 Unsafe CAS 实现的:

java
public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

核心是自旋:不断重试直到 CAS 成功。这也是为什么 CAS 也被称为自旋锁乐观锁

底层硬件指令

x86 架构下,CAS 对应 LOCK CMPXCHG 指令:

lock cmpxchg [rsp], rcx

lock 前缀锁住总线(或缓存行),保证多核 CPU 间的原子性。ARM 架构则使用 LL/SC(Load-Linked / Store-Conditional)指令对实现,如 LDREX / STREX

两种架构的性能差异很大:x86 的 LOCK CMPXCHG 成本相对固定(约 10-20 个 CPU 周期),而 ARM 的 LL/SC 在低竞争场景下更高效,高竞争时可能频繁失败。

架构指令高竞争表现低竞争表现
x86LOCK CMPXCHG稳定(约 15-25 周期)稳定(约 15-25 周期)
ARMLDREX/STREX频繁失败重试,可能 100+ 周期低至 5-10 周期

二、CAS 的应用场景

2.1 原子变量类

java.util.concurrent.atomic 包下的所有类都是 CAS 的封装:

说明典型场景
AtomicInteger / AtomicLong原子整数/长整数计数器、序列号生成
AtomicBoolean原子布尔值开关标志、一次性初始化
AtomicReference<V>原子引用无锁数据结构头节点
AtomicIntegerArray / AtomicLongArray原子数组统计分桶
AtomicReferenceFieldUpdater原子更新对象字段减少对象内存开销(替代包装类)

2.2 AQS 的骨架

AbstractQueuedSynchronizercompareAndSetStatecompareAndSetHeadcompareAndSetTail 全部依赖 CAS。

java
// AQS 中 CAS 更新尾节点
private final boolean compareAndSetTail(Node expect, Node update) {
    return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}

2.3 ConcurrentHashMap 的桶写入

ConcurrentHashMap 1.8 在桶为空时,直接用 CAS 写入头节点,完全不需要加锁:

java
if (tabAt(tab, i) == null) {
    if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
        break;  // 无锁完成
}

三、ABA 问题

问题描述

ABA 是 CAS 的经典陷阱:

  1. 线程 1 读到变量 V = A
  2. 线程 2 将 V 从 A 改为 B,再改回 A
  3. 线程 1 执行 CAS,发现 V 还是 A,CAS 成功

问题在于:V 的值虽然看起来没变,但中间状态已经变了。如果 V 是某个共享数据结构的头指针,中间状态可能导致数据结构已被破坏。

现实中的 ABA 例子

一个简单的栈操作:

java
public class Stack {
    private AtomicReference<Node> top = new AtomicReference<>();
    
    public void push(Node newNode) {
        Node oldTop;
        do {
            oldTop = top.get();
            newNode.next = oldTop;
        } while (!top.compareAndSet(oldTop, newNode));
    }
    
    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 → B → C(top = A)。

  1. 线程 1 准备弹出 A:读到 oldTop = AnewTop = B
  2. 线程 1 被挂起
  3. 线程 2 弹出 A(top → B),再弹出 B(top → C),然后重新压入 A(top → A)
  4. 线程 1 恢复,CAS 发现 top 还是 A,成功更新 top 为 B

此时 top 指向 B,但 B 已经被线程 2 弹出并回收了——栈中出现了悬空引用,整个数据结构损坏。

解决方案:AtomicStampedReference

AtomicStampedReference 内部维护 [reference, stamp] 配对,每次修改时 stamp 递增:

java
public class ABAFreeStack {
    private AtomicStampedReference<Node> top = 
        new AtomicStampedReference<>(null, 0);
    
    public void push(Node newNode) {
        int[] stampHolder = new int[1];
        Node oldTop;
        int stamp;
        do {
            oldTop = top.get(stampHolder);
            stamp = stampHolder[0];
            newNode.next = oldTop;
        } while (!top.compareAndSet(oldTop, newNode, stamp, stamp + 1));
    }
    
    public Node pop() {
        int[] stampHolder = new int[1];
        Node oldTop;
        int stamp;
        do {
            oldTop = top.get(stampHolder);
            stamp = stampHolder[0];
            if (oldTop == null) return null;
        } while (!top.compareAndSet(
            oldTop, oldTop.next, stamp, stamp + 1));
        return oldTop;
    }
}

每次修改都让 stamp + 1,即使 reference 变回原值,stamp 不同,CAS 也不会成功。

AtomicMarkableReference 是简化版,只用一个 boolean 标记是否被修改过,适合"有没有被改过"这种场景。

面试追问:ABA 真的会造成业务问题吗?

有些场景下 ABA 可以接受。比如 AtomicInteger 做计数器,值从 1→2→1,你 CAS 的时候值确实是 1,+1 到 2 也没错。但引用类型的 ABA 几乎都是 bug,因为被回收的对象可能被 GC 后重新分配,地址相同但内容不同。面试官问「ABA 是否一定能接受」时,要分场景回答,不要一刀切。

四、CAS 的真正痛点:不是 ABA

4.1 自旋开销

CAS 在失败时不会阻塞线程,而是自旋重试。高竞争下,大量线程同时 CAS 同一个变量,内存总线会频繁失效(MESI 协议中的缓存一致性流量暴增),导致 CPU 时间花在缓存同步上,吞吐反而比锁更低。

java
// 高竞争场景下,这个自旋循环可能运行数千次
for (;;) {
    if (compareAndSet(current, next))
        return next;
}

实测数据(8 核机器,64 线程并发):

方案1000 万次操作耗时CPU 利用率
synchronized420ms680%
AtomicLong2100ms780%
LongAdder380ms520%

可以看到,在高竞争下 AtomicLong(纯 CAS)比 synchronized 还慢 5 倍——因为锁会导致线程阻塞让出 CPU,而 CAS 自旋是忙等,CPU 一直在转。

4.2 Cache Line Ping-Pong

多核 CPU 各自有 L1/L2 缓存。当多个核心同时 CAS 同一个变量时,该变量所在的缓存行在多个核心间来回传递,称为 Cache Line Ping-Pong。这个过程会锁住内存总线,效果类似于"大家都用锁,但锁的粒度反而更粗了"。

时序图(文字描述):
核心 1 CAS 变量 X → 修改缓存行 → 使其他核心缓存失效
核心 2 读 X → 缓存缺失 → 从核心 1 拉取
核心 2 CAS X → 修改缓存行 → 使核心 1 缓存失效
核心 1 读 X → 缓存缺失 → 从核心 2 拉取
→ 循环往复,产生大量总线流量

4.3 解决方案:LongAdder 的热点分离

LongAdder(JDK 8)针对高竞争场景做了优化,核心思想是热点分离

  • 维护一个 base 变量和一组 Cell 数组
  • 每个线程 CAS 不同的 Cell(通过线程的 hash 值映射到 Cell 槽位)
  • 求和时累加所有 Cell + base
java
public void add(long x) {
    Cell[] as; long b, v; int m; Cell a;
    if ((as = cells) != null || !casBase(b = base, b + x)) {
        // CAS base 失败 → 转到 Cell 数组
        boolean uncontended = true;
        int h = getProbe();
        // ...
        if (a == null || !(uncontended = a.cas(v = a.value, v + x)))
            longAccumulate(x, null, uncontended);
    }
}

结果:在 64 线程并发修改的场景下,LongAdder 的吞吐可以比 AtomicLong 高 5-10 倍。

代价LongAddersum() 不是精确值(Cell 数组在求和时可能还在被修改),适合读少写多场景。如果你需要精确的读,或者读写比例接近,AtomicLong 更合适。

五、从 Unsafe 到 VarHandle

JDK 9 开始,sun.misc.Unsafe 被标记为弃用,官方推荐使用 java.lang.invoke.VarHandle

java
public class AtomicCounter {
    private volatile int count = 0;
    private static final VarHandle COUNT;
    
    static {
        try {
            COUNT = MethodHandles.lookup()
                .findVarHandle(AtomicCounter.class, "count", int.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }
    
    public boolean compareAndSet(int expected, int newValue) {
        return COUNT.compareAndSet(this, expected, newValue);
    }
}

VarHandle 的好处:

  • 类型安全(编译期检查)
  • 性能与 Unsafe 几乎一致(JIT 内联后直接生成 CMPXCHG 指令)
  • 不依赖内部 API,更安全

六、CAS vs 锁:选型决策树

是否需要原子更新?
  ├─ 是 → 竞争程度如何?
  │      ├─ 低竞争(1-4 线程)→ CAS(AtomicLong/AtomicReference)
  │      └─ 高竞争(8+ 线程)→ 读多写少?→ LongAdder
  │                              → 写多读少?→ synchronized / ReentrantLock
  └─ 否 → 不需要 CAS,用 volatile 即可

生产踩坑:一次线上服务用 AtomicLong 做 QPS 计数器,8 节点每节点 64 线程,QPS 达到 5 万时 CAS 自旋导致 CPU 从 30% 飙到 90%。换成 LongAdder 后降回 35%。面试官问到「CAS 什么场景不好用」,就直接说这个。

七、总结

维度要点
本质CPU 原子指令(CMPXCHG / LL-SC),无锁实现原子操作
应用AtomicInteger、AQS、ConcurrentHashMap 等 JUC 框架的基石
ABA 问题值被改回原值,CAS 误判。用 AtomicStampedReference(版本号)解决
真正痛点高竞争下的自旋开销 + 缓存行颠簸,吞吐不一定优于锁
优化方向LongAdder 热点分离、VarHandle 替代 Unsafe
架构差异x86 的 LOCK CMPXCHG 开销固定;ARM 的 LL/SC 低竞争更优
选型原则低竞争用 CAS,高竞争测一把再决定,别迷信无锁

CAS 不是完美的——它在低竞争时极快,高竞争时可能不如轻量锁。理解它的原理和边界,才能在并发编程中做出正确的技术选型。

参考资料

  • Intel 64 and IA-32 Architectures SDM — CMPXCHG 指令
  • 《Java 并发编程的艺术》方腾飞
  • java.util.concurrent.atomic.LongAdder 源码
  • java.lang.invoke.VarHandle Javadoc
  • AQS 源码:java.util.concurrent.locks.AbstractQueuedSynchronizer

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。