WRITING / 2026.09.25

从 CAS 到原子类:无锁更新的能力与代价

用库存扣减和访问计数实验解释 CAS、ABA、原子引用与 LongAdder,区分原子更新、业务不变量和统计快照。

比价服务准备统计商家请求数时,用 AtomicInteger 修复了计数丢失。很容易因此产生一个直觉:只要把普通变量换成原子变量,就不必再考虑并发协调。库存场景很快会打破这个直觉:库存不能为负,已售数量要与库存一起变化,读取到的旧状态还可能经历过一轮修改。

本篇是“Java 并发工程”的第二篇。我们通过五个 Java 21 实验理解 CAS 的原子边界,说明什么时候适合原子数值、什么时候需要原子引用,以及统计组件为什么不能直接承担库存判定。

先看一个有条件的更新

CAS 可以理解为比较当前位置的值与期望值,匹配时原子地替换为新值,不匹配则失败。比较和替换构成一个操作。它比较的是调用者提供的期望值与目标变量,不是让程序手工比较“工作内存”和“物理主内存”。

AtomicInteger stock = new AtomicInteger(10);
boolean first = stock.compareAndSet(10, 9); // true
boolean stale = stock.compareAndSet(10, 8); // false

第一次更新把库存从十改成九,第二次仍带着十这个旧期望值,因此失败并保留九。失败不是发生异常,也不意味着系统损坏;它通常表示调用方需要重新读取状态,再决定重试还是返回业务冲突。

CAS 本身不会保证外层业务一定成功。调用者只尝试一次,就可能返回失败;调用者不断重试,才形成常见的更新循环。把 CAS 与自旋重试当成同一个概念,会混淆一次原子指令与围绕它建立的算法。

CAS 与版本检查:普通引用只比较当前身份,带版本的引用同时检查状态历史标记

查看完整 SVG 机制图

原子类不是一份统一性能承诺

Java 原子类提供具有明确内存效果的操作。以 AtomicInteger 的 compareAndSet 为例,它不仅完成有条件更新,也具有相应的可见性语义。不同的 plain、opaque、acquire、release 等访问模式有不同约束,不能只看到名字里有“原子”就把所有方法当成同一种同步。

入门实现可以先使用 get、set、compareAndSet 和常见的原子累加方法,等到确有优化需求,再结合官方文档分析较弱访问模式。用更复杂的 API 并不自动获得收益,却可能增加证明发布关系的难度。原子包文档列出了这些方法与内存效果的对应关系。

底层可能使用硬件原子指令,也可能受平台实现影响。业务文章可以说明比较交换的思想,但不应给所有 CPU 套用同一条汇编指令,更不应把“没有显式互斥锁”解释为“没有竞争成本”。

CAS 循环要重新计算候选状态

扣减库存时,读取旧值、判断是否还有库存、构造新值并尝试替换,必须形成完整循环。CAS 失败后,要从新的当前状态重新判断业务条件,不能继续拿旧结果反复提交。

如果循环里还会更新已售数,可以把二者放入同一个不可变状态对象。实验使用 record 表示库存与已售数量,再由 AtomicReference 原子替换整个引用。

record Stock(int remaining, int sold) {}
AtomicReference<Stock> stock =
    new AtomicReference<>(new Stock(4000, 0));

for (;;) {
    Stock old = stock.get();
    if (old.remaining() <= 0) throw new IllegalStateException("售罄");
    Stock next = new Stock(old.remaining() - 1, old.sold() + 1);
    if (stock.compareAndSet(old, next)) break;
}

两个字段随同一个引用切换,使读者可以拿到一个自洽的状态版本。这里依赖对象构造后不再变化。如果 AtomicReference 内放的是一个可变对象,而多个线程直接修改对象内部字段,那么原子引用并没有覆盖那些修改。

