Skip to content

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,一次更新就丢了。

text
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)把「比较当前值是否等于预期值,相等就写入新值」作为一条原子指令完成。失败说明别人先改了,调用方通常重新读取、重新计算,再试一次:

java
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 万次这样的更新:

text
成功 800,000 次,计算函数执行 4,623,184 次(5.78 倍),放在循环里的副作用执行 4,623,184 次,最终余额 800,000

最终状态是正确的,但 apply 平均执行了将近 6 次。它必须是无副作用的纯计算:在里面发消息、写日志、调用远程服务,就会重复发生。副作用应该在 CAS 成功之后,用成功时的 before/after 执行一次。

竞争越激烈,重试越多,CPU 花在失败的计算上。临界区很长或者冲突率很高时,锁反而更省,公平与非公平锁的代价见 从 AQS 看 ReentrantLock。

读取当前值before = get()计算新值纯函数,可能执行多次compareAndSetbefore → after副作用成功后执行一次成功失败:别人先改了,重新读取实测:成功 800,000 次,计算函数执行 4,623,184 次(5.78 倍)
图 1 · 8 个线程各做 10 万次更新:成功 80 万次,计算函数执行约 462 万次。计算必须是纯函数;副作用放在 CAS 成功之后,只执行一次

四、ABA:什么时候真的有害 ​

CAS 只比较值。线程 1 读到 A,准备改成 B;这期间线程 2 把 A 改成别的、又改回 A,线程 1 的 CAS 仍然成功。

对计数器、状态标志来说这通常没有问题:值相同,含义也相同。问题出在同一个值在不同时刻代表不同的东西,典型的是无锁栈复用节点。按固定顺序重放一次交错:

  1. 栈是 A → B → C,线程 1 读到栈顶 A、下一个是 B,准备把栈顶换成 B;
  2. 线程 2 弹出 A、弹出 B(B 已经交给别人使用),再把节点 A 压回,栈变成 A → C;
  3. 线程 1 继续,CAS(A → B)。
text
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 轮、取最好的一轮:

线程数synchronizedAtomicLongLongAdder
14.26 亿次/秒1.46 亿次/秒1.46 亿次/秒
23803 万次/秒3935 万次/秒2.85 亿次/秒
42622 万次/秒2756 万次/秒5.48 亿次/秒
82959 万次/秒2064 万次/秒10.58 亿次/秒

几点观察:

  • AtomicLong 在竞争下总吞吐下降:8 个线程加起来比 1 个线程还慢得多,时间都花在缓存行的来回争抢和 CAS 失败上。
  • LongAdder 的吞吐随线程数近似线性增长,因为各线程写的是不同的槽。
  • 单线程时 synchronized 最快,是因为无竞争的循环里 JIT 可以做锁粗化等优化。这个数字不代表有竞争时的表现,有竞争时它和 AtomicLong 在同一个量级。
AtomicLongLongAdderTTTTTTTTvalueCAS 反复失败TTTTTTTTcell 0cell 1cell 2cell 3sum() = 各槽相加8 线程:约 2064 万次/秒单线程:约 1.46 亿次/秒8 线程:约 10.6 亿次/秒
图 2 · 8 个线程时 AtomicLong 约 2064 万次/秒,比单线程还低;LongAdder 约 10.6 亿次/秒。代价是 sum() 读到的不是一个锁定的快照,也没有条件更新

5.1 LongAdder 为什么不能做限额 ​

LongAdder.sum() 在读取时逐个累加各个槽,读的过程中其他线程仍在写,它不是一个加了锁的快照。更关键的是,LongAdder 没有「满足条件才增加」的原子操作。用它做限额只能写成先检查、再增加:

java
if (adder.sum() < 1000) adder.increment();                       // 检查和增加之间有窗口
atomic.getAndUpdate(v -> v < 1000 ? v + 1 : v);                  // 条件和更新在一次 CAS 里

限额 1000,8 个线程各尝试 1 万次,3 轮:

text
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:

java
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,但不要用它做判断。选择之前先问两个问题:有几个线程在写?每次写之前需不需要一个精确的当前值?


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权