CAS 与原子类:无锁更新为什么仍会竞争
把 volatile int 换成 AtomicInteger,丢失更新消失了,于是很容易得到一个过度结论:CAS 没有锁,所以一定更快。线上高峰却可能出现另一个画面——数十个线程围绕同一个原子变量不断重试,CPU 飙升,真正完成的更新并没有同比增长。没有阻塞队列不等于没有竞争,CAS 只是把“等待锁”换成了“失败后决定是否重试”。
原子类最适合小而独立的状态:序号、引用替换、一次性标志、热点统计。它不自动保护跨字段业务不变量,也不替代数据库事务。CAS 循环、内存语义、ABA 与 LongAdder 只有放回“锁、原子更新、不可变快照”的工程取舍中,才能解释正确性和容量代价。
从一次比较并交换开始
一个原子递增可以粗略理解为:读取旧值,计算新值,只有当前值仍等于旧值时才写入,否则重来。
int increment(AtomicInteger counter) {
for (;;) {
int current = counter.get();
int next = Math.addExact(current, 1);
if (counter.compareAndSet(current, next)) {
return next;
}
}
}compareAndSet 把“比较当前值并在匹配时写入”作为一个原子动作。失败表示在读取和提交之间有别的线程改过值,不表示 JVM 出错。调用者必须决定:立即重试、退避、转锁、返回冲突,还是放弃操作。
计算函数会在竞争下重复执行,因此不能在 CAS 更新函数里发送消息、扣款或写日志:
state.updateAndGet(old -> {
chargeAccount(); // 错误:函数可能执行多次
return old.next();
});更新函数必须无副作用且可重复计算。需要外部副作用时,先原子提交状态,再用幂等事件或事务边界驱动后续动作。
原子类提供的不只是“不可分割”
AtomicInteger.get()、set()、compareAndSet() 的内存效果通过 VarHandle 语义定义。常用原子类的普通 get/set 具有 volatile 读写效果,成功的 CAS 同时完成比较、写入并建立相应可见性。
lazySet 对应 release 语义,适合只要求此前写入在后续合适的 acquire/volatile 读取中可见、但不要求立即按 volatile 写处理的底层场景。应用代码如果不能准确画出配对读取和允许的结果,就不要仅为性能猜测改用更弱语义。
VarHandle 进一步暴露 plain、opaque、acquire、release、volatile 与多种原子 read-modify-write 操作。它适合 JDK、框架和高性能组件实现,不是业务字段更新的默认入口。弱语义少一层约束,也多一层证明责任。
CAS 只能保护它比较的那份状态
账户状态同时包含余额与版本:
AtomicInteger balance = new AtomicInteger(100);
AtomicInteger version = new AtomicInteger(7);分别原子更新两个字段,仍可能让读取者看到“新余额 + 旧版本”。原子性作用在单个变量上,不会跨两个原子对象自动组成事务。
一个 JVM 内可以把相关字段放进不可变对象并整体替换:
record AccountState(int balance, int version) {
AccountState debit(int amount) {
if (balance < amount) throw new IllegalStateException("insufficient");
return new AccountState(balance - amount, version + 1);
}
}
AtomicReference<AccountState> state =
new AtomicReference<>(new AccountState(100, 7));
AccountState debit(int amount) {
return state.updateAndGet(old -> old.debit(amount));
}这样 CAS 比较的是整份引用,多个字段一起迁移。但它仍只约束当前进程;多实例共享账户必须回到数据库条件更新、事务、幂等和审计。
ABA:值回来了,历史却变了
线程 A 读取状态 A,暂停;线程 B 把它改成 B,又改回 A;线程 A 的 CAS 只比较当前值,可能认为“没人改过”。若算法只关心当前值,ABA 不一定是问题;若节点生命周期、版本或所有权变化有意义,就必须识别中间历史。
可以把版本一起比较:
AtomicStampedReference<Node> head =
new AtomicStampedReference<>(initial, 0);
int[] stamp = new int[1];
Node current = head.get(stamp);
boolean changed = head.compareAndSet(current, next, stamp[0], stamp[0] + 1);AtomicMarkableReference 适合只需要一个布尔标记的场景。更常见的业务解法是显式版本字段和条件更新,而不是把内存 ABA 工具搬到跨服务数据上。
线性化点不等于一次方法调用只发一条指令
对 compareAndSet(expect, update),成功的线性化点是条件更新生效的瞬间;失败的线性化点是它观察到值不匹配的瞬间。方法前后的普通计算可以重试,只有成功提交决定共享状态从旧值切到新值。因此 CAS 循环必须满足两个条件:新值能从刚读到的快照重新计算,循环体在成功前没有不可重复的外部副作用。
compareAndExchange 比只返回布尔值多给出一个 witness,即比较时实际观察到的值。仓库实验先故意使用错误期望值,再使用正确期望值:
cd examples/backend-development/concurrency/cas-atomics
javac --release 17 -Xlint:all -Werror CasWitnessDemo.java
java CasWitnessDemo第一次 witness 为 7 而状态仍为 7,调用者不用再做一次独立 get 就知道比较为何失败;第二次 witness 仍为 7,但状态已经提交成 8。witness 只是比较时的观察,不是返回后永远有效的快照,另一个线程可以立即再次修改状态。
weakCompareAndSet 允许伪失败,适合已经处在重试循环里的算法;它不能直接替代“失败就报错”的一次性业务判断。无论强弱 CAS,都应暴露重试次数分布。平均值会掩盖少数线程长期输掉竞争,更有用的是单次操作重试的 p95/p99、单一 key 失败率和成功吞吐。若 CPU 上升、重试尾部拉长而成功提交量不变,系统是在做一致性流量,不是在做业务。
ABA 不是数值巧合,而是比较假设被历史破坏
假设无锁栈的头引用先是 A,线程 1 读取 A 及其 next=B 后暂停;线程 2 弹出 A、弹出 B,又把 A 压回。线程 1 再比较头引用时仍看到 A,CAS 可以成功把头改成它早先保存的 B,但 B 已经不再属于原来的栈结构。问题不是 A 这个引用“不能重复出现”,而是算法把“头仍为 A”错误等同于“从读快照以来结构没有发生影响我的变化”。
版本戳把比较域从 reference 扩成 (reference, stamp),A→B→A 后引用相同而 stamp 已改变。不可变节点、延迟回收或 hazard pointer/epoch 一类回收策略则从对象生命周期侧避免旧引用被过早复用。Java 有 GC 不代表 ABA 自动消失:GC 防止对象地址在仍可达时被释放,却不能阻止业务把同一个节点对象移除后再次放回,也不能替多字段状态建立版本。
选择方案前要先写出 CAS 的真实前置条件。如果只要求“当前计数仍为 7”,数值回到 7 可能完全合法;如果要求“订单从我读取以来从未经过关闭再重开”,就必须比较版本或把状态迁移交给锁/单一所有者。不是所有 CAS 都有 ABA bug,但每个 CAS 都应说明它忽略历史为何安全。
LongAdder 用空间和瞬时一致性换热点吞吐
所有线程更新同一个 AtomicLong,高竞争时会围绕一个位置反复 CAS。LongAdder 在竞争出现后把更新分散到多个 cell,读取 sum() 时再汇总。
LongAdder requestCount = new LongAdder();
void recordRequest() {
requestCount.increment();
}
long snapshot() {
return requestCount.sum();
}它很适合请求次数、命中次数、拒绝次数等统计;不适合余额、库存和序列号,因为并发中的 sum() 是瞬时汇总,不是一个全局原子快照,sumThenReset() 也不是与所有更新隔离的事务。
选择时可以这样判断:
| 需求 | 更合适的工具 | 关键理由 |
|---|---|---|
| 精确序号、状态位 | AtomicLong/AtomicReference | 每次更新需要单一线性化点 |
| 高竞争监控计数 | LongAdder | 分散写热点,允许瞬时汇总 |
| 多字段不变量 | 锁或不可变对象整体 CAS | 状态必须一起迁移 |
| 外部数据库状态 | 条件 SQL、版本号、事务 | JVM 原子类不跨实例 |
Striped64 如何把一个热点拆成多个冲突域
LongAdder 的 OpenJDK 实现基于 Striped64:低竞争时先更新基础值;发生冲突后,线程按探针选择 cell,冲突持续时尝试创建或扩展 cells,求和再读取 base 与各 cell。扩展减少多个写者争同一缓存位置的概率,却使 sum() 成为跨多个位置的瞬时聚合。扩容过程本身也需要协调,而且 cell 数不会无限追随瞬时线程数。
因此比较 AtomicLong 与 LongAdder 不能只跑一次取最小耗时。基准必须同时启动写线程、消费结果防止消除、分开记录预热与测量,并验证最终计数;结果只适用于该 CPU、JDK、线程数和读写比例。即使 LongAdder 在热点计数上更快,也不能替代余额、序列号或限额扣减,因为这些决策需要一次更新对应一个可立即读取的线性化值。
无锁、lock-free 与 wait-free 不要混说
应用开发常把“没写 synchronized”统称无锁。算法语义更严格:lock-free 通常表示系统整体持续有线程取得进展,但单个线程可能长期失败;wait-free 要求每个操作在有限步骤内完成。一个无限 CAS 重试循环可能让某个线程长期饥饿,不能因为使用原子类就宣称每个调用都有时延上界。
性能也不是单向结论。低竞争时 CAS 很便宜;高竞争、临界区较长或更新函数昂贵时,失败重试会浪费计算和缓存一致性流量。阻塞锁让失败者暂停,反而可能更稳定。应根据冲突率、成功吞吐、P99、CPU 和公平要求测试,而不是按“锁一定慢”选型。
把竞争做成可观测实验
一个有意义的实验同时统计成功与尝试次数:
AtomicInteger value = new AtomicInteger();
LongAdder attempts = new LongAdder();
int increment() {
for (;;) {
int current = value.get();
attempts.increment();
if (value.compareAndSet(current, current + 1)) {
return current + 1;
}
}
}八个线程同时更新后,attempts - value 近似反映 CAS 失败量。继续改变线程数与更新函数成本,观察:
成功吞吐是否继续增长。每次成功平均重试多少次。CPU 利用率与 P99 是否恶化。
加入轻量退避或换 LongAdder/锁后结果怎样。
不要用一个循环的 wall-clock 时间就宣布原子类胜过锁。JIT 预热、死代码消除、线程未真正同时起跑都会污染结果;严肃微基准使用 JMH,并对最终状态做正确性校验。
线上 CPU 高时怎样识别 CAS 热点
线程栈若多次停在 Atomic* 更新、Striped64、自定义 compare-and-set 循环附近,同时 CPU 高、业务成功吞吐不升,应怀疑热点竞争。JFR 或 async-profiler 能进一步确认热点方法;硬件计数器可在性能专项中分析缓存一致性成本。
先问状态为什么必须被所有线程写。按 key 分片、局部累计后批量合并、单线程所有者、队列串行化,往往比继续优化同一个 CAS 循环有效。若更新必须全局精确,就接受单一线性化点的容量上限,并设置背压。
原子更新的评审门槛
被原子保护的是单变量,还是隐藏着跨字段不变量?更新函数是否纯净,可被重复调用?CAS 失败后重试有无上限、退避或降级路径?
ABA 的中间历史是否影响正确性?读者需要精确快照,还是允许近似统计?状态是否跨 JVM;若跨实例,真正的提交点在哪里?
如何验证失败重试率、成功吞吐、P99 和 CPU,而不仅是平均速度?
同时运行线性化证据与热点竞争证据
完整源码位于 examples/backend-development/concurrency/cas-atomics/。CasWitnessDemo.java 展示 compare-and-exchange 的 witness 与一次成功提交;CasAtomicsDemo.java 用八个线程记录最终值、尝试次数和带版本 ABA 检测。进入目录执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out CasWitnessDemo.java CasAtomicsDemo.java
java -cp out CasWitnessDemo
java -cp out CasAtomicsDemo第一份稳定输出为:
mismatch: witness=7, state=7
match: witness=7, state=8
loop: from=8, to=9, retries=0第二份稳定满足 value=400000 与 abaDetected=true,attempts 会随调度和 CPU 改变;attempts - value 才是本轮竞争浪费的近似量。工程验收按相同 JDK、CPU 配额、线程数和更新函数比较成功吞吐、每次成功重试分布、p99 与 CPU。若重试尾部和 CPU 同时上升而成功量不变,应触发退避、分片、单一所有者或阻塞锁方案评估;不能给所有 CAS 循环套一个跨机器固定重试阈值。
这些问题回答不清时,简单锁通常比一个“看起来高级”的自旋状态机更安全。
可继续核对 java.util.concurrent.atomic 包说明、AtomicReference 和 VarHandle 的内存效果。