因此,“CAS 只能解决一个字段”也需要准确理解:一次操作针对一个原子位置,但这个位置可以保存不可变复合状态的引用。它能把多个业务字段一起发布,却不能自动处理跨数据库、跨进程或跨外部系统的事务。

重试中的副作用是另一类问题

更新函数可能因竞争被执行多次。如果在计算新状态时发送扣款请求、写业务日志或增加另一个计数器,CAS 最后虽然只成功一次,副作用却可能重复发生。候选状态计算应尽量保持纯粹,成功后的后续动作也要考虑失败恢复与幂等。

例如,成功扣减本地库存后发送消息,线程可能在消息发送前退出。这已经进入业务一致性问题,无法靠多加几个原子变量补齐。可以把“需要发送的事件”纳入受保护状态,再用合适的持久化协议处理,而不能把单进程原子性外推成可靠交付。

这也解释了为什么设计选择不只有“锁或无锁”。先减少共享状态、批量合并更新、让一个线程拥有某个状态,可能比把每个字段替换成 Atomic 类型更容易维护。

ABA:当前相同不等于从未变化

线程甲读取到引用 A,暂时离开。线程乙把引用改为 B,再改回同一个 A。甲回来后执行 compareAndSet,期望仍是 A,因此可以成功。CAS 正确判断了当前身份相同,却没有回答这段时间是否发生过变化。

实验故意控制这段顺序:先保存旧引用,让另一个线程完成 A 到 B 再到 A,然后执行旧期望值的 CAS。这里使用同一个 A 对象,而不是内容相等的新对象,因为 AtomicReference 比较的是引用身份,不是调用 equals 比较业务内容。

ABA 是否构成错误,取决于算法是否关心中间历史。单纯把状态改成某个值,可能并不关心;涉及节点复用、资源世代或状态版本时,就可能需要检测。不能把任何发生 A 到 B 再到 A 的代码都判定为有缺陷。

用版本标记表达历史要求

AtomicStampedReference 将引用与整数标记作为一对状态维护。读者应成对读取引用与标记,更新时同时提交两者的期望值。实验让引用回到 A,但标记从零变为二,旧标记的更新因此被拒绝。

版本标记的正确性依赖所有修改者遵守递增或更新协议。如果某条路径复用旧标记,检测就可能失效;整数也存在回绕范围。它提供了实现版本判断的工具,不是自动附带无限历史的对象管理器。

与数据库乐观版本号相比,两者思想相近,作用范围却不同。本篇实验只涉及一个 JVM 中的对象。数据库需要把版本判断与更新放进同一个数据库原子操作,分布式缓存和消息系统还要分别分析自己的条件更新语义。

AtomicLong 与 LongAdder 如何选择

访问统计可以把多个线程的累加压力分散到不同单元,再在读取时合并。LongAdder 面向这类高并发统计需求;它的 sum 在有并发更新时不是整个对象的原子快照。官方文档也明确区分统计用途与精细同步控制。LongAdder API

库存判定不能简单写成“sum 大于零就扣减”,因为检查与更新不构成一个原子决策。需要按当前值进行 CAS 或其他精确同步时,应选支持相应协议的工具。计数器跑得更快,也不代表它满足业务要求。

实验让四个线程各累加两万次,等待所有任务结束,再断言 LongAdder 总和为八万。此时没有并发修改,检查的是静止后的总数。我们没有把运行过程中的每次 sum 当成一致快照,也没有用一次耗时对比宣称它总比 AtomicLong 快。

五项实验与实际结果

下载源码包,阅读运行说明,执行:

python3 run.py --work /tmp/java-concurrency-run --group AtomicLab

本机 Java 21.0.1 的五项测试通过:普通 CAS 成功后拒绝陈旧值;普通引用 CAS 接受完成 A-B-A 后的相同身份;带标记的 CAS 拒绝旧版本;四千次库存扣减得到剩余零、已售四千;LongAdder 在任务结束后得到八万。完整记录见实测摘要。

