ABA 问题
CAS 会导致“ABA问题”。
CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。
比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但是不代表这个过程就是没有问题的。
部分乐观锁的实现是通过版本号(version)的方式来解决 ABA 问题,乐观锁每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行 +1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题,因为版本号只会增加不会减少。
四段描述完整给出了问题与版本号解法,但还差两块实战内容:ABA 在什么数据结构下才真正致命(普通计数场景其实无害),以及 Java 提供的两种 stamped 工具的正确用法与代价。
何时 ABA 才致命:无锁结构的节点复用
对普通值变量(计数器、余额),A→B→A 后值确实回到 A,CAS 成功通常无害——值语义上”没变过”。
真正致命的是无锁栈/链表/队列这类用 CAS 维护指针的结构:
无锁栈顶: A → B |
线程 1 的”预期 A”成立,但 A 背后的关联状态(next 指针、节点内容)已被改过又改回——CAS 只校验了值,没校验”历史”。这就是 ABA 的本质:值相同 ≠ 状态未变。
解法一:版本号(Stamped)
给每次修改附加单调递增版本,CAS 时同时比较”值+版本”:
AtomicStampedReference<V>:持 (reference, int stamp),compareAndSet(expectedRef, newRef, expectedStamp, newStamp)AtomicMarkableReference<V>:只持一个 boolean 标记(”是否被动过”),适用于只关心”有没有变过”而不关心次数
AtomicStampedReference<Node> top = new AtomicStampedReference<>(head, 0); |
代价:stamp 与引用两次读取之间仍非原子(需先 getStamp 再 getReference 或反之,存在微小窗口);且 stamp 回绕(int 溢出)理论上可复现 ABA——实践中配合节点不回收(无池化复用)基本免疫。
解法二:让节点不可复用(GC 帮忙)
Java 有 GC 的隐藏红利:无锁结构若不为节点做池化复用,回收的节点地址不会再被”同一个对象”占用,引用比较意义上的 ABA 概率大降(对象身份唯一)。这也是 JDK 并发包中无锁队列(ConcurrentLinkedQueue)不特意防 ABA 的原因:节点一次性使用,CAS 指针时旧节点不可能”复活”成预期值。
对比 C/C++ 无手动 GC 环境:节点内存回收后可能被新节点复用,ABA 是真实高频风险,必须 hazard pointer / epoch 回收等机制配合——ABA 的严重程度与内存管理模型强相关。
解法三:业务层版本号(数据库乐观锁)
UPDATE t SET status='DONE', version=version+1 WHERE id=7 AND version=3; |
影响行数 0 即 ABA/并发修改发生,业务重试或报错。version 单调递增不可回退,从根上消灭 A→B→A 的”伪装未变”。这也是上一篇文章业务同构性的延伸。
判断清单:我的场景需要防 ABA 吗
- 纯值语义(计数、金额、开关)→ 不需要(值回到 A 即语义未变)
- 无锁指针结构 + 节点池化复用 → 必须(StampedReference 或禁池化)
- Java 无锁结构 + 节点一次性分配 → GC 已兜底,通常不需要
- 业务状态机(订单状态流转)→ 数据库 version 或状态前置条件校验(UPDATE … WHERE status=’PAID’ 本身就是防 ABA)
ABA 问题的教学价值大于事故频率:它揭示了 CAS 的校验边界——只验当前值,不验历史。所有解法(版本号、标记、不可复用、状态前置条件)都是把”历史是否变过”这一信息重新纳入校验。抓住这一点,防 ABA 的手段就能按场景自行推导。











