《Java 并发编程·线程与内存模型》8.3 LongAdder 与高竞争累加

从 Striped64 的 base + Cell[] 结构讲清 LongAdder 为什么能绕开单变量 CAS 的天花板:给出 JDK 25 的 add/sum 源码、cell 分片的实测形状、@Contended 与伪共享的真实编译约束,并在负载 155 的机器上如实记录 JMH 误差超过 50% 的数据不可引用。

承接的问题

8.1 测到 8 个线程做 80 万次 CAS 自增,伴随约 67 万~72 万次失败重试;8.2 补上了家族地图,但所有原子类的写法都一样:所有线程 CAS 同一个内存位置。

这不是实现问题,是物理问题。一个 AtomicLong 的值只在一个缓存行里,N 个核要改它,就要把这个缓存行在核之间来回传递。核越多,传递越慢,失败重试越多,形成正反馈。所谓「CAS 比锁快」,前提是竞争不激烈。

LongAdder 换了一个思路:不要求任何时刻只有一个总和,而是让每个线程改自己那一份,读的时候再加起来。代价是 sum() 不再是快照。这一节讲清楚三件事:

  1. 分片是怎么做的,什么时候分片。
  2. 分片为什么必须配 @Contended,用户代码能不能用。
  3. sum() 非快照意味着哪些用法是错的。

一个变量撞上的墙

把 8.1 的失败重试数据换个角度看:8 个线程、80 万次成功自增、约 70 万次失败 CAS。失败的那 70 万次不是白做的——每一次都要重新读一次缓存行,而这条缓存行刚被别的核改过,读它就要做一次缓存一致性传输。

线程数继续加,吞吐不会线性上升,而是趋于平缓甚至下降:有效工作量占比越来越低,剩下的都在搬运缓存行。这就是单变量 CAS 的上限。

与之对应的是「空间换吞吐」:开 M 份计数器,线程按某种规则选一份改,最后求和。线程之间不再碰撞,缓存行各在各的核上,吞吐随核数近似线性增长。M 份计数器怎么选、怎么分配、怎么扩容,就是 Striped64 要解决的问题。

LongAdder 的结构:base + Cell[]

LongAdder 继承 Striped64,核心状态只有三个字段:

transient volatile long base;      // 无竞争时直接改这里
transient volatile int cellsBusy;  // 扩容/建槽位用的自旋锁,0 空闲 1 占用
transient volatile Cell[] cells;   // 竞争出现后启用,长度是 2 的幂

Cell 是一个极简容器:一个 volatile long value 加一个 VarHandle,CAS 用 weakCompareAndSetRelease——release 足够,因为写 Cell 不需要 acquire 语义。

add 的路径(JDK 25 源码):

public void add(long x) {
    Cell[] cs; long b, v; int m; Cell c;
    if ((cs = cells) != null || !casBase(b = base, b + x)) {
        int index = getProbe();
        boolean uncontended = true;
        if (cs == null || (m = cs.length - 1) < 0 ||
            (c = cs[index & m]) == null ||
            !(uncontended = c.cas(v = c.value, v + x)))
            longAccumulate(x, null, uncontended, index);
    }
}

逐段读:

  1. cells 还是 null,说明从没发生过竞争,直接 CAS base。成功就返回,这是最快路径,和 AtomicLong 一样快。
  2. base 的 CAS 失败,说明有竞争。用当前线程的 probe 值(ThreadLocalRandom 提供的每线程哈希)算出下标,CAS 对应的 Cell。
  3. Cell 为 null 或 CAS 又失败,进 longAccumulate:初始化表、扩容、或者换个槽位再试。

扩容规则写在 Striped64 的注释里:表初始大小为 2,每次竞争加剧翻倍,直到「不小于 CPU 数的最小 2 的幂」。槽位是懒创建的,用到才填。

实测 8 个线程跑完之后表的形状(用 --add-opens java.base/java.util.concurrent.atomic=ALL-UNNAMED 反射读 cells):

cells.length=8 nonNull=8 sum=4000000

本机 10 核,表长到 8,8 个槽位都被填满。这不是巧合:每个线程的 probe 分散到 8 个槽位,正好一人一个,碰撞消失。

关键点:分片是自适应发生的,不是配置出来的。 没有竞争时,LongAdder 和 AtomicLong 走同一条路(CAS base),不会因为「用了 LongAdder」就多付成本。这也是它比「一开始就分片」的实现更好的地方。

伪共享与 @Contended

