《Java 并发编程·性能诊断与事故》11.3 锁粒度、热点与拆分

热点到底是锁还是别的东西?本节先用 ThreadMXBean 与 JFR 定位锁竞争——实测 blockedCount 只认 synchronized、ReentrantLock 要去看 jdk.ThreadPark;再把单锁改成 16 路分片与 LongAdder,给出本机 8 线程下吞吐、p50、p99、p999 的真实对照,并说明 p99 为什么可能骗人、无锁化的边界在哪。

承接的问题

构造场景:libraryd-conc 的库存扣减在爆款书目上打到 16 并发。接口没有报错,CPU 只用了三成,吞吐卡在 2.4 万次/秒上不去,p999 却偶尔跳到 100 毫秒以上。线程转储里大部分工作线程都是 WAITING (parking),parking to wait for 指向同一个 ReentrantLock。

这就是典型热点:不是机器不够,是所有线程在排队等同一把锁。处置路径有三步,顺序不能反:

  1. 确认瓶颈真的是锁,而不是 GC、连接池或下游;
  2. 确认是哪种锁、谁在等、等了多久;
  3. 再决定是缩短临界区、分段、分片,还是改成无锁结构。

本节按这个顺序走。数据口径:Apple M1 Pro,10 核,32 GB,macOS 26.3,Temurin 25.0.5+8;实验期间 load average 在 70–170,系统实际可用的核心只有 2–3 个,所有分位都被操作系统调度污染,只适合同机同批对比,不作基准。

先定位:ThreadMXBean 能看见什么

ThreadMXBean 提供了 getBlockedCount() 与 getBlockedTime(),名字看起来正好是“锁竞争计数”。但它统计的是监视器(monitor)竞争,也就是 synchronized;ReentrantLock 的竞争走的是 LockSupport.park,不计入这两个字段。

下面的探针让 8 个线程用同一种锁跑 3 秒,然后在运行中采样每个线程的计数:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;

public final class ContentionProbe {
    static final Object MON = new Object();
    static final ReentrantLock LOCK = new ReentrantLock();
    static volatile long sink;

    public static void main(String[] args) throws Exception {
        boolean sync = args[0].equals("sync");
        ThreadMXBean mx = ManagementFactory.getThreadMXBean();
        mx.setThreadContentionMonitoringEnabled(true);
        AtomicBoolean stop = new AtomicBoolean();
        Thread[] ts = new Thread[8];
        for (int i = 0; i < ts.length; i++) {
            ts[i] = Thread.ofPlatform().name((sync ? "mon-" : "lock-") + i).start(() -> {
                while (!stop.get()) {
                    if (sync) {
                        synchronized (MON) { sink += burn(); }
                    } else {
                        LOCK.lock();
                        try { sink += burn(); } finally { LOCK.unlock(); }
                    }
                }
            });
        }
        Thread.sleep(3000);
        long count = 0;
        long time = 0;
        for (Thread t : ts) {
            ThreadInfo info = mx.getThreadInfo(t.threadId());
            count += info.getBlockedCount();
            time += info.getBlockedTime();
        }
        stop.set(true);
        for (Thread t : ts) t.join();
        System.out.printf("sync=%s blockedCountSum=%d blockedTimeSum=%dms%n", sync, count, time);
    }

    static long burn() {
        long x = 0x9E3779B97F4A7C15L;
        for (int i = 0; i < 3000; i++) {
            x ^= x << 13;
            x ^= x >>> 7;
            x ^= x << 17;
        }
        return x;
    }
}

本机真实输出(两次独立运行):

sync=false blockedCountSum=1 blockedTimeSum=0ms
sync=true  blockedCountSum=631 blockedTimeSum=21041ms

同一段临界区、同样的 8 个线程:synchronized 版本累计 631 次阻塞、21041 毫秒阻塞时间;ReentrantLock 版本只有 1 次、0 毫秒。

