CAS、ABA 与原子类:原子操作与进度边界
版本基线:API 契约以 Java SE 17 为准,实现分析固定到 OpenJDK 17u
jdk-17.0.12+7。本机实际编译运行版本为 Oracle JDK 17.0.12+8(x86_64)。happens-before、volatile/final 语义与进度三级口径的权威在JMM 正文;互斥锁与公平性归锁正文,AQS 与 Condition 归AQS 正文;并发容器归容器写侧契约。本页讲 compare-and-set 的契约、ABA、原子类族谱与其进度代价。
CAS 的契约:一次要么完成、要么不发生的读-改-写
Compare-and-set 的语义:当且仅当当前值仍等于期望值时,原子地写入新值并返回成功;否则什么都不做并返回失败。失败不是异常,是协议的一部分——它告诉调用方"你的观察已过期,重读再试"。JDK 文档对失败返回的措辞:
原码连续摘录(仅统一公共缩进):AtomicInteger.java,jdk-17.0.12+7,L125–L137。
/**
* Atomically sets the value to {@code newValue}
* if the current value {@code == expectedValue},
* with memory effects as specified by {@link VarHandle#compareAndSet}.
*
* @param expectedValue the expected value
* @param newValue the new value
* @return {@code true} if successful. False return indicates that
* the actual value was not equal to the expected value.
*/
public final boolean compareAndSet(int expectedValue, int newValue) {
return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
}典型重试循环把"读、计算、CAS"组成一个原子读-改-写:
int observed;
int next;
do {
observed = value.get(); // 每轮重读,不能用旧观察
next = observed + 1;
} while (!value.compareAndSet(observed, next));两条工程边界:期望值必须来自本轮读取——实验第 5 节的版本戳栈第一次实跑就是用陈旧观察的戳去做 CAS 而失败(AssertionError stamp=2),修正为每次 CAS 前重读后才通过;重试有成本——竞争越激烈,失败线程重复读与计算越多,缓存一致性流量越高。8 线程对同一 AtomicInteger 各做 5 万次递增,实测 40 万次成功伴随 33.9 万次失败重试(观察值):总量精确,个体无上界。这是 lock-free 的本义(见JMM 正文的进度分级),不是缺陷,但它是容量规划输入:热点计数在多核下的有效吞吐会随竞争劣化。
weak CAS:允许偶发失败与 plain 内存效应
JDK 9 起 weakCompareAndSet 被废弃,原因是命名撒谎:
原码连续摘录(仅统一公共缩进):AtomicInteger.java,jdk-17.0.12+7,L141–L158。
* if the current value {@code == expectedValue},
* with memory effects as specified by {@link VarHandle#weakCompareAndSetPlain}.
*
* @deprecated This method has plain memory effects but the method
* name implies volatile memory effects (see methods such as
* {@link #compareAndExchange} and {@link #compareAndSet}). To avoid
* confusion over plain or volatile memory effects it is recommended that
* the method {@link #weakCompareAndSetPlain} be used instead.
*
* @param expectedValue the expected value
* @param newValue the new value
* @return {@code true} if successful
* @see #weakCompareAndSetPlain
*/
@Deprecated(since="9")
public final boolean weakCompareAndSet(int expectedValue, int newValue) {
return U.weakCompareAndSetIntPlain(this, VALUE, expectedValue, newValue);
}读法分三步:
- 可能偶发失败:即使当前值等于期望值,weak CAS 也允许返回 false(部分平台省略内存屏障的代价)。
- plain 内存效应:成功时不建立 volatile 写读的排序保证;它只更新值,不承诺发布链。
- 可用的姿势:在重试循环里当"便宜的快路径"——失败就重读重试,最终一致即可。实验第 6 节用
weakCompareAndSetPlain循环双线程各 1 万次递增,总量精确 20000;循环里不能依赖它发布其他数据,那需要 volatile 级 CAS 或显式屏障。
内部实现大量使用 weak 形态(如 ConcurrentLinkedQueue 的尾指针推进,TAIL.weakCompareAndSet,失败"没关系")——判断能否用 weak 的标准是:失败是否可检测且可重试。判断失败的副作用是否为零,是把它用在业务状态机前必须回答的问题。
ABA:值相等不等于状态未变
CAS 只比较当前值。状态从 A 变成 B 再回到 A,旧观察者的 CAS 仍会成功——它以为"没变过",实际上中间发生了完整的两次变更。对 AtomicInteger 这只是误成功;对用引用做指针的无锁结构,节点复用会直接破坏结构。实验第 5 节的无锁栈交错:
栈: A -> B
T1: 读 top=a,记住 next=b (暂停)
T2: pop A(栈变 b);pop B(栈变空);把 a 推回(栈变 a,a.next=null)
T1: 恢复,CAS(top: a -> b) 成功 (当前 top 确实还是 a)
结果: 栈 = b,b 是已被移除的节点;a 丢失
实测 drained=[B]:已删除的 B 复活、在栈的 A 消失修复是把"值相等"升级为"值+版本都没变"。AtomicStampedReference 的比较实现:
原码连续摘录(仅统一公共缩进,截取方法局部):AtomicStampedReference.java,jdk-17.0.12+7,L146–L157。
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,一次 CAS 同时替换两者;同一交错下 T1 的旧版本(戳 0)CAS 失败,实测栈保持 [A]、戳推进到 3,结构完好。边界与替代:
- 版本回绕:int 戳溢出后回到旧值,检测失效;极高变更频率下要评估或用足够宽的计数。
- 原子性范围:戳只保护它配对的引用;一次改多个关联字段需要不可变状态对象整体替换,而不是多个原子变量。
- 替代方案:不可变节点 + 整体 CAS(无中间态可回退)、对象身份比较(每次新节点,天然无复用)、或干脆退回锁。
AtomicMarkableReference是"一位布尔戳"的特例(典型用法:逻辑删除标记)。
计数器族谱:AtomicLong、LongAdder 与频率统计
单一 AtomicLong 是热点:所有核的 CAS 打在同一地址。LongAdder 把增量分散到多个 Cell,读时求和。写路径:
原码连续摘录(仅统一公共缩进,截取方法局部):LongAdder.java,jdk-17.0.12+7,L85–L95。
public void add(long x) {
Cell[] cs; long b, v; int m; Cell c;
if ((cs = cells) != null || !casBase(b = base, b + x)) {
int index = getProbe();
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[index & m]) == null ||
!(uncontended = c.cas(v = c.value, v + x)))
longAccumulate(x, null, uncontended, index);
}
}无竞争时先打 base(单 CAS);检测到竞争才扩散到按线程探测值散列的 Cell,Cell 上的 CAS 再失败则进入 longAccumulate 扩容/重试。读路径的代价在文档里写得非常直白:
原码连续摘录(仅统一公共缩进,截取方法局部):LongAdder.java,jdk-17.0.12+7,L111–L127。
/**
* Returns the current sum. The returned value is <em>NOT</em> an
* atomic snapshot; invocation in the absence of concurrent
* updates returns an accurate result, but concurrent updates that
* occur while the sum is being calculated might not be
* incorporated.
*
* @return the sum
*/
public long sum() {
Cell[] cs = cells;
long sum = base;
if (cs != null) {
for (Cell c : cs)
if (c != null)
sum += c.value;
}sum() 遍历 base 加全部 Cell:这是一个非原子的多单元读,遍历期间并发更新可能被计入也可能被漏计。选型口径:
| 场景 | 选择 | 理由 |
|---|---|---|
| 余额、库存、限流闸门 | 不用 LongAdder | 需要精确线性化读,sum 非快照 |
| 监控计数、QPS 统计 | LongAdder | 高并发写、读频率低且容忍近似 |
| 单变量状态机(版本、序号) | AtomicLong/CAS 循环 | 需要读-改-写原子与全序 |
| 分键频率统计 | ConcurrentHashMap<K, LongAdder> + computeIfAbsent | 单键原子建计数器;跨键聚合仍非事务(见容器正文) |
原子类只保证单个变量单次操作原子。多变量不变量(扣余额同时写流水)需要锁、数据库事务或"不可变状态对象 + 单引用 CAS"的状态机,不能靠两个 AtomicLong 拼出来。
竞争时间线:栈复用与计数热点
| 时间 | T1 | T2 | 栈状态 |
|---|---|---|---|
| t1 | 读 top=a,记 next=b | — | A→B |
| t2 | 暂停 | pop A、pop B、push 回 a | a(a.next=null;B 已移除) |
| t3 | CAS(top: a→b) 成功 | — | b:已删除节点复活 |
| t4 | — | — | 结构损坏:丢失 a、复活 b |
| 时间 | 计数路径 | 观察 |
|---|---|---|
| t1 | T1 读 value=0,算 next=1 | — |
| t2 | T2 读 value=0,算 next=1 | 两线程持同一旧观察 |
| t3 | T1 CAS(0→1) 成功 | — |
| t4 | T2 CAS(0→1) 失败,重读重试 | 失败是协议,不是异常 |
| t5 | T2 CAS(1→2) 成功 | 总量精确;T2 多花一轮 |
设计案例:高并发频控计数(算例,非实测)
假设网关为每个 API key 计滑动窗口调用次数:5 万个活跃 key,峰值 20 万 QPS 分摊到 8 核,每分钟读一次计数做告警,超限即拒绝。以下为假设算例。
三个方案:
ConcurrentHashMap<String, AtomicLong>:每 key 一个热点。若流量集中在头部 key(Top-10 占 60%),单 key 的 CAS 争用近似把 8 核串行化成 1 核的有效吞吐,头部 key 理论上 screaming;拒绝判定读是精确线性化读,可直接做闸门。ConcurrentHashMap<String, LongAdder>:写路径分散,头部 key 不再是单点;但窗口判定读 sum 是近似值——用它做"硬闸门"会把并发窗口内的少量超额放进或错杀,只能做"软阈值+告警",硬闸门需另设精确计数或令牌桶(见并发综合案例的令牌桶原子性推导)。- 集中式(Redis)计数:精确且跨实例,但每请求一次网络往返,20 万 QPS 下的延迟与可用性代价转嫁给远端;适合多实例硬配额,不适合单机热点统计。
成本与恢复:方案 1/2 均为进程内状态,重启清零——对"限流窗口"要明确重启后的宽限策略;key 空间无限增长需要淘汰(TTL/LRU),把责任写清。验证方式:用真实键分布压测头部 key 的 CAS 失败率与拒绝判定的误差分布,而不是只测均匀键。
条件变化与边界
| 条件变化 | 结论如何变化 |
|---|---|
把 compareAndSet 换成 weakCompareAndSetPlain | 失败概率上升、内存效应降为 plain;只可用于失败可重试且无发布依赖的循环 |
| 竞争消失(单线程) | LongAdder 退化为 base 单 CAS;两种实现等价,选型依据消失 |
| 把 sum() 用于余额 | 违反契约:非原子快照;并发更新期间读到的是估计值 |
| 版本戳用尽(回绕) | 检测失效,回到裸 ABA;极高频率需评估计数宽度或改不可变对象 |
| CAS 目标从单 int 变多字段 | 单 CAS 不再够用;换不可变状态对象整体 CAS 或锁 |
| 核数增加 | 热点 AtomicLong 的重试率与缓存一致性流量上升;分散结构的收益随之放大(按架构实测,不外推) |
实验:输入、步骤、预期与边界
公开入口(仓库根):
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home \
bash labs/java-senior/run.sh jmm探针为 labs/java-senior/JmmProbe.java。本节相关输出(2026-10-24,Oracle JDK 17.0.12+8,macOS 26.7.1 x86_64):
== 4. progress: CAS retry cost and dispersed counting ==
AtomicInteger CAS loop total=400000 casFailures=338821(重试为观察值:无个体时延上界)
LongAdder total=400000(增量分散到 Cell;sum() 非原子快照,仅在无并发更新时精确)
== 5. ABA: node identity reuse corrupts a lock-free stack ==
plain stack: stale CAS(top=a -> saved next=b) succeeded=true
plain stack after stale CAS: [B](A 丢失、已移除的 B 复活——引用回到旧值但状态已变)
stamped stack: stale CAS with stamp 0 succeeded=false(版本 0->3,旧版本拒绝)
stamped stack intact: top=A next=null stamp=3
== 6. weakCompareAndSetPlain retry loop and lazySet release ==
weakCompareAndSetPlain loop total=20000(允许偶发失败,重试循环仍收敛)
lazySet(1) -> volatile get(): payload observed=42(release 写与 acquire 读配对,断言通过)断言口径:ABA 两版本都是确定性交错(闩锁固定暂停点),断言裸引用版必然损坏为 [B]、版本戳版必然拒绝;CAS 总量、weak 循环总量、release-acquire 配对都是规范保证,断言;失败重试次数是负载观察值,只记录不断言。历史对照:labs/concurrency 探针的 AtomicInteger expected=0 cas=true final=2 与 AtomicStampedReference cas=false 是同一 ABA 结论的整型版本,本轮用 Oracle JDK 17.0.12 复跑仍一致(复核记录见实测证据)。
边界:栈反例是手工固定的交错,不是任意调度证明;不覆盖 VarHandle 全部访问模式、AtomicIntegerFieldUpdater、LongAccumulator(正文未展开即不宣称);失败次数不可跨核数/架构外推。
追问链
第一层:CAS 失败了要不要抛异常重试?
失败是返回值不是异常:它表示"观察过期"。正确动作是重读再算再试;把失败当错误吞掉或固定重试 N 次后放弃,都要写清放弃后的状态语义(放弃的是这一次更新,不是一致性)。
第二层:为什么内部实现敢用 weak CAS,业务代码常不敢?
内部使用处(如 CLQ 尾指针推进)失败可检测、可重试、且无外部副作用发布依赖;业务状态机往往一次 CAS 失败会留下半更新的关联状态。判断标准是"失败路径是否零副作用且可收敛",不是"内部也这么写"。
第三层:计数从 AtomicLong 换 LongAdder 后监控读数变"毛刺",怎么办?
先确认用途:监控容忍近似,毛刺是分散单元求和非快照的本性,不是 bug;若这个数被拿去做硬闸门(余额/限流拒绝),换回精确计数或引入令牌桶/数据库条件更新。修复方向取决于消费方语义,不取决于写路径性能。
第四层:设计无锁结构时按什么顺序决策?
先问状态可否整体不可变替换(避免多字段半更新);再问引用复用是否可能(ABA 面);再决定版本戳/身份/锁;最后才优化 CAS 强度(weak/volatile)。跳过前两步直接上 CAS 是事故的常见起点。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java SE 17 AtomicInteger API | Java SE 17 | compareAndSet 语义、weak 形态与内存效应、lazySet |
| Java SE 17 AtomicStampedReference API | Java SE 17 | 引用+版本戳原子比较 |
| Java SE 17 AtomicMarkableReference API | Java SE 17 | 单布尔标记变体 |
| Java SE 17 LongAdder API | Java SE 17 | 分散计数、sum 非原子快照、与 CHM 组合做频率统计 |
| Java SE 17 VarHandle | Java SE 17 | compareAndSet/weakCompareAndSetPlain 的内存效应定义 |
| OpenJDK 17u AtomicInteger | jdk-17.0.12+7 | compareAndSet(L125–L137)、weakCompareAndSet 废弃注记(L141–L158) |
| OpenJDK 17u AtomicStampedReference | jdk-17.0.12+7 | Pair 引用+戳的一次 CAS(L146–L157) |
| OpenJDK 17u LongAdder | jdk-17.0.12+7 | add 的 base/Cell 分支(L85–L95)、sum 非快照(L112–L126) |
本地 JmmProbe 实验 | Oracle JDK 17.0.12+8 | CAS 重试成本、栈版 ABA、weak CAS、lazySet 实测输出 |
访问日期:2026-10-24。源码行号以 jdk-17.0.12+7 标签为准;运行环境为 Oracle JDK 17.0.12+8。重试次数、耗时类数字全部是单轮观察值,不是基准测试。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。