分片能生效的前提是每个 Cell 独占缓存行。如果两个 Cell 落在同一个 64 字节缓存行里,两个线程改的虽然是自己那份,硬件仍然认为它们在争同一行,效果和改同一个变量差不多。这就是伪共享。

Striped64.Cell 的声明上有一个注解:

@jdk.internal.vm.annotation.Contended static final class Cell {
    volatile long value;
    Cell(long x) { value = x; }
    final boolean cas(long cmp, long val) {
        return VALUE.weakCompareAndSetRelease(this, cmp, val);
    }
}

@Contended 让 JVM 在字段布局时插入填充,把每个 Cell 推到不同的缓存行。用 javap -p 看这个类(输出节选,略去 Compiled from 行),看不到任何填充字段:

final class java.util.concurrent.atomic.Striped64$Cell {
  volatile long value;
  private static final java.lang.invoke.VarHandle VALUE;
  java.util.concurrent.atomic.Striped64$Cell(long);
  final boolean cas(long, long);
  final void reset();
  final void reset(long);
  final long getAndSet(long);
  static {};
}

填充不是源码里的字段,是 JVM 在对象布局阶段加的。javap -v 能在常量池里看到 Ljdk/internal/vm/annotation/Contended;,但看不到填充的结果——想知道实际布局,得用 debug 版 JVM 的 -XX:+PrintFieldLayout,本机试了:

Error: VM option 'PrintFieldLayout' is develop and is available only in debug version of VM.

用户代码能不能用 @Contended?能,但要过两道关。第一道是编译期——这个包没有导出:

错误: 程序包 jdk.internal.vm.annotation 不可见
  (程序包 jdk.internal.vm.annotation 已在模块 java.base 中声明, 但该模块未将它导出到未命名模块)

加 --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED 就能编译。第二道是运行期——默认 RestrictContended 开着,用户类的注解会被忽略,必须再加 -XX:-RestrictContended:

java --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED \
     -XX:-RestrictContended -cp target/classes com.plume.conc.lab.Main

这两条命令都依赖 JDK 内部包,不适合写进业务代码。业务里要避免伪共享,务实的做法是:不要自己拼数组做分片,直接用 LongAdder;或者把热点字段之间留出足够的填充字段(手写 long p1, p2, ... p7),代价是可读性差、且填充是否有效依赖 JVM 布局。

sum() 不是快照

LongAdder.sum() 的源码就是「读 base,再把每个 Cell 加一遍」:

public long sum() {
    Cell[] cs = cells;
    long sum = base;
    if (cs != null) {
        for (Cell c : cs)
            if (c != null)
                sum += c.value;
    }
    return sum;
}

没有锁,没有版本号,没有重试。读到一半时有线程在改某个 Cell,这次读就不会包含那笔更新;读到 cells 之后表又扩容了,读的是旧表,但 Cell 对象是共享的,值仍然在。javadoc 写得很直接:返回值不是原子快照;并发更新期间算出的 sum 可能不包含这些更新。

实测:8 个线程各做 50 万次 increment,主线程在它们跑完之前不停调 sum(),记录样本数、最小值、最大值:

sum() samples=549368 min=0 max=4000000 final=4000000

54 万次采样里,最小读到 0(那时更新还没落地),最大读到 400 万(终值)。这些值都「对」,但没有任何一次读能代表「读的那一刻系统里已完成多少次自增」。 每一次都是一个拼合值。

这条性质决定了几类用法是错的:

用法为什么错替代
用 sum() 判断「是否达到上限」再做业务动作读到 99 时实际可能已经 101AtomicLong + CAS,或加锁
用 sum() 做对账、算差值两次读各自是拼合值,差值无意义在静默点读,或改用 AtomicLong
把 sum() 当序号发号不单调、不可重复读AtomicLong.incrementAndGet
用 sum() > 0 做「有没有发生过」的判断极端情况下可能漏看用 AtomicBoolean 或 LongAdder 之外的标志

reset() 的问题更严重。它的实现是 base = 0 再逐个 c.reset(),中间的更新会被直接抹掉。javadoc 的原话是「只有在确定没有线程并发更新时使用才有效」。实际能用的场景只有一个:周期性汇总,且汇总时刻业务处于静默点。有并发更新时,用 sumThenReset() 也一样——它逐 Cell 做 getAndSet(0),拿到的是各 Cell 在不同时刻的旧值之和,仍然不是快照。

本机跑了一次 8 线程更新、主线程不停 sumThenReset() 的对照:

