《Java 并发编程·JUC 源码与容器》8.2 LongAdder 的 Cell 分片

拆 LongAdder 的 base + cells 双层结构:@Contended 为什么让每个 Cell 约占 289 字节,sum() 为何不是快照(本机 158 万次采样有 24 万次把在借数算成负数),reset() 的破坏性,以及高竞争实测下它何时反而不如 AtomicLong。

承接的问题

8.1 的重试数据说明了一件事:AtomicInteger 的所有线程抢同一个地址,4 线程时平均每次成功自增要多付 1.03 次失败重试。失败重试只是症状,病根是单点。只要所有写都落到同一个缓存行,增加线程只会增加争抢。

LongAdder 的思路不是把 CAS 做得更快,而是让线程写不同的地址。它维护一个 base 和一个 Cell[],低竞争时写 base,有竞争就把线程散到不同的 Cell 上,读的时候再全部加起来。代价是 sum() 不再是一个精确快照。

本节回答四个问题:base 与 cells 如何协作;@Contended 到底让每个 Cell 付出多少空间;sum() 的弱一致在真实代码里会怎么咬人;什么时候 LongAdder 反而不如 AtomicLong。

结构:base 是快路径,cells 是溢洪道

先看 Striped64 的字段与 Cell。LongAdder 本身几乎没有状态,全部机制都在父类里:

// JDK 25 源码摘录,已删减
abstract class Striped64 extends Number {
    @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);
        }
        private static final VarHandle VALUE = MhUtil.findVarHandle(
                MethodHandles.lookup(), "value", long.class);
    }

    static final int NCPU = Runtime.getRuntime().availableProcessors();
    transient volatile Cell[] cells;
    transient volatile long base;
    transient volatile int cellsBusy;
}

再看 LongAdder 的三个关键方法:

// 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);
    }
}

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

public void reset() {
    Cell[] cs = cells;
    base = 0L;
    if (cs != null) {
        for (Cell c : cs)
            if (c != null)
                c.reset();
    }
}

add 的四层判断就是整个设计:

  1. (cs = cells) != null:一旦分片表建起来,就不再走 base,直接进分片路径。
  2. !casBase(b = base, b + x):表还没建时先试 base。成功就返回,这是无竞争时的全部开销——一次 CAS。
  3. (c = cs[index & m]) == null:槽位是懒创建的,第一次落到这个槽才分配。
  4. !c.cas(v = c.value, v + x):槽位存在就 CAS 自己的 Cell。只有这一步也失败,才进 longAccumulate 去扩容或换探针。

cs == null || (m = cs.length - 1) < 0 这个条件看起来奇怪,其实是防御 cells 刚被别的线程初始化成长度 2 之前的瞬态:表非空但长度为 0 的情况理论上不该出现,源码仍然兜住。

sum() 的顺序是 base 在前、cells 在后,逐个 volatile 读。每个 Cell 的 cas 用的是 weakCompareAndSetRelease——release 足够,因为这里不需要 acquire:写者只关心自己的更新别被重排到 CAS 之前,读者用 volatile 读拿到值即可。

为什么每个 Cell 要花 289 字节

Cell 上有 @jdk.internal.vm.annotation.Contended。本机 javap -v 确认注解真实存在于类文件:

RuntimeVisibleAnnotations:
  0: #68()
    jdk.internal.vm.annotation.Contended

这个注解告诉 JVM:把整个对象的前后各填充一段,避免它和别的对象共享缓存行。填充宽度由 -XX:ContendedPaddingWidth 控制,本机默认值:

int ContendedPaddingWidth = 128    {product} {default}
bool RestrictContended    = true   {product} {default}

128 字节,前后各一份。为什么单个 Cell 要这么奢侈?因为 Cell 总是成组出现在数组里。如果两个 Cell 落在同一个 64 字节缓存行,线程 A 改 Cell[0]、线程 B 改 Cell[1],硬件仍然要在两个核之间来回搬运这一行——这叫伪共享(false sharing),分片就白分了。源码注释写得很直白:Atomic 对象通常零散分布,填充是过度的;但 “Atomic objects residing in arrays will tend to be placed adjacent to each other”,不填充就会共享缓存行,并带来 “a huge negative performance impact”。

空间代价可以实测。本机用反射构造 Striped64$Cell 和 AtomicLong 各 10 万、30 万个,做两次堆占用差,得到边际大小(含数组槽位):

Cell      边际大小 = 289.1 B
AtomicLong 边际大小 = 32.5 B

