CAS 与原子类
CAS(Compare-And-Set)接收期望值和新值,原子地检查当前位置:匹配时写入,不匹配时保持原值。一次比较针对一个变量;业务计算可以发生在比较之前,提交时再确认计算依据是否仍然成立。
expected = 7,update = 8
├─ current = 7 → 写入 8 → 成功
└─ current = 9 → 保留 9 → 失败整数比较数值,AtomicReference 比较引用身份。两个内容相等但并非同一个对象的引用,也会比较失败。
比较结果与一次原子提交
从 witness 看清匹配和失败
compareAndSet 返回 boolean;compareAndExchange 返回比较时看到的值,称为 witness。下面的完整程序先给出错误期望值,再给出正确值:
import java.util.concurrent.atomic.AtomicInteger;
public final class CasWitnessDemo {
private CasWitnessDemo() { }
public static void main(String[] args) {
AtomicInteger state = new AtomicInteger(7);
int seen = state.compareAndExchange(6, 8);
if (seen != 7 || state.get() != 7) throw new AssertionError("mismatch");
System.out.printf("mismatch: witness=%d, state=%d%n", seen, state.get());
seen = state.compareAndExchange(7, 8);
if (seen != 7 || state.get() != 8) throw new AssertionError("match");
System.out.printf("match: witness=%d, state=%d%n", seen, state.get());
}
}在 Linux 普通用户可写目录保存为 CasWitnessDemo.java。使用完整 JDK,设置实际 JAVA_HOME;安装入口见Java 版本基线。示例在 Temurin 25.0.4+7 与 Java 17 上运行,编译目标为 Java 17。
export JAVA_HOME=/opt/jdk-25
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
LAB_OUT=$(mktemp -d /tmp/atomic-learning.XXXXXX)
"$JAVA_HOME/bin/java" -version
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror -d "$LAB_OUT" CasWitnessDemo.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" CasWitnessDemo预期输出:
mismatch: witness=7, state=7
match: witness=7, state=8两次 witness 都为 7,第一次没有写入,第二次将值改为 8。单线程使这里的后续 get 结果稳定;并发程序返回以后,其他线程可以再次更新,因此 witness 只表示比较瞬间的观察。具体方法契约见 AtomicInteger API。
如果编译器找不到 compareAndExchange,核对是否误用了早于 Java 9 的 JDK 或 release 目标。AssertionError 表示实验状态与预期不同,应保留输出并核对源码,不能继续把后续计数当作通过结果。
线性化点与重新计算
成功 CAS 生效的瞬间是该次条件更新的线性化点。强 CAS 失败表示比较时当前值与期望值不匹配;它没有记录读取以来的全部修改历史。
下面是依赖现有 AtomicInteger counter 的更新方法:
int increment(AtomicInteger counter) {
for (;;) {
int old = counter.get();
int next = Math.incrementExact(old);
if (counter.compareAndSet(old, next)) return next;
}
}两个线程都读到 7 时,都计算 8。一个先提交,另一个比较失败后重新读到 8、计算 9,再尝试提交。不能在失败后继续拿原来的 7 盲目重试。Math.incrementExact 让溢出显式失败;AtomicInteger 自带递增按 int 算术回绕,生成业务序号时要另行处理耗尽。
读 old → 计算 next → 比较并尝试写入
├─ 成功:返回提交结果
└─ 失败:重新读取并计算,或按调用契约返回冲突updateAndGet、getAndUpdate、accumulateAndGet 接收的函数可能被重复调用。函数中只做由输入快照决定的计算,不发送消息、不调用支付、不修改旁路集合。成功提交之后执行外部动作仍有“进程在两步之间退出”的缺口;需要可靠外部效果时,使用持久化事务和幂等协议,不能把内存 CAS 当成消息投递事务。
原子操作的范围与内存效果
常用对象怎样选择
| 对象 | 适合保护的状态 | 需要额外处理的关系 |
|---|---|---|
| AtomicBoolean / AtomicInteger / AtomicLong | 开关、精确计数、序号 | 多次操作之间的业务条件 |
| AtomicReference | 一个引用、不可变配置或状态快照 | 被引用对象内部的可变字段 |
| AtomicIntegerArray / AtomicLongArray / AtomicReferenceArray | 单个下标的原子访问 | 多个下标的整体一致性 |
| Atomic*FieldUpdater | 既有对象的指定 volatile 字段 | 字段访问权限与所有写入遵守协议 |
| AtomicStampedReference / AtomicMarkableReference | 引用与版本戳或布尔标记 | 戳生成、回绕、标记含义 |
| LongAdder / LongAccumulator | 高竞争累计统计 | 并发读取不是整体原子快照 |
这些类型的定位与内存访问定义见 atomic 包说明。数组并不使整组下标成为事务;字段更新器也不会自动接管绕开更新器的普通写入。
AtomicReference.set(newValue) 发布的是引用。若 newValue 发布后仍被无保护地修改,读者仍可能观察到数据竞争。不可变对象把初始化完成后的状态固定下来,使一次引用读取就能取得一致版本。
volatile、release/acquire 与弱 CAS
原子类的 get/set 使用 volatile 读写效果,lazySet/setRelease 使用 release 写。release 写与满足规则的 acquire 读取配合,可以发布此前写入;它不应被解释成“稍后一定刷新”的计时承诺。VarHandle 规范还区分 plain、opaque、acquire/release 和 volatile 访问。
plain 缺少跨线程排序保障;opaque 约束同一变量的访问一致性,但不负责发布其他字段。acquire/release 按读取与写入方向约束重排;volatile 提供更强的同步顺序。选择较弱模式时,需要写出究竟由哪个发布动作和哪个读取动作交接哪些字段。常见业务状态使用默认原子方法更容易维护。
compareAndSet、compareAndExchange 的默认形式具有 volatile 访问效果;带 Acquire 或 Release 后缀的交换只保证对应方向,失败时没有实际写入,不能凭一次失败建立发布动作。各模式应按具体方法的读、写效果分别判断。
weakCompareAndSet 系列允许伪失败,即值匹配时也可以返回 false。显式命名的 weakCompareAndSetPlain、Acquire、Release、Volatile 表达不同内存效果。旧的无后缀 weakCompareAndSet 因名称容易误导且只有 plain 效果而被弃用,不能把它当成 compareAndSet 的同义简写。弱 CAS 适合能够正确重试的算法;一次失败便作出业务拒绝的代码应另选契约。
用不可变快照提交多个字段
余额与版本需要同时改变时,把它们放进同一份不可变记录。下面是可运行实验包中的完整 Account:
import java.util.concurrent.atomic.AtomicReference;
public final class Account {
public record State(long balance, long version) { }
private final AtomicReference<State> state;
public Account(long balance) {
if (balance < 0) throw new IllegalArgumentException("balance");
state = new AtomicReference<>(new State(balance, 0));
}
public State debit(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount");
for (;;) {
State old = state.get();
if (old.balance() < amount) throw new IllegalStateException("insufficient");
State next = new State(old.balance() - amount, Math.incrementExact(old.version()));
if (state.compareAndSet(old, next)) return next;
}
}
public State snapshot() { return state.get(); }
}debit 从一次 state.get 得到余额与版本,检查金额后创建新记录。只有引用仍为 old 才提交;竞争失败后重新检查余额,因而不会拿失效余额继续扣减。snapshot 返回同一记录的两个字段,避免分别读取产生“新余额、旧版本”。
AtomicReference API规定引用比较使用身份。这里每次成功都产生新对象,旧引用不会被重新发布;如果应用把旧快照放回去,就需要重新考虑历史与版本。
金额用整数单位表示,构造时禁止负余额,扣减禁止非正金额。版本使用 incrementExact,达到 long 上限时显式拒绝更新;这些限制属于状态迁移的一部分。若业务状态复杂、分配成本高或更新函数很昂贵,使用同一把锁维护多个字段可能更直接。
下载 CAS 实验包,解压进入 cas-atomics 目录,在前面的终端继续执行:
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror -d "$LAB_OUT" *.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" AtomicsLab其中账户断言输出为:
balance=70 version=1 invalid-debits-unchanged=true成功扣减 30 后,负金额和透支都被拒绝,且拒绝没有修改状态。这个 Account 由一个应用组件持有,在其调用方法内完成内存更新;多实例账户需要数据库条件更新或事务。调用方若收到冲突、超时或网络错误,还应区分实际持久化结果,不能根据本地引用推断另一实例的余额。
ABA 要比较哪些历史
同一个引用可以被移除,再重新放回。只比较当前位置的算法可能接受这次变化:
时刻 读取者保存的快照 共享引用与版本
T0 (A, 0) (A, 0)
T1 (B, 1)
T2 (A, 2)
T3 尝试提交
只比较 A → 可成功
比较 (A, 0) → 失败对只关心“当前计数是 7”的操作,7→8→7 可能完全合法。无锁栈则还会依据旧头节点保存 next;头节点经过移除、其他节点弹出、再次入栈后,旧 next 可能已不再代表当前结构。是否有 ABA 缺陷,取决于提交的前置条件是否包含这段历史。
AtomicStampedReference将引用和 int stamp 一起比较。用 get(int[]) 同时读取一对值,避免分别调用 getReference/getStamp 拼接不同瞬间;所有相关修改都必须遵守戳更新规则。stamp 会回绕,不能把有限整数戳当作永不重复的身份。AtomicMarkableReference 只有一位标记,适合逻辑删除等状态,表达不了任意修改次数。
AtomicsLab 按固定顺序执行 A→B→A,再尝试旧快照提交,预期:
plain-accepted=true stamped-rejected=true这是明确构造的历史,不依赖线程调度碰运气。Java GC 能维持仍可达对象的存活,却不会禁止程序把同一个对象重新插入容器。组件若复用可变节点,还需要独立审查对象生命周期;业务代码优先采用成熟容器,避免自行拼接无锁链表。
热点计数与 Striped64
分散写入后如何读取
LongAdder 在竞争下把更新分散到多个位置,sum 读取基础值与各个 cell 后累加。写入不再全部争同一个数值,但并发 sum 的各部分来自不同读取时刻。LongAdder API明确它适合统计,sum 不是原子快照;写入全部结束后,汇总才可用作完整结果断言。
因此,可以用 LongAdder 记录请求次数,不能用“sum 小于库存就 decrement”的组合完成严格库存扣减。sumThenReset 与并发更新也不构成隔离的统计窗口;精确账期应在更新停止或有明确窗口切换协议后结算。
LongAccumulator 使用自定义函数与初始值聚合。函数需要适合重新计算,运算顺序不保证;通常要求结合性,若要求与顺序无关,还需交换性。max、min 适合该模式,减法或依赖前一事件次序的业务规则不适合。函数契约见 LongAccumulator API。
base、cells 与扩展协调
固定 OpenJDK jdk-25+36 的 Striped64 实现包含 base、cells 与 cellsBusy。低竞争时尝试更新 base;出现冲突后,按线程探针选 cell。目标 cell 为空或持续冲突时,可能创建 cell、调整探针或扩展数组;cellsBusy 协调结构调整,避免多个线程同时改变数组。
cell 数受实现条件与可用处理器数量约束,并不随每个新线程无限增长。sum 要跨位置读取,因此写扩散与读汇总是同一个设计的两面。缓存行布局和填充也影响伪共享成本,不能仅从源码中的对象个数推导某台机器的性能。
重试成本、进展与诊断
AtomicsLab 的八个线程各提交 50000 次更新。每个线程在本地统计尝试次数,主线程 join 全部结束后汇总,输出形态为:
value=400000 attempts=<本次尝试数> failures=<尝试数减400000>这里 attempts - value 是该轮被计数 CAS 的精确失败次数:每次尝试只计一次,且求和发生在所有工作线程结束后。数值随调度变化,允许失败数为 0。把统计放进另一个共享热点会改变原实验,因此没有让所有线程再争一个全局 attempts 原子变量。
这只验证计数与重试关系,不能据此给 AtomicLong 和 LongAdder 排速度名次。微基准需控制预热、编译、起跑、结果消费、线程数与读写比例;可使用 OpenJDK JMH建立测试,再以真实服务负载核对尾延迟。
lock-free 描述系统整体能持续取得进展,仍可能有单个线程反复失败;wait-free 对每个操作要求有限步骤内完成。一次原子方法和包含分配、回调、日志的完整业务方法,也属于不同层级。Java 原子包并不对所有平台承诺相同的底层非阻塞实现,不能从“未写 synchronized”推断整个算法的进展保证。
| 观察 | 进一步区分 | 调整与验证 |
|---|---|---|
| CPU 增长而成功提交量停滞 | 重试次数、热点 key、更新计算成本 | 分片或缩短计算;比较相同负载下每次成功尝试数 |
| 少量线程长期完成不了 | 每操作重试尾部、取消响应 | 有限重试后返回冲突,或统一改用可等待的同步协议 |
| LongAdder 汇总暂时偏低 | 是否仍有并发写、是否并发 reset | 停止写入后复核,检查统计窗口 |
| 多字段偶发不一致 | 是否分别读取或修改多个原子对象 | 整体快照或共同锁,再做并发不变量测试 |
| 引用回到旧值后误提交 | 对象复用与版本规则 | 禁止旧实例回放或比较版本,运行 ABA 负例 |
不要让一部分写者用 CAS、另一部分失败后随意改拿一把锁写同一状态:没有共同协议时,这把锁约束不了 CAS 写者。Thread.onSpinWait 是自旋提示,不保证公平,也不提供退出机制。需要取消的循环应主动检查中断或截止预算;返回失败前必须保证尚未提交。
CPU 热点可由采样分析确认,具体采集见并发诊断。如果热点是同一个全局计数,分片和统计聚合常比继续调退避更有价值;如果必须全局精确,就应以提交容量安排入口并发和背压。
完整运行与清理
在解压目录设置 JAVA_HOME 后运行 bash run.sh,脚本严格编译并运行两项实验,每个 Java 进程有 20 秒外部上限,最后输出 PASS atomics-lab。Linux 主机有 Docker 权限时,也可使用非 root、无网络的容器执行:
LAB_SOURCE="$(pwd -P)"
JDK_IMAGE=eclipse-temurin:25.0.4_7-jdk
docker pull "$JDK_IMAGE"
docker run --pull never --rm --network none --read-only \
--user 10001:10001 --cap-drop ALL --security-opt no-new-privileges \
--mount "type=bind,src=$LAB_SOURCE,dst=/lab,readonly" \
--tmpfs /tmp:rw,nosuid,nodev,size=128m,mode=1777 \
"$JDK_IMAGE" bash /lab/run.shUID 10001 需要读取源码目录,编译写入 /tmp。内网环境提前由可信联网端准备并校验镜像归档后导入;运行不下载依赖。Java 17 对照镜像为 eclipse-temurin:17.0.20_8-jdk。超时退出码 124 要结合线程输出调查,不能当成测试通过。
手工编译目录可在实验进程结束后回收:
case "$LAB_OUT" in
/tmp/atomic-learning.*) rm -r -- "$LAB_OUT" ;;
*) printf '%s\n' '保留未知目录' ;;
esac
unset LAB_OUT权威资料与规范地址
按方法语义查 API,按实现字段查固定版本源码。
| 资料 | 完整地址 |
|---|---|
| AtomicInteger API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/AtomicInteger.html |
| atomic 包说明 | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/package-summary.html |
| VarHandle 规范 | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/invoke/VarHandle.html |
| AtomicReference API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/AtomicReference.html |
| AtomicStampedReference | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/AtomicStampedReference.html |
| LongAdder API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/LongAdder.html |
| LongAccumulator API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/LongAccumulator.html |
| OpenJDK jdk-25+36 的 Striped64 实现 | https://github.com/openjdk/jdk/blob/jdk-25%2B36/src/java.base/share/classes/java/util/concurrent/atomic/Striped64.java |
| OpenJDK JMH | https://github.com/openjdk/jmh |
