跳到主要内容
约 7 分钟3 张卡

CAS

CAS(Compare And Set)在当前值等于预期值时,原子地写入新值。它适合把状态变化表达为一次可重试的条件更新。本文以 Java 17/21 为准,先讲更新失败后怎样重试,再解释副作用、ABA 与版本标记。最后区分强弱 CAS 的内存语义,以及原子更新与外部动作的边界。

1. 面试核心​

先记住这几条,再读正文
  • 比较并交换。 CAS 在当前值等于预期值时原子写入新值,失败后由调用方重新读取并决定是否重试。
  • 重试与副作用。 updateAndGet 在竞争后会重算新值,所以更新函数应只计算结果,避免重复执行外部动作。卡 · 重试函数与副作用
  • ABA。 ABA 指状态从 A 变为 B 后又回到 A,单独比较当前值的 CAS 无法发现中间发生过变化。卡 · ABA 问题
  • 版本标记。 AtomicStampedReference 把引用与整数版本一起原子比较,每次变化都推进版本才能检测中间变化。
  • 弱 CAS。 weakCompareAndSetPlain 即使当前值与预期值相同也允许失败,并且只提供 plain 内存语义。卡 · 弱 CAS 与内存语义

2. CAS 的条件更新​

余额扣减需要同时满足两个要求:余额足够,以及扣减所依据的余额仍是当前值。CAS 把比较与写入合成一个原子操作,失败后再重新检查:

AtomicInteger balance = new AtomicInteger(100);

boolean withdraw(int amount) {
while (true) {
int current = balance.get();
if (current < amount) {
return false;
}
if (balance.compareAndSet(current, current - amount)) {
return true;
}
}
}

本例假设 amount > 0,初始余额非负。循环先读取余额,不足时返回 false。余额足够时,compareAndSet 检查余额是否仍为 current,相同才写入扣减后的值。

成功的 CAS 是本次扣减生效的时刻,也称为线性化点。强 CAS 失败表示比较时的值与预期值不同。因此,调用方要重新读取余额,并再次检查是否足够,避免用旧余额继续扣减。

多个变量分别执行 CAS 时,各次更新独立生效,无法自动组成事务。如果多个字段需要共同满足业务约束,可以把它们放进不可变对象,再原子替换整个引用。

多字段状态的实现见 Java 原子类。

3. 重试函数与副作用​

更新失败后,原子类会用新读到的值重新计算。放在更新函数中的外部动作也会随之重复,例如下面的审计请求:

value.updateAndGet(current -> {
auditClient.send(current); // 不安全:函数可能执行多次
return current + 1;
});

这段函数在一次更新中可以被调用多次,所以 auditClient.send 会重复发送审计请求。类似的日志写入、实际扣款和网络请求,都属于会影响外部状态的副作用。更新函数应只根据输入计算新值,CAS 成功后再执行这些动作。

外部动作还需处理提交边界,见原子更新与外部动作。

4. ABA 问题​

CAS 比较的是当前值。有些算法还需要知道:从读取到更新之间,状态是否发生过变化。ABA 就是当前值已经恢复,变化历史却被隐藏的情况。

可以按以下线程交错顺序理解:

  1. 线程甲读取引用 A,并保存根据 A 算出的待更新值,随后暂停。
  2. 线程乙把 A 替换为 B,再放回同一个 A。
  3. 线程甲恢复执行,只比较当前引用时,CAS 仍会成功。

如果待更新值依赖的是旧节点关系,ABA 就会影响正确性。比如,无锁栈的栈顶节点被移除后重新插入,后继已经变化。旧线程仍用先前保存的后继更新栈顶,就会破坏栈的链表结构。

普通计数从 10 变到 11,再回到 10,若算法只关心当前数值,这种变化可以接受。因此,是否处理 ABA,要看节点被移除、复用等历史是否影响算法的判断。

5. 版本号与 AtomicStampedReference​

需要检测中间变化时,可以把版本和值一起比较。AtomicStampedReference 为引用附带一个整数版本,每次更新同时检查这两个值:

AtomicStampedReference<Node> head =
new AtomicStampedReference<>(initial, 0);

int[] stamp = new int[1];
Node current = head.get(stamp);
boolean updated = head.compareAndSet(
current,
current.next(),
stamp[0],
stamp[0] + 1
);

本例假设 current 非空,节点的 next 读取安全。get(stamp) 同时读取引用和版本,CAS 再原子比较两者。成功时,引用变为后继节点,版本加一。如果引用回到了原值,但版本已经变化,旧 CAS 就会失败。

使用版本标记时,需要理解以下适用边界:

  • 每次状态变化都必须推进版本。业务自己维护版本号时,也要把版本与值一起原子更新。
  • 整数版本溢出后会回绕,即再次出现曾经使用过的数值。旧引用和旧版本重新同时出现时,单靠这次比较仍无法发现历史变化。
  • 节点复用和内存回收还要满足算法自己的约束。版本号只检测变化,完整数据结构仍需逐种检查线程交错。