约 8.9 倍。一个 8 槽的 LongAdder 光 Cell 就 2.3 KB 左右;如果你在 ConcurrentHashMap 里给每个键挂一个 LongAdder(源码注释推荐的频率统计写法),每个都长到 8 槽时,十万个键就是两百多 MB。这不是小数点后的差异,是选型时就要算的账。

顺带说一个测量口径:本机 jol-core 0.17 在没有 agent 的模式下把 Cell 估成 152 字节,明显低于实测的 289 字节,因为它只猜到字段前的一段间隙、没算尾部填充。这类工具的输出要拿分配量交叉验证,不能直接引用。

sum() 不是快照,reset() 不是清零

sum() 的 javadoc 原话是 “The returned value is NOT an atomic snapshot”。它不是快照,原因在实现里一目了然:先读 base,再逐个读 cells,这中间没有任何锁或版本校验。读 base 之后发生的更新,如果落在还没读到的 Cell 上,会被算进去;落在已经读过的 Cell 上,就永远丢了。

本机让 8 个线程各自增 500 万次,同时不停做"连续两次 sum()":

连续两次 sum() 采样对=730200 不相等=240460 后一次更小=0
最终 sum=40000000 期望=40000000

73 万对采样里有 24 万对不相等(32.9%)。对只增不减的计数器,后一次不会更小,所以这个偏差表现为"某一对读到了不同的历史时刻",而不是数据错乱。最终值仍然精确等于 4000 万——只要写停了,sum() 就是准的。

更危险的是把两个 LongAdder 相减。本机 8 线程同时 borrowed.increment() 和 returned.increment(),采样 borrowed.sum() - returned.sum():

采样=1581050 出现负的在借数=247827 最小值=-2700556

158 万次采样里有 24.8 万次算出负数,最小 -270 万。原因很简单:两次 sum() 不在同一时刻,读 borrowed 之后、读 returned 之前,别的线程可能完成了一批归还。两个弱一致的值相减,误差会叠加,甚至会翻符号。 这不是 bug,是把非快照 API 当快照用的后果。

reset() 的语义在 javadoc 里写得很克制:“only effective if there are no concurrent updates”。实现是先 base = 0,再逐个 c.reset(),中间没有任何原子性。我在 8 线程共 800 万次自增的同时调了 323 万次 sumThenReset(),累计采样加末尾值恰好等于 800 万,没有观测到丢失——但这只是窗口太小(纳秒级),不是保证。源码自己都说 “intrinsically racy”,别把它当"读取并清零"的原子操作。

结构性指标:把 CAS 失败次数数出来

吞吐量受机器负载影响太大,不能单独当证据。更稳定的结构性指标是"每成功自增一次,背后有几次 CAS 失败"。控制变量很简单:同一段手写 CAS 循环,只改写入目标——一个 AtomicLong,对比一个 8 槽的 AtomicLongArray(按线程 id 取模选槽),每线程 100 万次:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicLongArray;

public final class StripeFailProbe2 {
    static final int OPS = 1_000_000;

    static long failures(int threads, boolean striped) throws Exception {
        AtomicLong single = new AtomicLong();
        AtomicLongArray slots = new AtomicLongArray(8);
        long[] fails = new long[threads];
        CountDownLatch start = new CountDownLatch(1);
        Thread[] ts = new Thread[threads];
        for (int t = 0; t < threads; t++) {
            final int id = t;
            ts[t] = Thread.ofPlatform().start(() -> {
                long f = 0;
                try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                for (int i = 0; i < OPS; i++) {
                    long prev, next;
                    do {
                        prev = striped ? slots.get(id & 7) : single.get();
                        next = prev + 1;
                        if (striped ? slots.compareAndSet(id & 7, prev, next)
                                    : single.compareAndSet(prev, next)) break;
                        f++;
                    } while (true);
                }
                fails[id] = f;
            });
        }
        start.countDown();
        for (Thread th : ts) th.join();
        long total = 0;
        for (long f : fails) total += f;
        return total;
    }

    public static void main(String[] args) throws Exception {
        for (int t : new int[]{1, 4, 8, 16}) {
            long expected = (long) t * OPS;
            System.out.printf("threads=%2d 单槽 %.3f 次/op   8 槽 %.3f 次/op%n",
                    t, failures(t, false) / (double) expected, failures(t, true) / (double) expected);
        }
    }
}

本机输出(load average 73.9,10 核):

threads= 1 单槽 0.000 次/op   8 槽 0.000 次/op
threads= 4 单槽 1.080 次/op   8 槽 0.000 次/op
threads= 8 单槽 2.081 次/op   8 槽 0.000 次/op
threads=16 单槽 2.398 次/op   8 槽 0.243 次/op

