Skip to content
Java 锁实现与并发细节 · 第 2 篇 / 共 4 篇
领域编程与框架
专题Java 专题
当前序列Java 锁实现与并发细节
阅读位置第 2 篇 / 共 4 篇当前专题第 6 个序列 / 共 10 个序列

CAS 的 ABA 问题怎么理解

CAS 很容易被理解成一句口号: “比较一下,没变就更新”。
真正麻烦的地方不是 CAS 本身,而是它只能看到“当前值”,看不到“值在这个过程中是否被别人改动过”。

ABA 问题正是这个盲点的典型体现。

先说结论

  • CAS 适合做单变量的原子更新,不适合承载复杂业务状态协同
  • ABA 的本质是“结果没变,但过程已经被别人动过了”
  • 如果业务关心“过程是否发生过变化”,就不能只比较值,通常要引入版本号、时间戳或状态机
  • AtomicStampedReference 能解决一类 ABA 问题,但不是所有并发问题都该用它

CAS 先别神化

CAS 的完整语义是:

  1. 读取当前值
  2. 拿旧值和内存中的值比较
  3. 如果相同,就更新成新值
  4. 如果不同,就重试

伪代码可以写成:

java
while (true) {
    int current = value.get();
    int next = current + 1;
    if (value.compareAndSet(current, next)) {
        break;
    }
}

它的优点很明确:

  • 不需要阻塞挂起线程
  • 低竞争场景下吞吐不错
  • 很适合计数器、状态位、游标推进这类简单原子更新

但它的前提同样明确:

  • 只能对一个变量的当前值做判断
  • 无法天然表达“中间是否发生过变化”

ABA 问题到底是什么

假设线程 T1 读取到变量值为 A,准备把它改成 C

这时线程 T2 先把 A 改成 B,又把 B 改回 A
等 T1 再来做 CAS 时,看到值还是 A,于是 CAS 成功。

从 T1 视角看,值“没变”;
但从系统真实过程看,这个值已经经历过一次变化。

这就是 ABA:

  • A -> B -> A
  • 结果看似一样
  • 过程已经不同

为什么有些场景不怕 ABA

不是所有 CAS 都要解决 ABA。

例如一个纯计数器,只关心当前值能否从 n 更新到 n + 1,并不关心值中间有没有被别人改过又改回来。
这种场景,ABA 并不会带来语义错误。

所以判断重点不是“有没有 ABA”,而是:

  • 业务是否关心这段期间对象是否被动过
  • 当前值是否只是数值,还是代表某个资源身份或对象引用

真正危险的场景

1. 无锁栈、无锁链表

节点引用可能被弹出后又重新挂回去,CAS 只看到“头指针还是同一个地址”,却不知道中间结构已经变了。

2. 状态机推进

比如订单状态从“待支付”到“已取消”再补偿回“待支付”,
此时你不能因为值又回到“待支付”就认为过程没发生过。

3. 资源复用

对象池、连接池、缓存槽位这些场景里,“同一个引用值”不一定代表“同一个语义状态”。

怎么解决 ABA

1. 加版本号

最常见的方法是把“值”和“版本”一起比较。
只要版本号变了,即使值一样,也认为状态发生过变化。

这正是 AtomicStampedReference 的思路。

java
AtomicStampedReference<String> ref =
    new AtomicStampedReference<>("A", 1);

更新时需要同时比较:

  • 当前引用值
  • 当前 stamp

只要 stamp 不一致,CAS 就失败。

2. 加标记位

如果不需要递增版本,只关心“是否被改动过”,也可以用 AtomicMarkableReference

3. 上升一个抽象层

很多业务系统里,最稳的做法并不是引入更复杂的 Atomic 类,而是:

  • 数据库行版本号
  • 乐观锁字段
  • 明确的状态机
  • 幂等表或去重表

也就是说,把并发语义交给更适合承载业务一致性的模型。

工程上怎么选

场景一: 计数器、自增序号、热点统计

优先考虑 AtomicLongLongAdder
这类场景一般不需要关心 ABA。

场景二: 状态位切换

如果只是 INIT -> RUNNING -> STOPPED 这种单变量状态切换,可以用 CAS,
但要确保状态转换图是闭合且受控的。

场景三: 引用对象、链表结构、可回收节点

要重点防 ABA,必要时上版本号或换数据结构。

常见误区

1. 以为 CAS 一定比加锁高级

CAS 只是另一种同步手段,不是性能万能药。
高竞争下反复自旋同样会浪费 CPU。

2. 用 Atomic 类硬做多字段一致性

CAS 最适合单变量。
一旦你想同时维护多个字段的一致性,往往就已经超出它的舒适区了。

3. 只知道 AtomicStampedReference,不知道为什么要用

很多人背得出类名,却没想清楚业务是不是“真的关心过程被改动过”。
没有这个判断,方案很容易过度设计。

一个实用判断

当你准备上 CAS 时,先问自己:

  1. 我更新的是一个数值,还是一个有身份语义的对象/状态
  2. 我关心最终值,还是关心中间是否被改动过
  3. 失败重试的成本高不高,竞争激烈时会不会空转

如果你关心的是“过程发生过变化”,那就不要只比较值。

总结

ABA 问题不是 CAS 的冷门细节,而是它能力边界的直接体现。
CAS 能看见“现在是什么”,但看不见“刚才经历过什么”。

所以真正的重点不是会不会背 AtomicStampedReference,而是知道什么时候只比较值已经不够用了。

延伸阅读相关文章优先当前专题,再补跨专题关联。
同一序列 · 顺着当前主线继续读AtomicLong 和 LongAdder 怎么选适合把 synchronized、CAS、LongAdder 和并发容器的适用边界放到一起看。Java 专题 · Java 锁实现与并发细节同一序列 · 顺着当前主线继续读CopyOnWriteArrayList 什么时候值得用适合把 synchronized、CAS、LongAdder 和并发容器的适用边界放到一起看。Java 专题 · Java 锁实现与并发细节同专题其他序列 · 共享标签:并发线程池参数怎么配适合把线程池参数、拒绝策略、CompletableFuture 和 ForkJoin 放在一起连续看。Java 专题 · Java 线程池与异步编排同专题其他序列 · 共享标签:并发false sharing 为什么会拖慢并发程序适合把 happens-before、volatile、原子类和阶段式同步器放在一条并发语义主线上理解。Java 专题 · Java 并发语义与同步器细节跨专题关联 · 同场景:基础学习autocommit 为什么也会影响锁行为适合把 next-key lock、一致性读、复制延迟和分库分表边界放回同一条一致性主线上判断。MySQL 专题 · MySQL 事务、锁与高可用边界跨专题关联 · 同场景:基础学习@Conditional 系列注解怎么配合使用适合把自动装配、条件装配、Profile 和循环依赖放回 Spring Boot 启动过程里理解。Spring 专题 · Spring Boot 启动、装配与配置
继续阅读Java 锁实现与并发细节当前序列第 2 篇 / 共 4 篇当前专题第 6 个序列 / 共 10 个序列
往前看
上一篇synchronized 为什么仍然是 Java 并发里的默认首选回到当前序列上一章上一序列JVM 与进程排障从第 1 篇开始:JVM 内存问题怎么分析
往后看
下一篇AtomicLong 和 LongAdder 怎么选继续当前序列下一章下一序列Java 类加载与运行时细节从第 1 篇开始:双亲委派模型到底解决了什么

把零散经验整理成可查、可复用、可持续更新的企业级知识门户