resetRace: resets=43892 harvested=1600000 final=0 total=1600000 expect=1600000

4.3 万次 sumThenReset 之后,收割到的总量加最终值正好等于期望值,这一轮没有观测到丢失。不能据此认为它安全:getAndSet 对单个 Cell 是原子的,但「读完所有 Cell」这个动作不是;丢不丢取决于交错,取决于某一笔更新落在 base 上还是 Cell 上、以及读取的顺序。契约上不保证,实测没丢,只能说明这一轮运气好。

何时不用 LongAdder

场景选择理由
需要精确的即时值做判断AtomicLongsum() 不是快照,判断会读到中间态
需要「读-改-写」返回旧值AtomicLongLongAdder 没有 getAndAdd
需要 CAS 语义(状态机、认领)AtomicLong / AtomicReference加法可交换,状态迁移不可交换
发号、生成单调 IDAtomicLongsum() 不单调
低竞争、调用稀疏AtomicLong两者在无竞争时几乎同速,LongAdder 还多占内存
统计、指标、计数LongAdder高竞争下吞吐显著更好
求最大值/最小值等非加法聚合LongAccumulator构造时传 LongBinaryOperator
浮点累加DoubleAdder没有 AtomicDouble

LongAccumulator 值得单独说一句:它和 LongAdder 共用 Striped64 的分片结构,只是把「加法」换成了构造时传入的二元运算。求最大延迟、最小时间戳这类聚合,用它比用 AtomicLong 的 CAS 循环更划算。但同样:get() 不是快照,而且运算必须满足结合律和交换律,否则分片合并的结果没有意义。

实测:AtomicLong vs LongAdder

先声明环境。跑之前 uptime:

23:37  up 9 days, 17 hrs, 6 users, load averages: 155.93 145.06 115.56

10 核机器上 load average 155,意味着就绪队列长期排满。这个环境下任何吞吐数据都不可引用。 下面给出两组数据,都附上「为什么不能用」的说明,不给结论性的加速比。

第一组是固定工作量的墙钟时间:8 线程 × 50 万次,跑 3 轮,交替执行。

expect=4000000 atomic=4000000 (195 ms) adder=4000000 (24 ms)
round 0: atomic=186 ms, adder=21 ms, atomicSum=4000000, adderSum=4000000
round 1: atomic=187 ms, adder=13 ms, atomicSum=4000000, adderSum=4000000
round 2: atomic=183 ms, adder=7 ms, atomicSum=4000000, adderSum=4000000
atomic ms=[186, 187, 183] adder ms=[21, 13, 7]

两边的结果都精确等于 400 万,这一点与负载无关,是正确性结论。耗时那一列只能看出方向:AtomicLong 稳定在 183~187 ms,LongAdder 在 7~24 ms 之间大幅波动。3 轮之内自己就差了 3 倍多,说明测量被系统负载主导,不能拿去算「快多少倍」。

第二组是 JMH:8 线程、吞吐模式、1 个 fork、2 轮预热、3 轮测量,每轮 1 秒。

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(1)
@Threads(8)
public class AdderThroughputBench {
    private final AtomicLong atomic = new AtomicLong();
    private final LongAdder adder = new LongAdder();

    @Benchmark
    public long atomicLong() { return atomic.incrementAndGet(); }

    @Benchmark
    public void longAdder() { adder.increment(); }
}

命令($JMH_CORE 指 jmh-core、jopt-simple、commons-math3 三个 jar 的 classpath):

java -cp target/classes:$JMH_CORE org.openjdk.jmh.Main AdderThroughputBench \
     -f 1 -wi 2 -i 3 -w 1 -r 1

本机输出(原样保留,不做修饰):

Benchmark                         Mode  Cnt       Score        Error   Units
AdderThroughputBench.atomicLong  thrpt    3   21200.828 ±  11092.598  ops/ms
AdderThroughputBench.longAdder   thrpt    3  925113.048 ± 866452.020  ops/ms

读这张表:

  • atomicLong 的误差是 ±11 092,相对 21 200 是 52%。
  • longAdder 的误差是 ±866 452,相对 925 113 是 94%。
  • 同一轮里还打印了一个置信区间 CI (99.9%): [58661.028, 1791565.068],上下限差 30 倍。

误差接近或超过 100% 的基准,一个数字都不能引用。它唯一能说明的是:这台机器当时不适合做吞吐测量。要得到可用数据,需要一台空闲机器、至少 5 个 fork、每轮 3~5 秒,并且用 -prof 之外的手段确认没有别的负载。

