面试官追问"ABA 具体怎么解决",我卡住了
12 月中的一次面试。聊到 CAS,我说"它有个 ABA 问题",面试官问"什么场景下真的会遇到 ABA?怎么解决?"
我答"加版本号",他又追了一句"JDK 里加版本号的那个类,你知道它的 compareAndSet 是怎么实现的吗?"
答不上来。回去把 java.util.concurrent.atomic 这个包翻了一遍,还写了几个 demo 跑了一遍。这篇是复盘。
CAS 到底做了什么
CAS 是 Compare And Swap 的缩写,三个操作数:内存位置 V、预期原值 A、新值 B。当且仅当 V 的值等于 A 时,才把 V 改成 B,否则什么都不做。整个操作是原子的。
JDK 里通过 Unsafe 类暴露出来:
public final class Unsafe {
public final native boolean compareAndSwapObject(Object o, long offset,
Object expected, Object x);
public final native boolean compareAndSwapInt(Object o, long offset,
int expected, int x);
public final native boolean compareAndSwapLong(Object o, long offset,
long expected, long x);
}
这四个参数的意思是:对象 o、字段在对象里的偏移量 offset、期望的旧值 expected、要写入的新值 x。之所以要传 offset 而不是字段名,是因为 JVM 要直接算出内存地址——native 代码里是这么算的:地址 = 对象基址 + offset。
最终的 native 实现(HotSpot,x86)是一条 CPU 指令 cmpxchg,多核环境下带 lock 前缀:
// hotspot/src/os_cpu/linux_x86/vm/atomic_linux_x86.inline.hpp
inline jint Atomic::cmpxchg(jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::is_MP(); // 是否多核
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}
LOCK_IF_MP 是个宏,多核时展开成 lock 前缀。这个前缀的作用是锁定总线(早期实现)或者锁定对应的缓存行(现代 CPU 用缓存一致性协议 MESI 实现),保证这条指令的原子性。
看 AtomicInteger 的用法:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
}
而 getAndAddInt 是个 CAS 自旋:
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = this.getIntVolatile(o, offset); // 读当前值
} while (!this.compareAndSwapInt(o, offset, v, v + delta));
// ↑ CAS 失败(期间被别的线程改了)就重读重试
return v;
}
注意那个 volatile int value。CAS 必须配合 volatile 才能保证可见性——读的时候能看到其他线程的最新写入,否则 CAS 会比较一个过期的值,逻辑上就错了。
ABA 是什么,什么时候真的会发生
场景:线程 1 读到值是 A,准备改成 B。这期间线程 2 把它改成 B,又改回 A。线程 1 执行 CAS,发现值还是 A,判断"没人改过",于是修改成功。
教科书上的说法到这里就结束了。但面试官问的是"什么场景真的会遇到"——这是关键,因为大部分业务场景下 ABA 是无害的。
比如计数器 incrementAndGet:值从 5 变成 6 又变回 5,中间发生了什么不重要,最终结果正确就行。这种场景不需要处理 ABA。
真正会出问题的是值相同但"对象"已经不是同一个的情况。我写了个无锁栈的 demo,这是并发教材里的经典例子:
public class LockFreeStack<E> {
private final AtomicReference<Node<E>> head = new AtomicReference<>();
private static class Node<E> {
final E item;
Node<E> next;
Node(E item) { this.item = item; }
}
public void push(E item) {
Node<E> newHead = new Node<>(item);
Node<E> oldHead;
do {
oldHead = head.get();
newHead.next = oldHead;
} while (!head.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = head.get();
if (oldHead == null) return null;
newHead = oldHead.next;
// 问题在这里:CAS 只比较了引用地址是否等于 oldHead
} while (!head.compareAndSet(oldHead, newHead));
return oldHead.item;
}
}
推演一下出错的时序。假设栈里是 A → B → C(A 是栈顶):
- 线程 1 调
pop,读到oldHead = A,newHead = B,然后被挂起。 - 线程 2 调
pop,弹出 A,栈变成B → C。 - 线程 2 调
pop,弹出 B,栈变成C。 - 线程 2 调
push(A')。这里的关键:如果 push 的是一个新创建的对象,地址不同,不会出问题。但如果用了对象池复用 A 这个节点(比如为了 GC 友好复用 Entry 对象),那新栈顶又是 A,且A.next = C。 - 线程 1 恢复,执行
head.compareAndSet(A, B)。它看到 head 确实是 A,CAS 成功,head 变成 B。 - 但 B 已经被弹出去了,它的状态是脏的,而且 C 丢了。栈结构损坏。
这个例子说明 ABA 的必要条件:值被改回原样,且"原样"背后代表的状态已经变了。没有对象复用的话,引用地址不会相等,所以纯 Java 代码里 ABA 相对少见(不像 C/C++ 手动管理内存那么容易触发)。
换成更业务的例子:账户余额。余额从 100 变 50(扣款),又变 100(退款),再扣 50。如果有个"余额不能超过初始值"的校验逻辑用 CAS 实现,那它看到 100 就认为"没动过",会漏判中间的操作。这种场景才真的需要处理。
AtomicStampedReference:加版本号
JDK 的解法是给引用绑一个 int 类型的版本号(stamp),CAS 时同时比较引用和版本号。
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference;
final int stamp;
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair; // 引用和版本号被绑成一个不可变对象
public AtomicStampedReference(V initialRef, int initialStamp) {
pair = Pair.of(initialRef, initialStamp);
}
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference && // 用 == 比较引用!
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}
}
设计很巧妙:用一个不可变的 Pair 把引用和版本号绑在一起,对 pair 这个字段做 CAS。因为 Pair 是不可变的,每次修改都 new 一个,就相当于"引用 + 版本号"这个组合是原子的。
那个 expectedReference == current.reference 用的是 == 而不是 equals。这是个陷阱:如果你传的 expectedReference 是内容相同但地址不同的对象,即使版本号对上了,CAS 也会失败。我第一次用的时候没注意,写了个 new String("abc") 当 expected,怎么都改不动,排查了半小时。
用 AtomicStampedReference 重写那个栈:
public class LockFreeStackWithStamp<E> {
private final AtomicStampedReference<Node<E>> head;
public LockFreeStackWithStamp() {
head = new AtomicStampedReference<>(null, 0);
}
public void push(E item) {
Node<E> newHead = new Node<>(item);
int[] stampHolder = new int[1];
Node<E> oldHead;
do {
oldHead = head.get(stampHolder); // 用数组传出版本号,Java 没有引用传递
newHead.next = oldHead;
} while (!head.compareAndSet(oldHead, newHead,
stampHolder[0], stampHolder[0] + 1));
}
public E pop() {
int[] stampHolder = new int[1];
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = head.get(stampHolder);
if (oldHead == null) return null;
newHead = oldHead.next;
} while (!head.compareAndSet(oldHead, newHead,
stampHolder[0], stampHolder[0] + 1));
return oldHead.item;
}
}
注意那个 int[] stampHolder 的写法。Java 没有引用传递,AtomicStampedReference.get() 只有一个返回值,所以版本号必须通过数组或者自定义对象带出来。这个 API 设计得确实别扭,但没办法。
每次操作版本号 +1,所以 A → B → A 的变化会让版本号从 0 变成 2,线程 1 拿着版本号 0 去 CAS 就会失败。
int 版本号会溢出吗?会。溢出后从 Integer.MAX_VALUE 变成 Integer.MIN_VALUE,理论上可能撞上。但要在同一次 CAS 的窗口内绕完 42 亿次循环,概率极低。真不放心可以用 AtomicMarkableReference,它用一个 boolean 而不是 int,只记录"有没有被改过"。
public class AtomicMarkableReference<V> {
public boolean compareAndSet(V expectedReference, V newReference,
boolean expectedMark, boolean newMark);
}
它解决的是"值是否被修改过",不解决"被修改过几次"。开销更小,语义也更弱。
CAS 的另外两个问题
面试官问的三个问题里,ABA 只是第一个。还有两个我后来也补上了。
自旋开销
CAS 失败就重试,如果竞争很激烈,线程会一直空转,CPU 白烧。看一组实测数据,20 个线程各做 100 万次自增:
| 实现 | 耗时 | 吞吐 | CAS 平均重试次数 | CPU 占用 |
|---|---|---|---|---|
synchronized | 4.81 s | 4.16 M ops/s | — | 约 400% |
AtomicLong | 2.94 s | 6.80 M ops/s | 3.7 | 约 780% |
LongAdder | 0.62 s | 32.3 M ops/s | — | 约 820% |
20 线程下 AtomicLong 比 synchronized 快 1.6 倍。但注意 CPU 占用:AtomicLong 是 780%,synchronized 只有 400%。因为 synchronized 阻塞的线程会让出 CPU,而 CAS 自旋的线程一直在烧 CPU。如果机器 CPU 资源紧张,自旋反而更糟。
而且竞争越激烈,CAS 的成功率越低。我测过 100 线程时的 AtomicLong,平均重试次数涨到 47 次,耗时变成 18.2 秒,反而比 synchronized 的 9.1 秒慢一倍。所以别迷信"CAS 一定比锁快",要看竞争程度。判断标准是临界区执行时间:临界区短(几纳秒到几十纳秒)用 CAS,临界区长(微秒以上)用锁,让线程阻塞比空转划算。
高竞争场景下真正的答案是 LongAdder(JDK 8 引入)。它的思路是分散热点:内部维护一个 Cell[] 数组,每个线程更新自己对应的 Cell,最后求和。所以它是"最终一致"的,sum() 在并发更新时拿到的不是精确值。适合做计数器、监控指标,不适合做需要精确读的场景(比如余额)。
只能保证一个变量的原子操作
CAS 一次只能原子地改一个变量。要同时改两个,要么加锁,要么把它们包成一个对象再用 AtomicReference:
static class AccountState {
final BigDecimal balance;
final long version;
AccountState(BigDecimal balance, long version) {
this.balance = balance;
this.version = version;
}
}
private final AtomicReference<AccountState> state =
new AtomicReference<>(new AccountState(BigDecimal.ZERO, 0));
public void deposit(BigDecimal amount) {
AccountState oldState, newState;
do {
oldState = state.get();
newState = new AccountState(
oldState.balance.add(amount),
oldState.version + 1);
} while (!state.compareAndSet(oldState, newState));
}
这个写法其实就是 AtomicStampedReference 的手动版。好处是可以带任意多的字段,坏处是每次修改都要 new 一个对象,GC 压力略大。我们订单状态机用过这个模式,QPS 3000 左右,YGC 从每分钟 2.1 次涨到 3.4 次,可以接受。
什么时候该处理 ABA
我的结论:
- 纯计数、累加、序列号生成:不用管。值变回原样对结果没影响。
- 无锁数据结构(栈、队列、链表):如果节点对象会被复用(对象池),必须处理,用
AtomicStampedReference。 - 状态机、余额类有业务含义的值:看业务。如果"回到原值"意味着中间发生过别的业务动作,那就要处理,通常的做法是加一个单调递增的 version 字段(数据库乐观锁也是这个思路)。
- 我们项目里的实际情况:用到原子类的地方全是计数器和开关标志,没有一处需要处理 ABA。这也是为什么我一开始答不上"什么场景会遇到"——确实没遇到过。
顺便说,数据库的乐观锁(UPDATE ... WHERE version = ?)本质上也是 CAS + 版本号,和 AtomicStampedReference 是同一套思路。想通了这一点,这两个东西就串起来了。
写在后面
现在回头看,《原子类与 CAS:ABA 问题及解决方案》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。