ABA 示例中的线程协调用于固定历史顺序,不是对无锁链表的完整实现。库存实验验证单进程状态替换和最终不变量,没有模拟库存落库、外部支付、进程崩溃或恢复。读者可以依据这些边界判断代码哪些部分可借鉴,哪些仍需业务协议补充。

竞争成本与工程取舍

CAS 循环失败后通常需要重新读取、重新计算和再次争用同一位置。竞争激烈时,重试会消耗 CPU,并增加对象分配或缓存通信。某个线程持续失败时,系统整体在前进,也不代表每个线程都能及时完成自己的操作。

锁同样有开销,但可以把等待者挂起,避免持续重算昂贵逻辑。较长临界区、复杂不变量、需要中断或超时的等待,往往值得使用更直接的同步方案。选择之前应先确认竞争程度、临界区成本和延迟目标,再设计有代表性的测量。

本轮没有 JMH 基准和 jcstress 压力测试,所以只对上述机制给出结论。不要从实验中某次计数完成时间推断生产瓶颈;真实服务可能耗时最多的是网络、序列化或下游限流,而不是原子更新本身。

给库存循环补上业务失败语义

示例恰好发起四千次扣减,对应四千份库存,所以每次调用最终都成功。若增加第五千次请求,业务代码应返回明确的售罄结果,而不是把库存为零当成需要无限重试的 CAS 冲突。读取到合法但不满足业务条件的状态,与原子替换因并发修改而失败,是两类不同事件。

可以把每次扣减的结果分成成功、售罄和调用取消。CAS 冲突通常在内部重新读取,售罄则直接返回;如果循环允许取消,应在适当位置检查任务预算。是否限制重试次数还需要结合调用契约:过早放弃会改变成功率,无限尝试又不能保证个体请求及时完成。

读取状态时也要一次取得完整的 record。先 get 读取 remaining,再 get 读取 sold,可能观察到两个不同状态版本。每个字段值都合法,拼在一起却可能破坏总库存关系。这与第一篇的快照问题一致:原子替换提供状态版本,调用方仍需正确使用这个版本。

若业务只需要一个请求数量,可以使用计数器;若要维护库存与已售的守恒关系,就使用复合状态或锁;若要追踪订单是否已经扣减,还需要订单身份和幂等记录。组件的名字不能替代业务建模,测试也应对准这些具体不变量,而不是只验证某次 CAS 返回 true。

为什么弱 CAS 不能直接照搬普通循环外的判断

原子 API 中带 weak 的比较交换允许特定形式的失败,访问模式也有区别。应用若把一次失败直接解释成“另一个线程必然修改了库存”,可能得出过强结论。本系列使用 compareAndSet,以减少入门示例的证明负担;进一步使用弱操作时,需要逐个核对方法的成功、失败和内存语义,并在算法层面容纳相应行为。

面试归纳

解释 CAS 时,先说比较与替换的原子边界,再说失败后的业务策略。解释 ABA 时,要指出当前身份相同与历史未变化的区别,并说明版本标记的协议要求。解释 LongAdder 时,应先讲统计语义与快照限制,再讨论潜在竞争收益。

下一篇回到锁,先从 synchronized 的可观察语义建立正确模型,再进入对象布局和固定版本的 HotSpot 实现,避免把某张旧版锁升级图当成所有 Java 程序都必须遵循的规则。

参考资料

Java 并发工程

  1. 共享变量为什么会出错:JMM、happens-before 与 volatile
  2. 从 CAS 到原子类:无锁更新的能力与代价 · 当前文章
  3. 理解 synchronized:从互斥语义到 HotSpot 锁实现
  4. 线程如何等待与退出:中断、Condition 与 LockSupport
  5. 沿着 ReentrantLock 读懂 AQS:获取、排队与唤醒
  6. ThreadLocal 在线程池中的边界:上下文、串值与清理
  7. CompletableFuture 实战:并行聚合、异常与超时

按时间浏览