顺带对照 5.2 的探针:那一节用的是同一个 ThreadMXBean 的 getCurrentThreadCpuTime(),算的是 W/C;这一节用的是 getBlockedCount(),算的是竞争次数。同一个接口、不同字段,能回答的问题完全不同——选错字段,结论就会反过来。如果只看 blockedCount,会得出“ReentrantLock 没有竞争”的错误结论——而它恰恰是热点所在。这个坑在很多自研监控里都存在。

所以第一步的判据是:先看锁的类型,再选指标。synchronized 看 blockedCount/blockedTime;ReentrantLock、Semaphore、连接池看 JFR 的 jdk.ThreadPark,或者直接读线程转储里的 parking to wait for 地址分布。

把常见症状和第一个该查的证据列成表,能省掉很多来回:

症状第一个该查的证据常见误判
CPU 低、吞吐上不去、线程 parkingjdk.ThreadPark 的 parkedClass 与累计时长以为在等 IO
CPU 高、GC 频繁分配速率、GC 停顿,不是锁先加锁分段,白忙
只有少数线程在跑、其余 BLOCKED转储里 - locked 的持有者以为线程池太小
p99 正常、p999 抖动非公平锁的 barging、GC、调度只看 p99 得出“没问题”
分段后吞吐没变分片是否哈希不均、是否伪共享继续加分段数

JFR:两种锁的互补事件

JFR 把两类等待分成了两个事件,正好互补:

事件覆盖的锁关键字段默认阈值
jdk.JavaMonitorEntersynchronizedmonitorClass、duration20 ms
jdk.ThreadParkLockSupport.park(含 ReentrantLock、Semaphore)parkedClass、duration、timeout20 ms

默认 20 ms 的阈值会把大量短竞争过滤掉,定位热点时要显式调低:

java -XX:StartFlightRecording=filename=park.jfr,duration=60s,jdk.ThreadPark#threshold=1ms \
     -cp out com.plume.conc.lab.HotspotLab

jfr summary park.jfr | grep -E "ThreadPark|JavaMonitorEnter"
jfr print --events jdk.ThreadPark park.jfr | grep -E "parkedClass|duration|eventThread" | head -20

两个事件在 default.jfc 里默认就是开启的(enabled=true,阈值 20 ms),不需要额外打开,只需要调低阈值。本机对一个 8 线程的 ReentrantLock 靶子录了 8 秒,jfr summary 的真实结果(第一列事件名、第二列事件数、第三列数据量字节数):

 jdk.ThreadPark                            707         26705
 jdk.JavaMonitorEnter                        0             0

换成 synchronized 靶子,同一时长的结果是 jdk.JavaMonitorEnter 772、jdk.ThreadPark 0。事件内容(本机真实输出,节选):

jdk.ThreadPark {
  duration = 1.01 ms
  parkedClass = java.util.concurrent.locks.ReentrantLock$NonfairSync (classLoader = bootstrap)
  eventThread = "lock-2" (javaThreadId = 32)
  stackTrace = [
    jdk.internal.misc.Unsafe.park(boolean, long)
    java.util.concurrent.locks.LockSupport.park(Object) line: 223
    java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(...) line: 790
    java.util.concurrent.locks.ReentrantLock$Sync.lock() line: 154
  ]
}

读这份事件的四个要点:

  • parkedClass 直接给出锁的实现类,不用猜;
  • eventThread 的分布说明是“少数线程长期霸占”还是“所有线程均匀排队”;
  • duration 分布比平均值有用,热点通常表现为长尾而不是均值升高;
  • duration 小于阈值的事件不会出现,阈值不调低就会漏掉真正的高频短竞争。

要回答“拆分后有没有变好”,用的就是这两个事件的计数和 duration 分位;ThreadMXBean 只适合做 synchronized 的粗粒度巡检。

分段还是分片

两个词经常混用,但约束完全不同:

