CAS 是什么?AtomicInteger 和 LongAdder 是怎么实现的?
一句话回答
CAS(Compare And Swap)是 CPU 提供的原子指令:给出内存地址、预期值和新值,只有当前值等于预期值时才写入新值,比较和写入不可分割;失败时不阻塞,直接返回 false,由调用方决定是否重试。AtomicInteger 内部是一个 volatile 的 int,更新时"读取当前值 → CAS → 失败就重读再试",也就是自旋 CAS。竞争激烈时大量线程 CAS 同一个变量,失败重试多,LongAdder 把计数分散到 base 和一组 Cell 上,不同线程更新不同的 Cell,读取时再求和,用更多的内存和不精确的读,换来更高的写入吞吐。
详细解析
CAS 的硬件支持
- x86 上对应带 lock 前缀的
cmpxchg指令,lock 保证多核下这次"读-比较-写"独占这块内存 - ARM 上用独占加载/独占存储指令对(LDXR/STXR)实现,ARMv8.1 起也有单条的 CAS 指令
- Java 层面:JDK 8 通过
sun.misc.Unsafe的compareAndSwapInt调用,JDK 9 起改为内部的jdk.internal.misc.Unsafe,并提供了公开的VarHandle。原子类的 CAS 同时具有 volatile 读和 volatile 写的内存语义,见 volatile 和 Java 内存模型
AtomicInteger 的实现
// AtomicInteger(JDK 21 源码节选)
private static final Unsafe U = Unsafe.getUnsafe();
private static final long VALUE = U.objectFieldOffset(AtomicInteger.class, "value"); // value 字段在对象里的偏移量
private volatile int value;
public final int incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
// Unsafe
@IntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset); // 1. 读出当前值
} while (!weakCompareAndSetInt(o, offset, v, v + delta)); // 2. CAS 失败(被别人改过,或弱 CAS 偶尔的假失败)就重读再试
return v;
}
value是 volatile 的,保证读到最新值;VALUE是字段偏移量,Unsafe 靠"对象 + 偏移量"直接操作这块内存- 这段 Java 代码是通用实现。
@IntrinsicCandidate表示 JIT 可以把它替换成平台相关的指令,例如 x86 上可以直接用一条lock xadd完成加法,不需要循环 updateAndGet、accumulateAndGet也是同样的循环,只是新值由传入的函数计算。竞争导致 CAS 失败时函数可能被再次调用,所以它必须没有副作用
ABA 问题
CAS 只比较"值是否等于预期",察觉不到值中间变过又变回来了。以用 CAS 修改栈顶的无锁栈为例:
- 栈是 A → B → C,线程 1 读到栈顶 A、A 的下一个是 B,准备
CAS(top, A, B)弹出 A,此时被挂起 - 线程 2 弹出 A、弹出 B,再把 A 压回去,栈变成 A → C
- 线程 1 恢复,栈顶还是 A,CAS 成功,栈变成 B → C:线程 2 已经弹出的 B 又回到了栈里,之后会被再弹出一次;在需要手动回收内存的语言里,B 甚至可能已经被释放或复用
单纯的计数器不怕 ABA,因为数值相同就是相同;但引用、链表节点、状态机这类"经过了什么"很重要的场景会出错。解决办法是给值加一个版本号:AtomicStampedReference 同时比较引用和一个 int 版本号;只关心"有没有被改过"时,可以用带一个 boolean 标记的 AtomicMarkableReference。
LongAdder:把一个热点拆成多个
AtomicLong 在高并发下的瓶颈是:所有线程都 CAS 同一个变量,大量 CAS 失败重试,这个变量所在的缓存行还要在各个核之间来回传递。LongAdder(继承自 Striped64)的结构:
LongAdder
├── base:没有竞争时,直接 CAS 它
└── cells:出现竞争后才创建;初始 2 个,竞争加剧时翻倍,上限是不小于 CPU 核数的最小 2 的幂
├── Cell[0].value ← 线程用自己的探针哈希值选格子
├── Cell[1].value
└── ...
sum() = base + 所有 Cell 的 value
add(x) 的过程:
- cells 还没创建时,先 CAS base,成功就结束
- CAS base 失败(说明有竞争),或者 cells 已经存在:用当前线程的探针哈希定位一个 Cell,CAS 这个 Cell
- cells 数组或对应的 Cell 还不存在,或者这次 CAS 也失败:进入
longAccumulate,初始化数组、创建 Cell、扩容数组,或者给线程换一个探针哈希,换个格子再试
Cell 类加了 @Contended 注解做填充,避免多个 Cell 挤在同一个缓存行里产生伪共享(见追问)。代价有三个:Cell 数组一旦扩容不会缩小,更占内存;sum() 不加锁,并发更新时不是一个精确的快照;increment() 没有返回值,拿不到"这次加完是多少"。除了求和,需要最大值、最小值这类累积时用 LongAccumulator,例如 new LongAccumulator(Long::max, Long.MIN_VALUE)。
怎么选
| AtomicInteger / AtomicLong | LongAdder | synchronized / Lock | |
|---|---|---|---|
| 竞争激烈时的写入 | 大量失败重试,吞吐下降 | 吞吐高 | 线程挂起,不空耗 CPU |
| 读取 | 精确 | sum() 不是快照 |
精确 |
| 能保护什么 | 单个变量 | 只能累加 | 任意的复合逻辑、多个变量 |
| 典型场景 | 序列号、状态标志、并发不高的计数 | QPS、调用次数等监控统计 | 临界区,见 synchronized 和 ReentrantLock |
CAS 适合"操作很短、冲突不多"的场景。临界区里的操作较长,或者竞争非常激烈时,自旋会一直空耗 CPU,用锁让线程挂起反而更好。synchronized 和 ReentrantLock 在无竞争时本身也是靠一次 CAS 加锁的。不想为每个字段都包一个 AtomicInteger 时,可以用 JDK 9 起提供的 VarHandle:MethodHandles.lookup().findVarHandle(类, 字段名, 字段类型) 拿到句柄后,直接对普通的 volatile 字段做 compareAndSet,它是 Unsafe 的公开、安全的替代品。
代码示例
import java.util.concurrent.atomic.AtomicStampedReference;
import java.util.concurrent.atomic.LongAdder;
public class CasDemo {
public static void main(String[] args) throws InterruptedException {
// 单线程模拟 ABA:用版本号察觉"A -> B -> A"
String a = "A", b = "B"; // 比较的是引用(==),所以前后要用同一个对象
AtomicStampedReference<String> ref = new AtomicStampedReference<>(a, 0);
int stamp = ref.getStamp(); // 线程 1 记下版本 0
ref.compareAndSet(a, b, 0, 1); // 线程 2:A -> B
ref.compareAndSet(b, a, 1, 2); // 线程 2:B -> A
System.out.println(ref.compareAndSet(a, b, stamp, stamp + 1)); // false:值还是 A,版本已经变了
// LongAdder:4 个线程并发累加,发生竞争后会分散到不同的 Cell 上
LongAdder pv = new LongAdder();
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10_000; j++) pv.increment();
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println(pv.sum()); // 40000:写入全部结束后再求和,结果是精确的
}
}
面试官可能追问
只用 CAS 怎么同时更新两个变量?
CAS 一次只能比较和交换一个值。可以把两个变量封装成一个不可变对象,用 AtomicReference 整体替换:读出旧对象,基于它创建新对象,再 CAS 引用,失败就重来。AtomicStampedReference 就是这么做的,它内部把"引用 + 版本号"包成一个 Pair 对象。变量多、逻辑复杂时直接用锁更清楚。
什么是伪共享?LongAdder 为什么要处理它?
CPU 缓存以缓存行为单位(常见是 64 字节)在核之间同步。两个变量落在同一个缓存行里,核 1 改变量 x、核 2 改变量 y,虽然改的不是同一个变量,缓存行也会在两个核之间反复失效、传递,效果接近于在争抢同一个变量。LongAdder 的目的就是让不同线程写不同的 Cell,如果多个 Cell 挤在一个缓存行里,这个优化就白做了,所以 Cell 用 @Contended 做了填充。
LongAdder 能用来生成唯一的序号吗?
不能。increment() 没有返回值,sum() 在并发更新时也不是精确的快照,两个线程可能读到同一个和。生成序号要用 AtomicLong.incrementAndGet() 这类返回新值的原子操作,它保证每次调用拿到的值都不同。LongAdder 适合"写很多、偶尔读一次总数"的统计场景,ConcurrentHashMap 统计元素个数用的也是这个思路。
易错点
- CAS 只保证单个变量单次更新的原子性;先
get()再set()依然会丢失更新,要用compareAndSet循环或updateAndGet updateAndGet、accumulateAndGet传入的函数可能被执行多次,里面不能有副作用(比如打日志计数、发消息)AtomicStampedReference用==比较引用。用Integer当值时,超出 -128~127 缓存范围的数每次自动装箱都是新对象,CAS 会莫名失败LongAdder.sum()在有并发写入时只是近似值,不能用它做"达到阈值才放行"这类精确判断
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。