本节目标:知道什么时候用
AtomicLong、什么时候用LongAdder,以及 CAS 在什么情况下会骗你。
适用版本:JDK 25(Temurin 25.0.5);只使用 JDK 标准库。
承接的问题
4.1 说明了 volatile 不保证原子性。libraryd 的借阅计数正是这样一个场景:借出一次加一,归还一次加一,多个请求线程同时改。
先看最朴素的写法错到什么程度。一个普通 long,8 个线程各增 100 万次,用 CountDownLatch 对齐起跑:
static long plain;
static void run(String name, int threads, int perThread, Runnable op) throws Exception {
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch go = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
new Thread(() -> {
ready.countDown();
try { go.await(); } catch (InterruptedException e) { return; }
for (int j = 0; j < perThread; j++) {
op.run();
}
done.countDown();
}, name + "-" + i).start();
}
ready.await();
go.countDown();
done.await();
}
本机输出(8 线程 × 100 万次):
plain=3337002 atomic=8000000 adder=8000000
plain 只数到 333 万,丢了 466 万次。plain++ 是「读—加—写」,两个线程可以同时读到同一个旧值,各自加一后写回,后写的覆盖先写的。这一节要回答三个问题:CAS 到底做了什么、AtomicLong 和 LongAdder 的差距从哪来、CAS 什么时候会给出错误的结果。
CAS 与 AtomicLong 的内部循环
CAS 是 compare-and-set 的缩写,三个操作数:期望值 expect、新值 update、以及一个内存位置。语义是「如果当前位置的值等于 expect,就换成 update 并返回 true;否则什么都不做,返回 false」。这三步由一条 CPU 指令(x86 的 lock cmpxchg、ARM 的 CAS/LDAXR-STLXR 序列)保证原子。
AtomicLong.incrementAndGet() 就是一个 CAS 循环。手写出来是这样:
public final class CasCounter {
private volatile long value;
public long incrementAndGet() {
while (true) {
long current = value;
long next = current + 1;
if (compareAndSet(current, next)) {
return next;
}
}
}
private boolean compareAndSet(long expect, long update) {
return U.compareAndSetLong(this, VALUE, expect, update);
}
private static final VarHandle U;
private static final long VALUE;
static {
try {
U = java.lang.invoke.MethodHandles.lookup()
.findVarHandle(CasCounter.class, "value", long.class);
VALUE = 0L;
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
}
循环里没有任何锁:读当前值、算新值、CAS 写回,失败就重来。低竞争时它比锁快得多,因为没有内核参与,也不会阻塞线程;高竞争时失败率上升,大量线程在同一个缓存行上反复重试,性能急剧下降。
JDK 9 之后 AtomicLong 内部也用 VarHandle,但为了兼容保留了 sun.misc.Unsafe 时代的字段布局。上面这段代码的意义不是替代 AtomicLong,而是说明它的失败路径:CAS 失败的线程不会等待,而是立刻重读、重算、重试。
竞争下 AtomicLong 与 LongAdder 的差距
用同一套 harness 跑三种实现,线程数从 1 到 16,每线程 100 万次:
run("plain++", threads, perThread, () -> plain++);
run("AtomicLong", threads, perThread, atomic::incrementAndGet);
run("LongAdder", threads, perThread, adder::increment);
本机输出(Apple M1 Pro,macOS 26.3,Temurin 25.0.5,单次运行,未用 JMH):
threads=8 perThread=1000000
AtomicLong elapsedMs= 394 opsPerMs= 20260.7
LongAdder elapsedMs= 34 opsPerMs=229681.3
threads=16 perThread=1000000
AtomicLong elapsedMs= 730 opsPerMs= 21912.2
LongAdder elapsedMs= 65 opsPerMs=243365.4
单线程时两者接近(7ms 与 9ms),4 线程时变成 157ms 与 19ms。同样 8 线程、800 万次,AtomicLong 用了 394ms,LongAdder 用了 34ms;再跑三次,AtomicLong 稳定在 404–407ms,LongAdder 在 24–29ms,差距约 14–17 倍。同时可以看到 AtomicLong 的吞吐随线程数增加而下降(12.8 万/ms → 2 万/ms),这就是同一个缓存行上的 CAS 失败重试。
LongAdder 的思路是拆:一个 base 加一个 Cell[] 数组,每个线程通过 probe 值散列到不同的 Cell 上累加,最后 sum() 把所有 Cell 加起来。不同线程改不同缓存行,CAS 冲突大幅减少。
代价有两个:
- 内存。Cell 数组会随竞争扩张,直到接近 CPU 核数。计数器多的时候要算这笔账。
sum()不是原子快照。它逐个读 Cell 再相加,期间可能有线程正在累加,所以sum()返回的是「某个近似时刻」的值。
对 libraryd 的借阅计数来说,这两个代价都可以接受:计数只用于报表和监控,没有「读到的值必须精确到某一次借阅」的业务含义。反过来,如果你要判断「余额是否足够扣款」,sum() 不能用。
CAS 的陷阱:ABA
CAS 只比较「值是否相等」,不比较「这个值有没有被改过又改回来」。经典后果出现在用 CAS 实现的栈上。
初始栈是 B -> A。慢线程读到栈顶 B、下一个节点 A,准备把栈顶换成 A;此时快线程把 B 弹出、把 A 弹出、又把同一个 B 对象压回去。慢线程的 CAS 看到栈顶仍然是 B,比较成功,把栈顶设成 A——而 A 早已被弹出。
public final class AbaStack {
static final class Node {
final String value;
Node next;
Node(String value) { this.value = value; }
}
static final AtomicReference<Node> top = new AtomicReference<>();
public static void main(String[] args) throws Exception {
Node a = new Node("A");
Node b = new Node("B");
b.next = a;
top.set(b);
CountDownLatch read = new CountDownLatch(1);
CountDownLatch resume = new CountDownLatch(1);
Thread slow = new Thread(() -> {
Node observed = top.get();
Node next = observed.next;
read.countDown();
try { resume.await(); } catch (InterruptedException e) { return; }
boolean ok = top.compareAndSet(observed, next);
System.out.println("slow CAS(observed=B, new=A) ok=" + ok);
}, "slow-popper");
slow.start();
read.await();
Node poppedB = top.getAndSet(top.get().next);
System.out.println("fast pop1=" + poppedB.value + " top=" + top.get().value);
Node poppedA = top.getAndSet(null);
System.out.println("fast pop2=" + poppedA.value + " top=null");
poppedB.next = null;
top.set(poppedB);
System.out.println("fast push same B back, top=" + top.get().value);
resume.countDown();
slow.join();
Node now = top.get();
Node pop1 = top.getAndSet(now.next);
Node pop2 = top.getAndSet(top.get() == null ? null : top.get().next);
System.out.println("pop1=" + pop1.value + " pop2=" + (pop2 == null ? "null" : pop2.value));
}
}
本机输出:
fast pop1=B top=A
fast pop2=A top=null
fast push same B back, top=B
slow CAS(observed=B, new=A) ok=true
pop1=A pop2=null
CAS 返回了 true。A 在 fast pop2 时已经被弹出并交给调用方,现在又回到了栈顶,下一次 pop 会第二次拿到它。换成图书馆的场景,就是同一本书被发给了两个会员。
修法是给引用加一个版本号,让「改过又改回来」也能被识别:
static final AtomicStampedReference<Node> top = new AtomicStampedReference<>(null, 0);
int[] stamp = new int[1];
Node observed = top.get(stamp);
int observedStamp = stamp[0];
// ... 中间被别人改过三次 ...
boolean ok = top.compareAndSet(observed, observed.next, observedStamp, observedStamp + 1);
本机输出:
fast finished, top=B stamp=3
slow CAS(stamp=0) ok=false currentStamp=3
final top=B
慢线程带着 stamp=0 来,发现当前是 3,CAS 失败,重新读取即可。代价是每次读写多一个 int,引用和版本号要一起更新。libraryd 里真正需要 CAS 的地方不多:计数用 LongAdder,状态流转用数据库条件更新。
在 libraryd 里落地
第一个类是 LoanCounters,形状按领域约定,两个 LongAdder:
package com.plume.library.domain;
import java.util.concurrent.atomic.LongAdder;
public final class LoanCounters {
private final LongAdder borrowed = new LongAdder();
private final LongAdder returned = new LongAdder();
public void recordBorrowed() {
borrowed.increment();
}
public void recordReturned() {
returned.increment();
}
public long borrowed() {
return borrowed.sum();
}
public long returned() {
return returned.sum();
}
}
它被 LoanService 在借阅、归还成功后调用。注意方法名是 recordBorrowed 而不是 incrementBorrowed:这里记的是「已经成功提交的事件」,不是一个可以回滚的中间状态。如果事务回滚了,就不应该调用它。
第二个类是 LoanMetrics,把几种不同的原子需求放在一起:
package com.plume.library.app;
import com.plume.library.domain.LoanCounters;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
public final class LoanMetrics {
private final LoanCounters counters = new LoanCounters();
private final AtomicLong maxOverdueDays = new AtomicLong();
private final AtomicLong rejectedBorrows = new AtomicLong();
private final AtomicBoolean cacheWarmed = new AtomicBoolean();
public void recordBorrowed() {
counters.recordBorrowed();
}
public void recordReturned() {
counters.recordReturned();
}
public void recordRejected() {
rejectedBorrows.incrementAndGet();
}
public void observeOverdue(long days) {
maxOverdueDays.accumulateAndGet(days, Math::max);
}
public boolean markCacheWarmed() {
return cacheWarmed.compareAndSet(false, true);
}
public long borrowed() {
return counters.borrowed();
}
public long returned() {
return counters.returned();
}
public long active() {
return counters.borrowed() - counters.returned();
}
public long maxOverdueDays() {
return maxOverdueDays.get();
}
public long rejectedBorrows() {
return rejectedBorrows.get();
}
public boolean cacheWarmed() {
return cacheWarmed.get();
}
}
四个字段对应四种语义,值得逐个说:
LoanCounters:高频写、低频读、允许近似。LongAdder。maxOverdueDays:需要「读—比较—写」的原子复合操作,用accumulateAndGet。这是AtomicLong的强项,LongAdder做不了。rejectedBorrows:低频写(只有过载时才加),AtomicLong足够。cacheWarmed:一次性开关,compareAndSet(false, true)保证只有一个线程能拿到true,可以用来触发一次性的预热回调。
active() 用两次 sum() 相减,结果是一个近似值:两次读之间可能有借阅或归还发生,甚至可能出现负数(先读到归还的新值,再读到借阅的旧值)。所以它只能用于展示,不能用于「是否超过借阅上限」这类判断。借阅上限由数据库的唯一索引和计数查询保证,不靠这个内存计数。
报表读取也要注意:sum() 在高并发下会把正在累加的 Cell 读成「还没加」,所以同一毫秒连续读两次,第二次可能比第一次小。这不是 bug,是 LongAdder 的定义。如果报表要求单调递增,应该在读取侧做一次 max(last, current),或者直接查数据库。
常见错误与边界
错误一:把 LongAdder 当精确瞬时值。 典型写法是 if (adder.sum() >= limit) reject(),然后 adder.increment()。检查和自增之间没有原子性,两个线程可能都通过检查。需要「检查并更新」时用 AtomicLong 的 compareAndSet 循环,或者直接用锁/数据库。
错误二:CAS 循环里做有副作用的事。
// 反例:重试会重复发送通知
while (true) {
long current = counter.get();
notificationService.send(current); // CAS 失败会再发一次
if (counter.compareAndSet(current, current + 1)) {
break;
}
}
CAS 循环体可能执行多次,里面只能有「纯计算」。发送通知、写日志、扣款这类动作必须放在 CAS 成功之后。
错误三:AtomicReference 里放可变对象。 AtomicReference<List<Loan>> 的 compareAndSet 比较的是引用地址,两个内容相同但地址不同的列表不相等。更危险的是,拿到引用后直接 list.add(...),绕过了原子性。要么放不可变对象(List.copyOf),要么用 AtomicReferenceArray 这类明确支持元素级操作的结构。
边界:ABA 不只在无锁栈里出现。 任何「读—判断—CAS」的流程,只要中间状态可能回到原值就有风险;「余额 100 → 50 → 100」和「一直是 100」在 CAS 眼里没有区别。
小结
plain++在 8 线程 800 万次下丢了 466 万次,普通字段不能做并发计数。- CAS 是「比较并交换」的单指令原子操作,
AtomicLong.incrementAndGet是它的循环封装;失败线程重读重试,不阻塞。 - 8 线程 800 万次,
AtomicLong约 394–407ms,LongAdder约 24–34ms;差距来自缓存行竞争与重试。 LongAdder.sum()不是原子快照,只能用于报表和监控,不能用于「检查后动作」。- CAS 只比较值,不比较历史;
AtomicStampedReference用版本号识别 ABA,本机实验中 CAS 由true变为false。 - libraryd 的计数用
LongAdder,最大值/一次性开关用AtomicLong/AtomicBoolean,借阅上限交给数据库。
单个变量的原子性解决了,但「先检查可借副本、再决定排队还是借出」这种跨字段的判断,仍然需要锁。下一节看 ReentrantLock 与条件队列。
阅读导航:上一节:4.1 可见性与重排序:volatile 与 happens-before · 下一节:4.3 锁与条件队列:synchronized、ReentrantLock 与 AQS 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。