Java 并发中的 CAS 和 ABA 问题怎么解决?
CAS 本质是调用 CPU 原子比较并交换指令,Java 通过 Unsafe 或 VarHandle 暴露;ABA 指值从 A 变 B 又变回 A 而 CAS 无法察觉,标准解法是用 AtomicStampedReference 加版本号,
结论:CAS 是 CPU 提供的一条原子比较并交换指令,Java 通过 sun.misc.Unsafe(JDK 9 起推荐 java.lang.invoke.VarHandle)暴露出来,AtomicInteger、AtomicReference 这类类都建立在它之上;ABA 问题的标准解法是给引用额外绑一个「版本号」,用 AtomicStampedReference 的 compareAndSet(expectedRef, newRef, expectedStamp, newStamp) 把「值没变」升级为「值和版本都没变」,其次是不复用对象(每次 new 新对象)或直接加锁。
CAS 在 Java 里到底是怎么实现的?
结论:Java 的 CAS 本质是调用 CPU 的原子指令,x86 上是 lock cmpxchg,ARM 上是 LDREX/STREX 或 CAS 指令,Java 层只做了一层封装。
调用链是:AtomicInteger.compareAndSet(expect, update) → Unsafe.compareAndSwapInt(this, valueOffset, expect, update) → JVM 的 Unsafe_CompareAndSwapInt → 平台相关的原子指令。它有三个参数:内存地址(对象 + 字段偏移量 valueOffset)、期望值、新值;地址上的当前值等于期望值才写入新值并返回 true,否则什么都不做返回 false。
所以 CAS 本身不阻塞、不加锁,失败方需要自己在循环里重试,这也是所有无锁结构(ConcurrentLinkedQueue、AtomicInteger 自增等)的实现套路。JDK 9 之后,官方推荐用 VarHandle 替代 Unsafe,例如 MethodHandles.lookup().findVarHandle(...).compareAndSet(...),语义一致但更安全。
ABA 问题是怎么发生的?
结论:ABA 指的是「值从 A 变成 B 又变回 A」,CAS 只比较值,看不出中间发生过变化,于是误判成「没人动过」并写入成功。
典型场景是自旋:内存里是 1,线程 T2 把它改成 2 再改回 1,线程 T1 拿着旧值 1 做 compareAndSet(1, 3),因为当前值确实是 1,CAS 成功。如果程序只关心最终数值,这没问题;但如果是无锁栈、无锁链表这种靠引用关系维持结构的场景,就可能丢节点或读到已经被移除的结点。
复现 ABA 的经典结构是 Treiber stack 的 pop:先读出栈顶 old 和 old.next,再 CAS 把栈顶换成 next。如果中间别的线程把 old 和 next 都弹走又复用节点把 old 推回来,CAS 会成功,但 next 早已不在栈里,栈结构就断了。关键前提是「节点被回收复用」——如果每次 push 都 new 一个新节点,引用不会重复出现,ABA 就没有危害。
解决 ABA 问题有哪几种做法?
结论:优先用 AtomicStampedReference 加版本号,只关心「改没改过」就用 AtomicMarkableReference,结构上能做到不复用对象就干脆不用它们,最后兜底是加锁。
第一种,版本号法:
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);
int[] stampHolder = new int[1];
Integer cur = ref.get(stampHolder);
int stamp = stampHolder[0];
ref.compareAndSet(100, 101, stamp, stamp + 1); // 值和版本号都要匹配
AtomicStampedReference 内部把「引用 + int 版本号」打包成一个不可变 Pair,每次更新版本号 +1,CAS 时值和戳记必须同时相等。注意 stamp 是 int,会在 21 亿次后回绕,实际业务量达不到,可以忽略。
第二种,标记法:AtomicMarkableReference<V> 只带一个 boolean 标记,适合「只关心是否被改过一次」的场景,比如无锁链表里的删除标记,compareAndSet(expectedRef, newRef, expectedMark, newMark)。它不做递增计数,防不住多次往返。
第三种,从设计上规避:不改可变对象的内部状态,而是每次修改生成新对象(值对象、不可变节点),旧引用永远不会再出现,ABA 从根上不存在。函数式数据结构和 AtomicReference 配合 ImmutableList 就是这套思路。
第四种,直接加锁:synchronized 或 ReentrantLock 把「读 + 判断 + 写」整体包住,代价是失去无锁的伸缩性,但对大多数业务代码是最省心的选择。
CAS 还有哪些必须知道的坑?
结论:CAS 除了 ABA,还有自旋开销、只能保证单个变量原子性两个硬伤。
自旋开销:高并发下 CAS 频繁失败,线程在循环里空转烧 CPU;AtomicLong 单点热点更明显。JDK 8 加入的 LongAdder 用 Cell 数组把竞争分散到多个槽位,sum() 时再汇总,写多读少的计数场景吞吐量明显高于 AtomicLong,代价是 sum() 拿到的不是某一瞬间的强一致值。
只能保证一个变量:要原子更新多个字段,要么用 AtomicReference 把整个不可变对象换掉,要么加锁。JDK 还提供 AtomicIntegerFieldUpdater、AtomicReferenceFieldUpdater 做字段级更新,但被更新的字段必须是 volatile 且不能是 private。
另外,所有 CAS 操作都带 volatile 的读写内存语义,写入后对其它线程可见,这一点不用再额外加 volatile 修饰。
总结一下:CAS 靠 CPU 原子指令实现,是无锁编程的地基;ABA 的根因是「只比值不比起历史」,标准解法是 AtomicStampedReference 绑版本号,弱化版是 AtomicMarkableReference 的布尔标记,最彻底的是不复用可变对象或直接加锁;同时别忘了用 LongAdder 化解自旋热点、用加锁或整体替换处理多字段原子性。
转载请注明出处,版权归原作者所有。