《Java 并发编程·JUC 源码与容器》8.3 Striped64 与高并发计数设计

进入 LongAdder 的引擎 Striped64:探针哈希如何把线程映射到 Cell、cellsBusy 为什么是一把不阻塞的自旋锁、longAccumulate 的建槽/重试/扩容三分支,以及 JDK 25 用 SharedSecrets 桥接 ThreadLocalRandom 探针的原因与手写简化版实测。

承接的问题

8.2 看到了 LongAdder 的外部行为:base 加 cells、sum() 不是快照、每个 Cell 约 289 字节。本节打开引擎,回答三个实现问题:线程怎么知道自己该写哪个 Cell?cellsBusy 保护的临界区里到底做了什么,为什么不用阻塞锁?longAccumulate 在一个循环里如何区分"建槽、重试、扩容"三种情况?

这三个问题合起来就是高并发计数器的设计骨架:用线程本地的哈希把写分散,用一个只保护结构变化的短临界区兜底,用重试代替排队。

探针:线程到槽位的映射

LongAdder.add 里有一行 int index = getProbe()。这个 probe 不是 Striped64 自己的字段,它存在 java.lang.Thread 里,由 ThreadLocalRandom 维护:

// JDK 25 源码摘录,已删减
static final int getProbe() {
    return U.getInt(JLA.currentCarrierThread(), PROBE);
}
static final int advanceProbe(int probe) {
    probe ^= probe << 13;   // xorshift
    probe ^= probe >>> 17;
    probe ^= probe << 5;
    U.putInt(JLA.currentCarrierThread(), PROBE, probe);
    return probe;
}

两个细节。第一,advanceProbe 是 xorshift:同一线程连续冲突时,低几位会变,从而映射到不同的槽。第二,读写的对象是 currentCarrierThread(),不是 Thread.currentThread()。源码在 localInit 里写得很清楚:“if virtual, share probe with carrier”——虚拟线程与它的载体线程共用同一个 probe。原因不难推:Cell[] 的槽位数按 CPU 核数封顶,映射的粒度本来就该是"谁在实际占用 CPU",而不是"逻辑上有多少个任务"。

Striped64 与 ThreadLocalRandom 不在同一个包,前者怎么读到后者的包内字段?靠 SharedSecrets 桥:

// JDK 25 源码摘录,已删减
interface JavaUtilConcurrentTLRAccess {
    int getThreadLocalRandomProbe();
    int advanceThreadLocalRandomProbe(int r);
}
// JDK 25 源码摘录,已删减
private static final JavaUtilConcurrentTLRAccess TLR =
    SharedSecrets.getJavaUtilConcurrentTLRAccess();
static final int getProbe() {
    return TLR.getThreadLocalRandomProbe();
}
static final int advanceProbe(int probe) {
    return TLR.advanceThreadLocalRandomProbe(probe);
}

为什么不把 Thread.threadLocalRandomProbe 改成 public?因为那是 java.lang.Thread 的内部布局,一旦公开就成了受兼容性约束的 API。SharedSecrets 是 JDK 内部的"好友通道":接口在 jdk.internal.access,实现注册在嵌套类 ThreadLocalRandom$Access 的静态块里;getJavaUtilConcurrentTLRAccess() 发现字段为空时会 Class.forName 触发这个静态块,再返回注册好的实例。只有 java.base 内的可信代码拿得到。

本机用反射读出真实数值(--add-opens java.base/java.lang=ALL-UNNAMED,只用于观察):

访问 TLR 之前 probe=0
ThreadLocalRandom.current() 之后 probe=-1640531527
调用 1000 次 nextInt 之后 probe=-1640531527(不变=true)
Striped64.advanceProbe(-1640531527) = 1359758873
调用后线程字段 probe=1359758873(已更新=true)
Striped64.getProbe() = 1359758873

-1640531527 就是 0x9E3779B9(黄金比例增量)的有符号表示,这是 PROBE_INCREMENT;调用 1000 次 nextInt 不会动 probe,说明 probe 和随机数生成是两条独立的流。probe 初值为 0,longAccumulate 的第一行 if (index == 0) { ThreadLocalRandom.current(); ... } 就是为它准备的:0 表示"还没初始化",不能拿来索引。

cellsBusy:只在结构变化时用的自旋锁

Striped64 只有一把锁,而且它不是 ReentrantLock:

// JDK 25 源码摘录,已删减
final boolean casCellsBusy() {
    return CELLSBUSY.compareAndSet(this, 0, 1);
}