单槽的失败次数随线程数上升:4 线程时每成功一次平均失败 1.08 次,8 线程 2.08 次,16 线程 2.40 次。8 槽在 8 线程时是 0,因为每个线程独占一槽;16 线程时两个线程挤一槽,才出现 0.243 次。这就是 LongAdder 的全部理由:不是让 CAS 变快,是让失败变少。 失败意味着重读、重算、重试,每一次都是实打实的指令和缓存行往返。

这里用的是固定 8 槽、按 id & 7 取模的理想化版本。真实的 LongAdder 用线程探针哈希选槽,槽数随竞争从 2 动态翻倍到 NCPU,而且槽位是懒创建的——8.3 拆它的引擎。

什么时候 LongAdder 反而不如 AtomicLong

先看高竞争下的对照。本机每档取 3 次的最好值,ns/op 按总操作数计(load average 65.5,10 核,数字只作结构参考):

threads=1  AtomicLong 7.57 ns/op   LongAdder 7.43 ns/op  加速 1.02x
threads=2  AtomicLong 18.97 ns/op  LongAdder 3.88 ns/op  加速 4.90x
threads=4  AtomicLong 34.89 ns/op  LongAdder 2.13 ns/op  加速 16.37x
threads=8  AtomicLong 53.58 ns/op  LongAdder 1.50 ns/op  加速 35.83x

单线程两者几乎一样(7.57 对 7.43),因为 LongAdder 也只是一次 casBase;线程一多,AtomicLong 的耗时随线程数线性上升,LongAdder 反而下降——每个线程写自己的槽,总耗时接近单线程。8 线程差 36 倍。

但下面这些场景,LongAdder 是错的选择:

  • 要读旧值做判断。LongAdder 没有 getAndIncrement() 返回旧值,没有 compareAndSet,只有 add/increment/sum。库存扣减、序列号、许可计数都不能用它。
  • 要把计数当同步条件。if (adder.sum() == 0) shutdown() 这类判断在并发下没有意义,sum() 随时在变,而且读的瞬间就可能已经过时。
  • 频繁读。每次 sum() 要遍历所有 Cell,8 槽就是 9 次 volatile 读。写多读少才划算;读比写还频繁时,AtomicLong 的一次读更便宜。
  • 内存敏感。289 字节一个 Cell,槽位只增不减。一次瞬时高并发把表撑到 8 槽,之后即使只剩一个线程,这 8 个 Cell 也一直占着。源码注释明确说不回收:长生命周期实例的竞争会复发,短生命周期实例不值得回收。
  • 需要精确的版本号。库存快照版本号必须单调且精确,LongAdder 的 sum() 不是快照,连"读到一个确定版本"都做不到。

把这几个边界收成一张表:

需求合适的选择原因
高并发累加、只读总和LongAdder分片写,写停止后 sum() 精确
要读旧值、要 CASAtomicLongLongAdder 没有读改写 API
非加法的聚合(最大值、最小值)LongAccumulator同一套 Striped64,但要求函数满足结合律
精确的在借数 / 序列号 / 版本号AtomicLong必须是一次原子读写
十万个键各挂一个计数先算内存每槽约 289 B,槽位不回收

LongAccumulator 值得单独说一句。它和 LongAdder 共用 Striped64,区别是构造时传一个 LongBinaryOperator:LongAdder.add(x) 等价于 accumulator.accumulate(x) 配 (a, b) -> a + b。因为它允许任意结合律函数,sum() 变成了 get(),语义同样是"读的过程中可能有更新没算进去"。用它求最大值时不能传 Math::max 之外的带副作用函数,也不能依赖调用顺序。

还要注意 LongAdder 的 intValue()/longValue()/doubleValue() 全都转调 sum():它们不是"读一个字段",是遍历所有 Cell。把 LongAdder 直接传给只认 Number 的日志或监控 API,每次格式化都会触发一次全表扫描。

一句话:LongAdder 是累加器,不是计数器对象。它的 API 表面上像计数器,语义上只承诺"写停止后总和正确"。

在 libraryd-conc 里落地

借阅指标是典型的写多读少:每次借书、还书、拒绝都加一,报表每分钟读一次。这类计数用 LongAdder;但库存快照版本号要精确,用 AtomicLong。两类状态放在同一个类里,边界正好对照:

package com.plume.conc.core;

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

