volatile、CAS 与 LongAdder:三件工具解决的不是同一个问题
volatile管可见性,CAS 管「值没被别人改过才更新」,LongAdder管高竞争下的计数。把三者都说成「保证线程安全」,就会在错误的地方用错工具。
8 个线程各对一个 volatile long 做 100 万次 ++,结果不是 800 万,而是 150 万左右,600 多万次更新丢了。换成 AtomicLong 结果正确,但 8 个线程的总吞吐只有单线程的七分之一。再换成 LongAdder,吞吐是 AtomicLong 的 50 倍;可如果用它做「最多 1000 个名额」的判断,最终会发出 1005 个。
三个工具各自只解决一部分问题。本文在 JDK 21 上逐一实测它们的边界:什么时候丢更新,CAS 失败重试时哪些代码会被执行多次,ABA 在什么情况下有害,以及 LongAdder 为什么适合统计却不适合做限额。
一、先说结论
volatile保证可见性与有序性,不保证复合操作原子:count++是读、加、写三步,实测 8 个线程各加 100 万次,每轮丢失 630 多万次更新。- CAS 失败要重试,重试循环里的代码会执行多次:实测 80 万次成功更新,计算函数执行了约 460 万次。发消息、扣库存这类副作用绝不能放进重试循环。
- ABA 只在「同一个值代表不同的东西」时有害:计数器从 1 变 2 又变回 1 无所谓;无锁栈复用节点时,普通 CAS 会把已经弹出的节点放回栈顶,版本号可以挡住它。
- 高竞争计数用
LongAdder:8 个线程时约 10.6 亿次/秒,AtomicLong约 2000 万次/秒。 LongAdder不能用来做判断:它没有条件更新,「sum()小于 1000 就加 1」实测最终到了 1005;需要精确阈值时用AtomicLong的条件 CAS 或锁。
二、volatile:只保证看得见
volatile 的作用在 JMM 与 happens-before 里讲过:对它的写对之后的读可见,并且禁止相关的重排序。它不改变 count++ 的本质:先读当前值,加 1,再写回。两个线程同时读到 100,各自写回 101,一次更新就丢了。
8 个线程各加 100 万次:
volatile long 为 1,522,470(丢失 6,477,530),AtomicLong 为 8,000,000
volatile long 为 1,582,627(丢失 6,417,373),AtomicLong 为 8,000,000
volatile long 为 1,653,809(丢失 6,346,191),AtomicLong 为 8,000,000丢失的比例高得惊人,是因为 8 个线程在紧密循环里争同一个缓存行,几乎每次读—改—写都会和其他线程交错。volatile 适合的场景是一个线程写、多个线程读的状态,例如停止标志、配置版本号;多个线程都要修改时,需要原子操作或锁。
三、CAS:值没被改过才更新
CAS(Compare-And-Swap)把「比较当前值是否等于预期值,相等就写入新值」作为一条原子指令完成。失败说明别人先改了,调用方通常重新读取、重新计算,再试一次:
AtomicReference<Account> state = new AtomicReference<>(new Account(0, 0));
void deposit(long amount) {
Account before, after;
do {
before = state.get();
after = before.apply(amount); // 可能执行多次
} while (!state.compareAndSet(before, after));
}8 个线程各做 10 万次这样的更新:
成功 800,000 次,计算函数执行 4,623,184 次(5.78 倍),放在循环里的副作用执行 4,623,184 次,最终余额 800,000最终状态是正确的,但 apply 平均执行了将近 6 次。它必须是无副作用的纯计算:在里面发消息、写日志、调用远程服务,就会重复发生。副作用应该在 CAS 成功之后,用成功时的 before/after 执行一次。
竞争越激烈,重试越多,CPU 花在失败的计算上。临界区很长或者冲突率很高时,锁反而更省,公平与非公平锁的代价见 从 AQS 看 ReentrantLock。
四、ABA:什么时候真的有害
CAS 只比较值。线程 1 读到 A,准备改成 B;这期间线程 2 把 A 改成别的、又改回 A,线程 1 的 CAS 仍然成功。
对计数器、状态标志来说这通常没有问题:值相同,含义也相同。问题出在同一个值在不同时刻代表不同的东西,典型的是无锁栈复用节点。按固定顺序重放一次交错:
- 栈是 A → B → C,线程 1 读到栈顶 A、下一个是 B,准备把栈顶换成 B;
- 线程 2 弹出 A、弹出 B(B 已经交给别人使用),再把节点 A 压回,栈变成 A → C;
- 线程 1 继续,CAS(A → B)。
AtomicReference:线程 1 的 CAS 成功,栈变成 [B, C](已被弹出的 B 回到了栈顶)
AtomicStampedReference:线程 1 的 CAS 失败(版本 0 → 当前 3),栈仍是 [A, C]AtomicStampedReference 把一个版本号和引用一起比较,A 被弹出又压回之后版本已经变了。数据库里的乐观锁用的是同一个办法:UPDATE … WHERE version = ?。
判断要不要处理 ABA,问一句:值相同是否就意味着状态相同? 是,就不用管;否,就要加版本号,或者像 Java 的无锁队列那样不复用节点。
五、AtomicLong 与 LongAdder
AtomicLong 所有线程都 CAS 同一个变量,竞争越多失败越多。LongAdder 在竞争时把计数分散到多个槽(Cell)里,每个线程大多只更新自己命中的槽,读取时把所有槽加起来。
在 10 核机器上,每种计数器在每个线程数下跑 3 轮、取最好的一轮:
| 线程数 | synchronized | AtomicLong | LongAdder |
|---|---|---|---|
| 1 | 4.26 亿次/秒 | 1.46 亿次/秒 | 1.46 亿次/秒 |
| 2 | 3803 万次/秒 | 3935 万次/秒 | 2.85 亿次/秒 |
| 4 | 2622 万次/秒 | 2756 万次/秒 | 5.48 亿次/秒 |
| 8 | 2959 万次/秒 | 2064 万次/秒 | 10.58 亿次/秒 |
几点观察:
AtomicLong在竞争下总吞吐下降:8 个线程加起来比 1 个线程还慢得多,时间都花在缓存行的来回争抢和 CAS 失败上。LongAdder的吞吐随线程数近似线性增长,因为各线程写的是不同的槽。- 单线程时
synchronized最快,是因为无竞争的循环里 JIT 可以做锁粗化等优化。这个数字不代表有竞争时的表现,有竞争时它和AtomicLong在同一个量级。
5.1 LongAdder 为什么不能做限额
LongAdder.sum() 在读取时逐个累加各个槽,读的过程中其他线程仍在写,它不是一个加了锁的快照。更关键的是,LongAdder 没有「满足条件才增加」的原子操作。用它做限额只能写成先检查、再增加:
if (adder.sum() < 1000) adder.increment(); // 检查和增加之间有窗口
atomic.getAndUpdate(v -> v < 1000 ? v + 1 : v); // 条件和更新在一次 CAS 里限额 1000,8 个线程各尝试 1 万次,3 轮:
LongAdder 先 sum() 再 increment():最终 1,000 / 1,005 / 1,003
AtomicLong 条件 CAS:最终 1,000 / 1,000 / 1,000超发的根源是「先检查再执行」不是原子操作;用 AtomicLong 写成同样的两步也会超发。区别在于 AtomicLong 提供了把条件和更新放进同一次 CAS 的方法,LongAdder 没有。
| 场景 | 选择 | 原因 |
|---|---|---|
| QPS、命中次数、监控计数 | LongAdder | 写多读少,读到的是某个时刻附近的值就够了 |
| 订单序号、余额、配额、库存 | AtomicLong 的条件 CAS,或锁 | 每次都要基于精确的当前值做决定 |
| 多个字段要一起保持一致 | 不可变对象 + AtomicReference,或锁 | 单个变量的原子性不等于多个变量的原子性 |
跨进程的配额和库存又是另一回事,见 Redis 原子性边界。
六、VarHandle:自己写并发结构时的入口
需要在自己的类里对某个字段做 CAS,而不想为每个字段包一个 AtomicXxx 对象时,JDK 9 起的标准做法是 VarHandle,而不是 sun.misc.Unsafe:
static final class Slot { volatile int state; }
static final VarHandle STATE = MethodHandles.lookup().findVarHandle(Slot.class, "state", int.class);
boolean acquire(Slot slot) {
return STATE.compareAndSet(slot, 0, 1); // 8 个线程同时调用,只有 1 个成功
}VarHandle 还提供 acquire/release、opaque 等比 volatile 更弱的访问模式,只在确实需要时使用。业务代码优先用 java.util.concurrent 里现成的类。
七、常见误区
- 「
volatile能保证线程安全」:只保证可见性和有序性,count++仍会丢更新。 - 「CAS 就是无锁,一定比锁快」:竞争激烈、计算较重时,大量失败重试比排队更贵。
- 「用了 CAS 就要处理 ABA」:只有同一个值代表不同状态时才有害,例如节点复用。
- 「
LongAdder是更快的AtomicLong」:它适合统计,不适合需要精确当前值的判断。 - 「多个
AtomicXxx组合起来就是原子的」:每个变量各自原子,组合起来仍然可能被其他线程插入中间状态。
小结
三个工具对应三个问题:只需要看得见,用 volatile;需要「值没变才更新」,用 CAS,并让重试循环里只有纯计算;需要在大量线程下计数,用 LongAdder,但不要用它做判断。选择之前先问两个问题:有几个线程在写?每次写之前需不需要一个精确的当前值?
配套实验
- codesphere-labs/java/atomics-and-longadder:
volatile上的i++、CAS 重试与副作用、无锁栈的 ABA、三种计数器在 1—8 个线程下的吞吐、用LongAdder做限额(验证记录)
参考资料
- JDK 21 API:java.util.concurrent.atomic
- JDK 21 API:LongAdder
- JDK 21 API:VarHandle
- JEP 193: Variable Handles
- Brian Goetz 等,《Java Concurrency in Practice》第 15 章(原子变量与非阻塞同步)