它保护三件事:初始化 cells 数组、扩容数组、往空槽里安装新 Cell。三件事的共同点是"改结构",不是"改值"。正常的累加走 Cell.cas,完全不碰这把锁。

为什么用自旋 CAS 而不是阻塞锁?因为临界区只有几条指令:分配一个 Cell、写进数组、把 cellsBusy 置回 0。让拿不到锁的线程 park 再 unpark,开销比这段临界区本身还大。所以源码的处理是:拿不到锁就不拿,去试 base、去试别的槽、或者 advanceProbe 换一个槽;等锁的人不会排队,也不会阻塞。

释放锁用的是普通写 cellsBusy = 0,不是 CAS。这是安全的,因为只有持锁线程会写它;cellsBusy 是 volatile,这次写本身带 release 语义,把临界区里对 cells 的修改发布出去。所有安装/扩容路径都在锁内做一次 cells == cs 或 rs[j] == null 的复检:等锁期间数组可能已经被别人换掉,不复检就会写进一个已经废弃的数组。

longAccumulate 的三条分支

longAccumulate 是整个 Striped64 的主循环。先看进入函数和"槽位为空"的处理:

// JDK 25 源码摘录,已删减
final void longAccumulate(long x, LongBinaryOperator fn,
                          boolean wasUncontended, int index) {
    if (index == 0) {
        ThreadLocalRandom.current(); // force initialization
        index = getProbe();
        wasUncontended = true;
    }
    for (boolean collide = false;;) {       // True if last slot nonempty
        Cell[] cs; Cell c; int n; long v;
        if ((cs = cells) != null && (n = cs.length) > 0) {
            if ((c = cs[(n - 1) & index]) == null) {
                if (cellsBusy == 0) {       // Try to attach new Cell
                    Cell r = new Cell(x);   // Optimistically create
                    if (cellsBusy == 0 && casCellsBusy()) {
                        try {               // Recheck under lock
                            Cell[] rs; int m, j;
                            if ((rs = cells) != null &&
                                (m = rs.length) > 0 &&
                                rs[j = (m - 1) & index] == null) {
                                rs[j] = r;
                                break;
                            }
                        } finally {
                            cellsBusy = 0;
                        }
                        continue;           // Slot is now non-empty
                    }
                }
                collide = false;
            }

槽位为空时,先"乐观地" new Cell(x),再抢锁;抢到后复检槽位还是不是空的,是就把新 Cell 挂上去。new 放在锁外,是为了把可能触发 GC 的分配移出临界区;复检放在锁内,是因为等锁期间别人可能已经装好了。

接着是槽位非空时的 CAS 与扩容:

// JDK 25 源码摘录,已删减
            else if (!wasUncontended)       // CAS already known to fail
                wasUncontended = true;      // Continue after rehash
            else if (c.cas(v = c.value,
                           (fn == null) ? v + x : fn.applyAsLong(v, x)))
                break;
            else if (n >= NCPU || cells != cs)
                collide = false;            // At max size or stale
            else if (!collide)
                collide = true;
            else if (cellsBusy == 0 && casCellsBusy()) {
                try {
                    if (cells == cs)        // Expand table unless stale
                        cells = Arrays.copyOf(cs, n << 1);
                } finally {
                    cellsBusy = 0;
                }
                collide = false;
                continue;                   // Retry with expanded table
            }
            index = advanceProbe(index);

最后是表还没建时的初始化,以及退回 base:

// JDK 25 源码摘录,已删减
        else if (cellsBusy == 0 && cells == cs && casCellsBusy()) {
            try {                           // Initialize table
                if (cells == cs) {
                    Cell[] rs = new Cell[2];
                    rs[index & 1] = new Cell(x);
                    cells = rs;
                    break;
                }
            } finally {
                cellsBusy = 0;
            }
        }
        // Fall back on using base
        else if (casBase(v = base,
                         (fn == null) ? v + x : fn.applyAsLong(v, x)))
            break;
    }
}

这条链上的几处控制流值得逐条拆:

  1. wasUncontended 是一个"免扩容"标记。调用方 add 已经试过一次 Cell.cas 并失败,才带着 false 进来;这里先把标记翻成 true 然后继续循环,只换探针、不扩容。一次失败可能只是瞬时碰撞,不值得为它翻倍数组。
  2. collide 是"连续两次失败"的计数器。第一次 CAS 失败只把 collide 置 true;同一个线程在同一轮里再失败一次,才进入扩容分支。扩容是 O(n) 的 Arrays.copyOf,必须确认竞争是持续的。
  3. n >= NCPU || cells != cs 时只重置 collide,不扩容。槽位数封顶在 CPU 数,因为再多的槽也不会带来更多并行;cells != cs 表示数组已被别人换掉,当前这一轮是基于旧数组的判断,直接作废。
  4. 扩容只翻倍,不缩容。源码注释解释了不回收:长生命周期实例的竞争会复发,回收的槽还会被再次需要;短生命周期实例根本不值得回收。
  5. 每一轮末尾都 advanceProbe。同一槽位反复失败时,换一个哈希比原地重试更可能找到空闲槽。

一句话概括:先试一次,失败只换哈希;连续失败才扩容;到 CPU 数就停止扩容,改为在探针空间里找一个不冲突的映射。

手写一个 16 槽的 Striped64

把上面的骨架缩到最小:base + Cell[]、per-thread 探针、cellsBusy 自旋锁、翻倍扩容,最多 16 槽。为了让代码能放进一节,省略了 @Contended 填充和 doubleAccumulate:

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;

/** 简化版 Striped64:最多 16 个 Cell,探针冲突后扩容。省略了 @Contended 填充。 */
public final class MiniStriped2 {
    static final int MAX = 16;
    static final class Cell {
        volatile long value;
        Cell(long v) { value = v; }
        boolean cas(long c, long v) { return VALUE.compareAndSet(this, c, v); }
    }
    private volatile Cell[] cells;
    private volatile long base;
    private volatile int busy;
    private int expansions;
    private static final VarHandle CELLS, BASE, BUSY, VALUE;
    static {
        try {
            MethodHandles.Lookup l = MethodHandles.lookup();
            CELLS = l.findVarHandle(MiniStriped2.class, "cells", Cell[].class);
            BASE = l.findVarHandle(MiniStriped2.class, "base", long.class);
            BUSY = l.findVarHandle(MiniStriped2.class, "busy", int.class);
            VALUE = l.findVarHandle(Cell.class, "value", long.class);
        } catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); }
    }
    private static final ThreadLocal<Integer> PROBE = ThreadLocal.withInitial(() -> {
        int p = ThreadLocalRandom.current().nextInt();
        return p == 0 ? 1 : p;
    });
    static int advance(int p) { p ^= p << 13; p ^= p >>> 17; p ^= p << 5; PROBE.set(p); return p; }

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

    private void accumulate(long x, boolean wasUncontended, int index) {
        for (boolean collide = false;;) {
            Cell[] cs; Cell c; int n; long v;
            if ((cs = cells) != null && (n = cs.length) > 0) {
                if ((c = cs[(n - 1) & index]) == null) {
                    if (busy == 0 && BUSY.compareAndSet(this, 0, 1)) {
                        try {
                            Cell[] rs; int m, j;
                            if ((rs = cells) != null && (m = rs.length) > 0 &&
                                rs[j = (m - 1) & index] == null) { rs[j] = new Cell(x); return; }
                        } finally { busy = 0; }
                    }
                    collide = false;
                } else if (!wasUncontended) {
                    wasUncontended = true;
                } else if (c.cas(v = c.value, v + x)) {
                    return;
                } else if (n >= MAX || cells != cs) {
                    collide = false;
                } else if (!collide) {
                    collide = true;
                } else if (busy == 0 && BUSY.compareAndSet(this, 0, 1)) {
                    try { if (cells == cs) { CELLS.set(this, Arrays.copyOf(cs, n << 1)); expansions++; } }
                    finally { busy = 0; }
                    collide = false; continue;
                }
                index = advance(index);
            } else if (busy == 0 && cells == cs && BUSY.compareAndSet(this, 0, 1)) {
                try {
                    if (cells == cs) {
                        Cell[] rs = new Cell[2]; rs[index & 1] = new Cell(x);
                        CELLS.set(this, rs); expansions++; return;
                    }
                } finally { busy = 0; }
            } else if (BASE.compareAndSet(this, v = base, v + x)) {
                return;
            }
        }
    }

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

    public static void main(String[] args) throws Exception {
        int threads = Integer.parseInt(args[0]), ops = 1_000_000;
        MiniStriped2 m = new MiniStriped2();
        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 < ops; i++) m.add(1);
            });
        long t0 = System.nanoTime(); start.countDown();
        for (Thread th : ts) th.join();
        long expected = (long) threads * ops;
        System.out.println("threads=" + threads + " sum=" + m.sum() + " 期望=" + expected
                + " cells=" + (m.cells == null ? 0 : m.cells.length)
                + " expansions=" + m.expansions + " 耗时=" + (System.nanoTime() - t0) / 1_000_000 + "ms");
    }
}