所以这一节不给「LongAdder 比 AtomicLong 快 N 倍」的结论。可以确定的只有两件事:两者结果都正确;在本机这种核数(10)和线程数(8)下,LongAdder 的耗时明显更低,方向与 Striped64 的设计预期一致,但具体倍数需要干净的机器才能测。

在 libraryd-conc 里落地

借阅服务的指标天然适合 LongAdder:只增不减、读多写多、不参与业务判断。

public final class LoanMetrics {
    private final LongAdder reserved = new LongAdder();
    private final LongAdder returned = new LongAdder();
    private final LongAdder rejected = new LongAdder();
    private final LongAccumulator maxLatencyNanos = new LongAccumulator(Math::max, 0L);

    public void onReserved(long latencyNanos) {
        reserved.increment();
        maxLatencyNanos.accumulate(latencyNanos);
    }

    public void onReturned() { returned.increment(); }
    public void onRejected() { rejected.increment(); }

    /** 每次调用都是各计数器在不同时刻的拼合值,不能当快照用。 */
    public Snapshot snapshot() {
        return new Snapshot(reserved.sum(), returned.sum(), rejected.sum(),
                maxLatencyNanos.get());
    }

    public record Snapshot(long reserved, long returned, long rejected, long maxLatencyNanos) {}
}

两个约定要写进注释,否则下一个人一定会踩:

  • snapshot() 的四个数不是同一时刻的。监控上报可以接受,因为下一次采样会补上;业务判断不能接受。
  • 不在 LoanMetrics 里放 reset()。指标只在进程生命周期内累加,需要窗口值时由上报端做差分(用两次 snapshot() 相减),而不是在服务里清零。

如果确实需要「本分钟预约数」这种窗口值,正确做法是让监控系统按时间序列做差分,或者用两个 LongAdder 交替滚动——但滚动本身有切换时刻的竞态,仍然只能在近似的意义上使用。

常见错误与边界

  • 把 sum() 当快照。 它由「读 base + 遍历 cells」拼成,不对应任何时刻。
  • 用 sum() 做限流或上限判断。 读到 99 时可能已经 101,超限已经发生。
  • 在并发更新时调 reset()。 会抹掉落在重置窗口里的更新,javadoc 明确禁止。
  • 以为 LongAdder 一定比 AtomicLong 快。 无竞争时两者都走 CAS base,速度接近;LongAdder 还多占内存。
  • 用 LongAdder 做状态机。 加法可交换,状态迁移不可交换,语义直接不对。
  • 把 LongAccumulator 的运算写成不满足结合律。 分片合并的顺序不确定,减法、字符串拼接这类运算会给出随机结果。
  • 以为设置 -XX:-RestrictContended 就能让业务类的 @Contended 生效。 还要 --add-exports,且依赖 JDK 内部包,升级可能失效。
  • 在容器里按 availableProcessors() 估算分片数。 表长上限与核数相关,容器 CPU 限额变化会影响分片效果,压测要在目标环境做。

小结

  • LongAdder 用 base + Cell[] 分片:无竞争走 base CAS,出现竞争后按线程 probe 分散到 Cell,表长按 2 的幂扩到不小于核数;本机 8 线程跑出 cells.length=8。
  • 分片要生效,Cell 必须独占缓存行,Striped64.Cell 靠 @Contended 做填充;填充在 JVM 布局阶段插入,javap 看不到。业务代码用 @Contended 需要 --add-exports 与 -XX:-RestrictContended,不建议依赖。
  • sum() 不是快照,reset() 只能在静默点使用;用它做上限判断、对账、发号都是错的。本机 54 万次采样中最小读到 0、最大读到终值,正好说明每次读都是拼合值。
  • 实测只保留正确性结论:两种实现都精确得到 400 万。吞吐数据在 load average 155 的机器上误差达 52%~94%,不可引用,需要在空闲机器上用多 fork 的 JMH 重测。
  • 选型口诀:要判断、要语义、要序号,用 AtomicLong;要统计、要吞吐、只要最终值,用 LongAdder。

到这里,无锁部分告一段落。下一节回到阻塞:自旋到底在等什么,park/unpark 的许可为什么只有一张,以及为什么大多数业务代码不该自己写自旋。

阅读导航:上一节 8.2 原子类家族与字段更新器 | 下一节 9.1 自旋、阻塞与 park/unpark

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收