《Java 高级编程》4.2 原子类与 CAS:AtomicLong 与 LongAdder 的取舍

8 个线程各增 100 万次,普通 long 只数到 333 万。本节用真实计数实验比较 AtomicLong 与 LongAdder 的吞吐与适用面,手写一遍 CAS 循环,再用 Treiber 栈复现 ABA 并给出 AtomicStampedReference 的修法,最后落到 libraryd 的 LoanCounters 与 LoanMetrics。

本节目标:知道什么时候用 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发