本机运行(load average 68.96,10 核):

threads=8 sum=8000000 期望=8000000 cells=8 expansions=3 耗时=79ms
threads=16 sum=16000000 期望=16000000 cells=8 expansions=3 耗时=115ms

两次都是 3 次扩容(2→4→8),总和精确。重复运行时 cells 还会出现 4 和 16:扩容取决于实际碰撞,不取决于线程数。collide 要求同一个线程在同一个槽上连续失败两次才翻倍;线程被调度得越散,失败一次后换个探针就成功了,表就停在小尺寸。16 线程只扩到 8 槽并不奇怪,两个线程共用一个槽也仍然是正确的,只是多几次 CAS 重试。耗时受本机负载影响很大,这里不作吞吐结论——唯一必须在所有运行里成立的是 sum() == 期望。

在 libraryd-conc 里落地

分馆借阅统计里有两个典型指标:累计借出量(纯加法)和单次借阅批次的最大值(非加法聚合)。前者用 LongAdder,后者用 LongAccumulator,两者共用 Striped64:

package com.plume.conc.core;

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

/** 单个分馆的借阅统计。borrows 是累加,maxBatch 是求最大值。 */
public final class BranchStats {
    private final LongAdder borrows = new LongAdder();
    private final LongAccumulator maxBatch = new LongAccumulator(Math::max, 0L);