分段(striping)分片(sharding)
做法同一逻辑量拆成 N 份,各自加锁,读时汇总按业务键把请求路由到不同的锁/实例
典型实现LongAdder 的 Cell[]、16 路 ReentrantLock按 ISBN 哈希到 16 个队列或 16 个服务
读语义汇总值是弱一致快照单 key 内强一致,跨 key 无全局一致
扩容改数组长度,扩容瞬间要处理汇总一致性哈希或双写迁移
跨片操作不存在(各份独立)需要顺序 + 两阶段,回指 4.3

LongAdder 是分段的极致:它把计数拆到多个 Cell,每个 Cell 用 CAS 更新,热点线程落在不同的 Cell 上;sum() 遍历所有 Cell 相加,因此不是原子快照。它的源码里还有一个容易忽略的细节:Cell 用 @jdk.internal.vm.annotation.Contended 做了缓存行填充,否则相邻 Cell 落在同一缓存行,分段会退化成伪共享。自己写分段数组时也要做同样的填充(本节的实现用 long[16][8] 手工留出 64 字节)。

分片和分段的取舍可以压缩成一句话:能被合并的计数用分段,有业务语义的资源用分片。库存总数可以分段(LongAdder 求和),但“某个 ISBN 的库存不能为负”这种判断不能分段,必须落到单 key 的锁或数据库条件更新上。

分片数不是越多越好。用利特尔法则估一个下界:设单次临界区时长 S,目标吞吐 R,则需要的并行度约为 R × S,再除以单核能承载的并行度并留 2 倍余量。举例:S = 5 µs、R = 200 000/s,R × S = 1,意味着只要 1–2 路就能吃下;如果实际是 16 个线程同时抢,说明真正该做的是缩短 S,而不是加到 64 路。反过来,S = 50 µs、R = 200 000/s 时 R × S = 10,16 路才合理。这个估算假设每路都能独立并行,哈希不均时要打折。

实测:p99 到底有没有变好

同一段“临界区里做一点业务计算再自增”的逻辑,四种实现,8 个线程各跑 5 万次,每次操作记录自延迟后合并排序。分片实现的关键部分:

static final class Striped implements Counter {
    private final ReentrantLock[] locks = new ReentrantLock[16];
    private final long[][] cells = new long[16][8];    // 每格 64 字节,避免伪共享
    private final long[][] sinks = new long[16][8];    // 业务计算的结果,防止被优化掉

    Striped() {
        for (int i = 0; i < locks.length; i++) {
            locks[i] = new ReentrantLock();
        }
    }

    public void inc(long work, int slot) {
        int index = slot & 15;                          // 调用方按线程分配固定的 slot
        ReentrantLock l = locks[index];
        l.lock();
        try {
            sinks[index][0] += burn(work);              // 业务计算留在临界区内
            cells[index][0]++;                          // 只累加计数
        } finally {
            l.unlock();
        }
    }

    public long sum() {
        long total = 0;
        for (int i = 0; i < locks.length; i++) {
            locks[i].lock();
            try { total += cells[i][0] + (sinks[i][0] & 0); } finally { locks[i].unlock(); }
        }
        return total;
    }

    static long burn(long seed) {
        long x = seed;
        for (int i = 0; i < 20_000; i++) {
            x ^= x << 13;
            x ^= x >>> 7;
            x ^= x << 17;
        }
        return x;
    }
}

测量方法是每个线程在 inc 前后各取一次 System.nanoTime(),把 8 × 50 000 = 40 万个样本合并后排序取分位;nanoTime 包含线程被 OS 换出、等待 CPU 的时间,所以它测的是“端到端自延迟”,不是“锁内时间”。想看锁内时间,要在 tryLock 返回后、unlock 前各取一次,两者相减。两种口径不能混着比较。

复现命令(HotspotLab 是包含四种实现的完整类):

javac -d out HotspotLab.java
java -cp out HotspotLab