对于因复用同一个节点产生的 ABA,还可以约束节点复用,或采用适合该算法的内存回收协议。

如果只需记录节点是否被逻辑删除,可以使用 AtomicMarkableReference。它携带一个布尔标记,适合表达“已删除”状态,表达能力与持续推进的版本号不同。

6. CAS 的进度保证​

CAS 更新失败时,线程可以重试,无需等待锁持有者释放锁。但高竞争下,一个线程会反复失败,而其他线程仍持续更新成功。也就是说,系统整体有进展与每个线程都能及时完成,是两种不同的保证。

lock-free 表示系统整体能持续取得进展,wait-free 则要求每个操作都在有界步数内完成。具备哪种保证,要根据完整算法判断,具体区别见 锁与无锁。

竞争明显时,可通过退避减少密集重试,也可以考虑分片、批处理或使用锁。退避指失败后暂缓下一次尝试,具体收益仍需基准测试和运行时采样验证。

分片与批处理怎样降低共享竞争,见 锁与无锁。

7. 弱 CAS 与内存语义​

选择 CAS 方法时,要同时区分失败条件和内存可见性。强 compareAndSet 在值匹配时执行原子更新,并具有 volatile 读写语义。这里的 volatile 语义用于建立线程间的可见性与顺序约束。

弱 CAS 允许虚假失败,也就是值匹配时仍返回 false。调用方要按算法要求处理这种失败,并决定是否重试。

weakCompareAndSetPlain 只提供 plain 内存语义。plain 指普通读写的内存访问模式,该操作的原子性只覆盖目标变量。若要让其他线程同时看到之前写入的普通字段,还需建立发布关系,例如使用 volatile 语义的 CAS。

Java 17/21 的原子类提供按内存模式命名的弱 CAS 方法。旧 weakCompareAndSet 使用 plain 语义,却容易被理解成 volatile 语义,因此已弃用。

可见性与发布关系见 Java 内存模型。

8. 原子更新与外部动作​

把副作用移到 CAS 成功之后,可以避免重算时重复执行,但还需要处理两步操作之间的失败。比如,CAS 更新状态后再发送消息,进程在发送前崩溃,消息就会缺失。发送后未收到确认时,调用方重试又会产生重复。

业务若要求状态与消息共同提交,需要额外的一致性协议。比如,在同一个持久化事务中更新业务状态并记录待发送事件,再重试投递。消费方按事件标识去重,使重复投递只产生一次业务效果,这称为幂等处理。内存 CAS 本身的原子范围只覆盖被更新的状态。

9. 常见问题​

9.1 CAS 与乐观并发控制​

CAS 是执行原子条件更新的基础操作,常用于实现乐观并发控制。乐观并发控制先读取并计算,提交时再检查状态是否仍满足预期。CAS 可以承担这个检查与更新,但算法还需要定义失败重试、结束条件和进度保证。

9.2 引用比较与内容比较​

10 · 面试题卡片​

10.1 什么是 ABA,什么时候需要处理​

出现公司:阿里巴巴、蚂蚁集团

考察重点

  • 解释 CAS 为何无法单独检测 A 到 B 再回到 A 的变化。
  • 判断算法是否依赖节点被移除或复用的历史。
  • 说明版本标记怎样检测变化,以及回绕和节点复用仍有哪些约束。

相关内容:ABA 问题和版本号与 AtomicStampedReference。

参考回答

ABA 指值从 A 变成 B 又回到 A,算法依赖这段变化历史时就需要处理。

CAS 只比较当前值,因此会接受恢复后的 A。如果只是普通计数,当前值相同就足够,ABA 可以被接受。如果旧线程依赖的是节点被移除前的关系,节点复用后,这些关系已经改变,继续更新就会出错。

可以用 AtomicStampedReference 同时比较引用和版本。每次变化都推进版本,在版本未回绕时,旧 CAS 就会失败。也可以约束节点复用,或采用适合算法的内存回收协议。最终仍需按完整数据结构检查线程交错。

10.2 弱 CAS 失败一定表示其他线程改过值吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释值匹配时仍会失败的原因,并区分原子更新与内存可见性保证。

相关内容:弱 CAS 与内存语义。

参考回答

弱 CAS 允许虚假失败,所以返回 false 时,其他线程未必改过值。

调用方应重新检查条件,并按算法要求决定是否重试。此外,weakCompareAndSetPlain 只有 plain 内存语义。如果还要发布其他字段,需建立相应的同步关系,例如改用 volatile 语义的 CAS。

10.3 为什么 updateAndGet 的函数应无副作用?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释重算怎样导致外部动作重复,以及为什么成功后的外部动作仍需处理一致性。

相关内容:重试函数与副作用、原子更新与外部动作。

参考回答

updateAndGet 在竞争后会重新调用函数,所以函数中的外部动作也会重复执行。

函数应只根据输入计算新值,把日志、实际扣款或网络请求放到 CAS 成功之后。即使这样,更新与外部动作之间仍会发生进程崩溃。因此,状态与消息需要共同提交时,还需持久化事务、待发送事件和幂等投递等业务协议。

11. 参考资料​