    /** batch 为本次借出的册数,必须为正。 */
    public void record(int batch) {
        if (batch <= 0) throw new IllegalArgumentException("batch must be positive");
        borrows.add(batch);
        maxBatch.accumulate(batch);
    }

    public long borrows()  { return borrows.sum(); }
    public long maxBatch() { return maxBatch.get(); }
}

LongAccumulator 的构造参数是 (LongBinaryOperator, identity),Math::max 满足结合律和交换律,所以更新顺序不影响结果;0L 是单位元。如果换成减法或字符串拼接,结果就依赖执行顺序,不能用在 Striped64 上。maxBatch 只增不减;要统计"每分钟最大批次",正确做法是每个窗口换一个新累加器,而不是试图清零——reset 在并发下不安全,8.2 已经说明。

库存快照版本号则不能用这两个类。它要求"读到的每个版本都是一个确定的历史状态",只能用 AtomicLong 的 incrementAndGet/get。LongAdder.sum() 连"某一时刻的值"都不保证,拿它当版本号会让读者看到重复或跳变的版本。

设计的代价与边界

第一,不要在生产代码里手写 Striped64。本节这段一百来行的版本省掉了 @Contended、doubleAccumulate、探针与 ThreadLocalRandom 的共享、以及各种复检;任何一处省错,得到的就是"大多数时候对"的计数器。JDK 的实现有 JCStress 级别的验证,直接用 LongAdder/LongAccumulator。

第二,LongAccumulator 的函数必须满足结合律。源码不保证更新顺序,也不保证函数只被调用一次(CAS 失败会重算)。带副作用的函数会在重试时被执行多次。

第三,槽位不回收。cells 只扩不缩,一次高并发峰值会永久抬高内存占用;每个槽约 289 字节。

第四,虚拟线程共享载体探针。JDK 25 的 probe 挂在 carrier thread 上,大量虚拟线程跑在少量载体上时,它们会共用同一批槽位,分片效果退化。CPU 密集的计数本来就不该放在虚拟线程里;如果确实要,接受碰撞率上升,或者改用每个任务自己的局部计数再汇总。

第五,它不能替代序列号。Striped64 没有"读旧值并加一"的原子操作,sum() 也不保证单调到可以用来排序;sum() 也不是快照,这条在 8.2 已用实测数据说明。

小结

Striped64 用三样东西把单点竞争摊开:线程探针(ThreadLocalRandom 维护、虚拟线程与载体共享、经 SharedSecrets 桥接)决定写哪个槽;cellsBusy 一把只在建表、扩容、装槽时使用的自旋锁保护结构变化;longAccumulate 用一个 collide 状态机区分"换探针重试"和"翻倍扩容",并在 NCPU 处封顶。手写简化版跑出的 8 槽/16 槽结果与总和都精确,但真正的工程结论是:理解它,然后使用它,不要重造它。

下一章从原子类转向 VarHandle:8.1 说 AtomicInteger 因为启动依赖没能用上它,9.1 就来看它到底提供了哪些访问模式。

阅读导航:上一节 8.2 LongAdder 的 Cell 分片 | 下一节 9.1 VarHandle 的访问模式

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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