/** 借阅事件的累加指标;版本号必须精确,所以单独用 AtomicLong。 */
public final class BorrowMeter {
    private final LongAdder borrowed = new LongAdder();
    private final LongAdder returned = new LongAdder();
    private final LongAdder rejected = new LongAdder();
    private final AtomicLong stockVersion = new AtomicLong();

    public void onBorrow()   { borrowed.increment(); }
    public void onReturn()   { returned.increment(); }
    public void onReject()   { rejected.increment(); }

    public long borrowed()   { return borrowed.sum(); }
    public long returned()   { return returned.sum(); }
    public long rejected()   { return rejected.sum(); }

    /** 库存快照版本号:读者用它判断"是不是同一个版本",必须精确。 */
    public long bumpStockVersion() { return stockVersion.incrementAndGet(); }
    public long stockVersion()     { return stockVersion.get(); }
}

注意我没有提供 outstanding() 方法。前面那组负数实验说明,borrowed.sum() - returned.sum() 在并发下会算出负数。如果业务确实要"当前在借数",正确做法是维护一个单独的 AtomicLong outstanding,借出时 incrementAndGet、归还时 decrementAndGet,让"借出减归还"在一次原子操作里完成;或者接受它是估算值,并在展示层夹到非负。把两个累加器相减,是最容易写、也最容易出负数的一种。

下面这段可以独立运行,复现"两个 sum 相减出负数":

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.LongAdder;

public final class OutstandingProbe {
    public static void main(String[] args) throws Exception {
        LongAdder borrowed = new LongAdder(), returned = new LongAdder();
        int threads = 8, perThread = 3_000_000;
        CountDownLatch start = new CountDownLatch(1);
        Thread[] ts = new Thread[threads];
        for (int t = 0; t < threads; t++) {
            ts[t] = Thread.ofPlatform().start(() -> {
                try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                for (int i = 0; i < perThread; i++) { borrowed.increment(); returned.increment(); }
            });
        }
        start.countDown();
        long samples = 0, negative = 0, min = 0;
        boolean alive = true;
        while (alive) {
            long outstanding = borrowed.sum() - returned.sum();
            samples++;
            if (outstanding < 0) { negative++; min = Math.min(min, outstanding); }
            alive = false;
            for (Thread th : ts) alive |= th.isAlive();
        }
        for (Thread th : ts) th.join();
        System.out.println("采样=" + samples + " 出现负的在借数=" + negative + " 最小值=" + min);
    }
}

什么时候不要用 LongAdder 做指标?需要"每个事件都能被准确归属到某个时间窗口"的时候。sumThenReset 不是原子的,清点和清零之间发生的更新可能既没进这一期、也没进下一期。要严格分期,就在窗口边界停写(切换到一个新的 LongAdder 实例),或者接受报表是近似值并在文档里写清楚。

常见错误与边界

第一,把 sum() 当 size() 做业务判断。第 5 章已看到 ConcurrentHashMap.size() 走 sumCount(),同样是 base 加计数单元的弱一致读。

第二,用两个 LongAdder 相减表达"差值"。前面实测 158 万次采样里 24.8 万次为负;要精确差值就用一个 AtomicLong 或 LongAccumulator。

第三,在流量中调 reset()。javadoc 明说只在没有并发更新时有效;sumThenReset() 也一样。

第四,以为槽位会缩回去。Cell[] 只扩不缩,一次高并发峰值会永久抬高这个实例的内存占用。

第五,以为 LongAdder 一定比 AtomicLong 快。单线程两者打平,读多写少时 LongAdder 的 sum() 反而更贵,内存还贵 9 倍。

第六,在 LongAccumulator 里传不满足结合律的函数。它和 LongAdder 共用 Striped64,更新顺序不确定;减法、字符串拼接这类不满足结合律的操作会得到不可复现的结果。

小结

LongAdder 用 base 加 cells 把"一个热点地址"拆成"N 个地址":无竞争时一次 casBase,有竞争时按线程探针散到各自的 Cell,扩容到 NCPU 为止。每个 Cell 用 @Contended 前后各填 128 字节,换来不伪共享,代价是约 289 字节一个、约为 AtomicLong 的 9 倍。sum() 顺序读 base 和所有 Cell,不是快照——本机 32.9% 的连续采样对不相等,两个累加器相减甚至会出现负数。reset() 只适合停写之后。

下一节打开 Striped64 的引擎:探针怎么生成、cellsBusy 为什么是一把自旋锁、longAccumulate 的三条分支如何决定"建槽、重试、扩容"。

阅读导航:上一节 8.1 AtomicInteger 家族的 CAS 路径 | 下一节 8.3 Striped64 与高并发计数设计

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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