四种实现跑两轮,本机真实结果:

方案吞吐(次/秒)p50p99p999
单锁(非公平,默认)23,644 – 23,93741 – 42 µs49 – 52 µs86 – 119 ms
单锁(公平锁)22,019 – 22,488350 – 354 µs442 – 470 µs0.9 – 1.1 ms
16 路分片176,031 – 190,93040 µs59 – 73 µs0.34 – 0.94 ms
LongAdder169,761 – 171,41240 µs91 – 92 µs1.1 – 1.3 ms

这张表有三处必须直说,不能挑好看的讲:

第一,吞吐提升约 7.4–8.1 倍,但 p99 反而变差了。 非公平单锁的 p99 是 49–52 µs,分片是 59–73 µs。原因不是分片没效果,而是非公平锁的 p99 本身不可信:抢到锁的线程可以连续完成很多次操作(barging),这些操作的延迟接近 0,把 p99 分位拉低了;被 AQS 挂起的线程则在 p999 上暴露——86 到 119 毫秒。

第二,公平锁是让排队可见的对照组。 把单锁换成 new ReentrantLock(true) 后,p50 从 42 µs 涨到 350 µs,p99 到 442–470 µs,这时分片把 p99 降到 59–73 µs,是 6–7 倍的改善。也就是说:如果你要证明“拆分改善了排队延迟”,就必须用公平锁或者自己记录入队时间,否则 p99 会骗你。

第三,p50 的 40 µs 是这台机器的调度地板,不是锁的成本。 LongAdder 完全不互斥,p50 也是 40 µs——因为 load average 70–170,8 个线程抢 2–3 个核,每次操作都要等 CPU。所有分位都混入了调度延迟,这正是“同机同批对比、不作基准”的原因。也正因为有这层地板,分片把 p99 从 470 µs 压到 70 µs 之后就压不动了:剩下的 40–70 µs 主要是等 CPU,而不是等锁。如果换一台空闲机器,分片后的 p99 应该更接近 S(单次临界区时长)本身。

p999 上分片的表现也不稳定(0.34–0.94 ms),LongAdder 甚至比单锁还差(1.1–1.3 ms)。这不是分片无效,而是长尾事件太少(40 万次操作里只有 400 个样本),加上 GC 和调度噪声,单次测量的置信区间很宽。结论要用吞吐这种聚合量来下,p999 只用来发现“有没有病”,不用来比较“病轻了多少”。

无锁化的边界

看到分段有效,很容易下一步就想“干脆无锁”。先看四条边界:

  • 只适合可交换、可合并的操作。 计数、求和、最大值可以;if (stock > 0) stock-- 不行,因为判断和扣减必须原子。
  • sum() 不是快照。 并发更新时遍历 Cell 得到的是近似值;对账、限额这类要求精确的场景,要在低峰期取数或加版本校验。
  • CAS 在真热点上会退化。 一个 AtomicLong 被 16 个线程反复 CAS,失败重试会变成另一种形式的排队;LongAdder 就是为这个问题存在的。
  • 顺序语义不能丢。 借阅事件需要按发生顺序处理时,无锁队列的“最终一致”不满足要求,应该用单写者 + 有界队列,把并发收敛到一个线程。

把可选方案放在一起,取舍就清楚了:

方案适用代价不适用
单锁临界区短、竞争低竞争时排队已确认的热点
分段可合并的计数读是弱一致快照、要防伪共享需要复合条件判断
分片按业务 key 强一致跨片操作要排序与两阶段全局约束
LongAdder高并发计数sum() 非原子快照精确限额、实时对账
单变量 CAS状态机、标志位自旋失败、ABA多个字段的共同不变量

读多写少的场景还有一层选择:ReentrantReadWriteLock 或 StampedLock 的乐观读。它们的边界是“读操作足够长、写比例足够低”,否则读写锁的内部状态竞争会把收益吃掉;而且 StampedLock 不可重入、不支持条件变量,误用会写出比死锁更难查的活锁。没有实测数据之前,不要把“读多写少”当成使用读写锁的理由。

