CAS 原理与 ABA 问题
从一段代码说起
public class Counter {
private int count = 0;
public void increment() {
count++; // 这不是原子操作
}
}count++ 在字节码层面是三条指令:getfield → iconst_1 → iadd → putfield。两个线程同时执行,结果可能不是 +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 能力:
public final native boolean compareAndSwapInt(
Object o, long offset, int expected, int x
);参数说明:
o:对象实例offset:字段在对象内存中的偏移量(通过Unsafe.objectFieldOffset获取)expected:预期值x:新值
AtomicInteger 的 incrementAndGet 就是用 Unsafe CAS 实现的:
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], rcxlock 前缀锁住总线(或缓存行),保证多核 CPU 间的原子性。ARM 架构则使用 LL/SC(Load-Linked / Store-Conditional)指令对实现,如 LDREX / STREX。
两种架构的性能差异很大:x86 的 LOCK CMPXCHG 成本相对固定(约 10-20 个 CPU 周期),而 ARM 的 LL/SC 在低竞争场景下更高效,高竞争时可能频繁失败。
| 架构 | 指令 | 高竞争表现 | 低竞争表现 |
|---|---|---|---|
| x86 | LOCK CMPXCHG | 稳定(约 15-25 周期) | 稳定(约 15-25 周期) |
| ARM | LDREX/STREX | 频繁失败重试,可能 100+ 周期 | 低至 5-10 周期 |
二、CAS 的应用场景
2.1 原子变量类
java.util.concurrent.atomic 包下的所有类都是 CAS 的封装:
| 类 | 说明 | 典型场景 |
|---|---|---|
AtomicInteger / AtomicLong | 原子整数/长整数 | 计数器、序列号生成 |
AtomicBoolean | 原子布尔值 | 开关标志、一次性初始化 |
AtomicReference<V> | 原子引用 | 无锁数据结构头节点 |
AtomicIntegerArray / AtomicLongArray | 原子数组 | 统计分桶 |
AtomicReferenceFieldUpdater | 原子更新对象字段 | 减少对象内存开销(替代包装类) |
2.2 AQS 的骨架
AbstractQueuedSynchronizer 的 compareAndSetState、compareAndSetHead、compareAndSetTail 全部依赖 CAS。
// AQS 中 CAS 更新尾节点
private final boolean compareAndSetTail(Node expect, Node update) {
return unsafe.compareAndSwapObject(this, tailOffset, expect, update);
}2.3 ConcurrentHashMap 的桶写入
ConcurrentHashMap 1.8 在桶为空时,直接用 CAS 写入头节点,完全不需要加锁:
if (tabAt(tab, i) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // 无锁完成
}三、ABA 问题
问题描述
ABA 是 CAS 的经典陷阱:
- 线程 1 读到变量 V = A
- 线程 2 将 V 从 A 改为 B,再改回 A
- 线程 1 执行 CAS,发现 V 还是 A,CAS 成功
问题在于:V 的值虽然看起来没变,但中间状态已经变了。如果 V 是某个共享数据结构的头指针,中间状态可能导致数据结构已被破坏。
现实中的 ABA 例子
一个简单的栈操作:
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 准备弹出 A:读到
oldTop = A,newTop = B - 线程 1 被挂起
- 线程 2 弹出 A(top → B),再弹出 B(top → C),然后重新压入 A(top → A)
- 线程 1 恢复,CAS 发现 top 还是 A,成功更新 top 为 B
此时 top 指向 B,但 B 已经被线程 2 弹出并回收了——栈中出现了悬空引用,整个数据结构损坏。
解决方案:AtomicStampedReference
AtomicStampedReference 内部维护 [reference, stamp] 配对,每次修改时 stamp 递增:
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 时间花在缓存同步上,吞吐反而比锁更低。
// 高竞争场景下,这个自旋循环可能运行数千次
for (;;) {
if (compareAndSet(current, next))
return next;
}实测数据(8 核机器,64 线程并发):
| 方案 | 1000 万次操作耗时 | CPU 利用率 |
|---|---|---|
synchronized | 420ms | 680% |
AtomicLong | 2100ms | 780% |
LongAdder | 380ms | 520% |
可以看到,在高竞争下 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
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 倍。
代价:LongAdder 的 sum() 不是精确值(Cell 数组在求和时可能还在被修改),适合读少写多场景。如果你需要精确的读,或者读写比例接近,AtomicLong 更合适。
五、从 Unsafe 到 VarHandle
JDK 9 开始,sun.misc.Unsafe 被标记为弃用,官方推荐使用 java.lang.invoke.VarHandle:
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.VarHandleJavadoc- AQS 源码:
java.util.concurrent.locks.AbstractQueuedSynchronizer