承接的问题
构造场景:libraryd-conc 的库存扣减在爆款书目上打到 16 并发。接口没有报错,CPU 只用了三成,吞吐卡在 2.4 万次/秒上不去,p999 却偶尔跳到 100 毫秒以上。线程转储里大部分工作线程都是 WAITING (parking),parking to wait for 指向同一个 ReentrantLock。
这就是典型热点:不是机器不够,是所有线程在排队等同一把锁。处置路径有三步,顺序不能反:
- 确认瓶颈真的是锁,而不是 GC、连接池或下游;
- 确认是哪种锁、谁在等、等了多久;
- 再决定是缩短临界区、分段、分片,还是改成无锁结构。
本节按这个顺序走。数据口径: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 低、吞吐上不去、线程 parking | jdk.ThreadPark 的 parkedClass 与累计时长 | 以为在等 IO |
| CPU 高、GC 频繁 | 分配速率、GC 停顿,不是锁 | 先加锁分段,白忙 |
只有少数线程在跑、其余 BLOCKED | 转储里 - locked 的持有者 | 以为线程池太小 |
| p99 正常、p999 抖动 | 非公平锁的 barging、GC、调度 | 只看 p99 得出“没问题” |
| 分段后吞吐没变 | 分片是否哈希不均、是否伪共享 | 继续加分段数 |
JFR:两种锁的互补事件
JFR 把两类等待分成了两个事件,正好互补:
| 事件 | 覆盖的锁 | 关键字段 | 默认阈值 |
|---|---|---|---|
jdk.JavaMonitorEnter | synchronized | monitorClass、duration | 20 ms |
jdk.ThreadPark | LockSupport.park(含 ReentrantLock、Semaphore) | parkedClass、duration、timeout | 20 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
四种实现跑两轮,本机真实结果:
| 方案 | 吞吐(次/秒) | p50 | p99 | p999 |
|---|---|---|---|---|
| 单锁(非公平,默认) | 23,644 – 23,937 | 41 – 42 µs | 49 – 52 µs | 86 – 119 ms |
| 单锁(公平锁) | 22,019 – 22,488 | 350 – 354 µs | 442 – 470 µs | 0.9 – 1.1 ms |
| 16 路分片 | 176,031 – 190,930 | 40 µs | 59 – 73 µs | 0.34 – 0.94 ms |
LongAdder | 169,761 – 171,412 | 40 µs | 91 – 92 µs | 1.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 里落地
- 库存计数按 ISBN 分段。 每个 ISBN 一个
LongAdder(或 16 路AtomicLong),reserve先做配额与库存的原子判断,再累加;定期把增量快照落库,崩溃时用数据库值兜底。 - 借阅记录按 ISBN 分片。 16 个
ReentrantLock,index = Math.floorMod(isbn.hashCode(), 16);同一 ISBN 的借还与库存判断在同一片内完成。 - 跨片借阅复用 4.3 的顺序 + 超时。 一次借多本书时,先按 ISBN 排序再逐片加锁,
tryLock超时返回CONFLICT。 - 指标。
ThreadPark的累计时长、每把分片锁的等待计数、LongAdder.sum()的调用频率都打到 8091;分片锁的等待分布如果仍然集中在一片,说明哈希不均或有超级热点,需要二级拆分。 - 验证。 同一压测脚本跑拆分前后两轮,比较吞吐、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 线程与池的指标口径
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。