什么时候不要拆分: 临界区本身只有几条指令、竞争时间占比很低时,拆分会增加代码复杂度和读一致性成本,收益接近零;先用 JFR 证明 ThreadPark 的累计时长占比超过 10%,再动手。什么时候不要无锁化: 需要精确读、需要复合条件更新、需要严格顺序时,无锁结构的弱一致语义会把这个负担转嫁给业务代码,通常比一把锁更贵。

在 libraryd-conc 里落地

  1. 库存计数按 ISBN 分段。 每个 ISBN 一个 LongAdder(或 16 路 AtomicLong),reserve 先做配额与库存的原子判断,再累加;定期把增量快照落库,崩溃时用数据库值兜底。
  2. 借阅记录按 ISBN 分片。 16 个 ReentrantLock,index = Math.floorMod(isbn.hashCode(), 16);同一 ISBN 的借还与库存判断在同一片内完成。
  3. 跨片借阅复用 4.3 的顺序 + 超时。 一次借多本书时,先按 ISBN 排序再逐片加锁,tryLock 超时返回 CONFLICT。
  4. 指标。 ThreadPark 的累计时长、每把分片锁的等待计数、LongAdder.sum() 的调用频率都打到 8091;分片锁的等待分布如果仍然集中在一片,说明哈希不均或有超级热点,需要二级拆分。
  5. 验证。 同一压测脚本跑拆分前后两轮,比较吞吐、p999 和 ThreadPark 累计时长;只改一项,避免把 GC 或下游抖动算进收益。

常见错误与边界

  • 用 blockedCount 判断 ReentrantLock 竞争。 本机实测它几乎恒为 0,会漏掉真正的热点。
  • JFR 用默认 20 ms 阈值。 高频短竞争一条都抓不到;定位阶段调到 1 ms。
  • 分段数拍脑袋等于核数。 分片数应该按“同时竞争的线程数 / 单次临界区时长”来估,并留出哈希不均的余量。
  • 忘了填充缓存行。 相邻 Cell 共享缓存行时,分段吞吐可能还不如单锁。
  • 频繁调用 LongAdder.sum()。 每次求和都要遍历所有 Cell,高频调用会把分段打回单点。
  • 用 p99 证明拆分有效。 非公平锁的 p99 会被 barging 稀释;要么用公平锁做对照,要么看吞吐与 p999。
  • 拆分后热点漂移。 爆款 ISBN 全落在同一片时,16 路等于 1 路;需要按 key 再拆或做本地聚合。

小结

热点定位的第一步不是看 CPU,而是确认“等的是哪种锁”。本机实测:ThreadMXBean 对 synchronized 记录了 631 次阻塞、21041 毫秒,对 ReentrantLock 只有 1 次、0 毫秒;后者要靠 JFR 的 jdk.ThreadPark,8 秒录到 707 个事件、parkedClass 直接指向 ReentrantLock$NonfairSync。

拆分的收益要用对指标。本机 8 线程下,16 路分片把吞吐从 2.36 万提升到 17.6–19.1 万次/秒(约 7.4–8.1 倍);p99 从非公平单锁的 49–52 µs 变成 59–73 µs,没有变好,因为 barging 把 p99 稀释了;用公平锁做对照时,p99 从 442–470 µs 降到 59–73 µs。真正稳定的信号是吞吐和 p999(86–119 ms 降到 0.34–0.94 ms)。这台机器 load average 70–170,p50 的 40 µs 是调度地板,所有分位只适合同机同批对比。

下一节开始第 12 章:把这些等待变成可告警的指标——线程池的 active、queue、completed 到底该怎么采、怎么算。

阅读导航:上一节 11.2 分布式锁的租约与续期 | 下一节 12.1 线程与池的指标口径

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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