一次「所有指标都正常」的故障
构造场景:PooledLoanService 是卷一第 10 章的线程池分片实现——8 个固定的借贷处理线程,前面挂一个有界队列,库存扣减走 conc-core 里的共享台账。压测入口放在 conc-ops,256 并发,每轮 50 万次预约。
第 1 分钟 P99 是 38 ms。第 3 分钟开始往上爬。第 5 分钟停在 1.4 s。
三条曲线被同时拉出来看:
| 指标 | 读数 | 常见的第一反应 |
|---|---|---|
| CPU 利用率 | 31% | 算力还有富余,不是 CPU 瓶颈 |
| 老年代占用 / GC 停顿 | 12%,0 次 | 不是内存问题 |
| 日志 ERROR 条数 | 0 | 业务没报错,不是代码 bug |
三个「正常」拼在一起,反而把可能性收窄到一条:线程没有在干活,在等。 等锁、等队列、等一个永远不会来的信号。而「等」这件事,任何一条 CPU / GC / 日志曲线都看不见。
这一卷要处理的,就是这类「监控面板全绿、用户全红」的场景。
「等」是并发系统的默认状态
先把一笔账算清楚。一个请求在系统里活 1.4 秒,其中真正占用 CPU 的时间可能只有 3 毫秒。剩下 1397 毫秒在做什么?在等:等锁释放、等队列有空位、等连接池归还、等磁盘、等对端响应。CPU 利用率低不代表系统空闲,只代表没有线程在被调度。
用 ThreadMXBean 可以把这笔账直接读出来。下面这个程序起 8 个线程抢同一把监视器锁,1 秒后打印每个线程的阻塞次数与累计 CPU 时间:
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
public class WaitLedger {
static final Object POOL = new Object();
static long hits;
public static void main(String[] args) throws Exception {
Thread[] workers = new Thread[8];
for (int i = 0; i < workers.length; i++) {
workers[i] = Thread.ofPlatform().name("loan-" + i).start(() -> {
long deadline = System.nanoTime() + 1_500_000_000L;
while (System.nanoTime() < deadline) {
synchronized (POOL) { hits++; }
Thread.onSpinWait();
}
});
}
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
boolean cpu = mx.isThreadCpuTimeSupported();
boolean cont = mx.isThreadContentionMonitoringSupported();
if (cont) mx.setThreadContentionMonitoringEnabled(true);
Thread.sleep(1_000);
long[] ids = new long[workers.length];
for (int i = 0; i < workers.length; i++) ids[i] = workers[i].threadId();
for (ThreadInfo ti : mx.getThreadInfo(ids, cpu, cont)) {
System.out.printf("%-8s %-12s blocked=%-8d waited=%-4d cpuMs=%d%n",
ti.getThreadName(), ti.getThreadState(),
ti.getBlockedCount(), ti.getWaitedCount(),
cpu ? mx.getThreadCpuTime(ti.getThreadId()) / 1_000_000 : -1L);
}
for (Thread w : workers) w.join();
System.out.println("hits=" + hits);
}
}
本机输出(单次运行;同一时段 uptime 的 load average 在 118 到 151 之间,见后文):
loan-0 RUNNABLE blocked=13033 waited=0 cpuMs=385
loan-1 BLOCKED blocked=13578 waited=0 cpuMs=402
loan-2 BLOCKED blocked=13516 waited=0 cpuMs=393
loan-3 BLOCKED blocked=13493 waited=0 cpuMs=386
loan-4 BLOCKED blocked=13470 waited=0 cpuMs=390
loan-5 RUNNABLE blocked=13553 waited=0 cpuMs=399
loan-6 BLOCKED blocked=13322 waited=0 cpuMs=393
loan-7 BLOCKED blocked=13315 waited=0 cpuMs=397
hits=17441287
读法:1 秒里每个线程被阻塞约 1.3 万次,累计 CPU 时间约 390 ms,也就是说有六成左右的时间不在 CPU 上。而 waited=0 说明它们没有调用过 wait / await,纯粹是在抢锁。
这里必须马上补一句:这 610 ms 的非 CPU 时间里,有一部分是锁等待,也有一部分是这台机器被别的负载抢走的时间——同一时段 load average 在 118 到 151 之间。所以上面这组数只能说明「等」存在且量级可观,不能说明「锁导致了多少损耗」。要拆开这两者,得用第 3 章的 JFR 事件,或者第 1 章的对照组设计。
三类失效:算错、写慢、扛不住
并发问题不是一个维度上的问题。按后果和发现方式,至少可以分成三类,它们的诊断手段几乎不重叠。
| 类别 | 典型表现 | 怎么被发现 | 修复代价 | 本卷章节 |
|---|---|---|---|---|
| 正确性失效 | 数据错、丢更新、读到半初始化对象 | JCStress、压测对账、事故回溯 | 最高:已经写错的数据要修 | 8、9 |
| 性能失效 | RT 上涨、吞吐上不去、CPU 空转 | 压测、火焰图、JFR | 中等:改结构或调参 | 1、2、3、13 |
| 容量失效 | 队列积压、拒绝率飙升、OOM、雪崩 | 监控告警、故障注入 | 视情况:可能要改容量模型 | 5、10、14 |
正确性失效:最贵,也最安静
它不一定表现为报错。一个丢失的库存扣减,在日志里和一次成功扣减长得一模一样。这类问题的特点是低频、不可复现、代价滞后:压测跑一天不复现,上线三个月后对账才炸。
对付它的手段是构造执行序,不是加压。第 8 章的 JCStress 和第 9 章的可见性事故,讲的都是「把两个线程的交错点钉死」。
性能失效:最吵,最容易被误诊
RT 涨了,所有人都会看。但看到不等于看懂。最常见的误诊是把「排队」当成「变慢」:看到 P99 高就去优化单次执行,结果单次执行从 3 ms 优化到 2 ms,P99 一点没动——因为 1.4 秒里 1.39 秒是排队。
第 1 章的三节分别给三把尺子:Amdahl 与 USL 量扩展性上限,上下文切换量调度开销,分位数与并发阶梯量排队。第 2 章讲怎么把这三把尺子用对。
容量失效:最突然
队列积压到 OOM、线程池拒绝后上游重试放大、容器内存超限被杀。这类问题的共同点是有阈值,过阈值之后是非线性崩塌。第 5 章给容量公式,第 10 章给事故复现,第 14 章给容器里的参数基线。
三类失效之间会互相转化。容量失效的现场往往伴随性能失效的指标,而正确性失效经常在容量事故之后才被发现——因为降级逻辑绕过了本该走的校验。
规则为什么在压力下不够用
卷一把「怎么写得对」讲完了:happens-before 的八条规则、volatile 的语义、监视器锁的互斥与可见性、CAS 的原子性。这些结论跨版本成立,也是本卷全部推理的起点。但它们只回答「不出错」,不回答「扛得住」。
三个具体的落差:
互斥不等于吞吐。 synchronized 保证同一时刻只有一个线程在临界区,这个保证在 8 个线程抢和 800 个线程抢时一样成立。但后者的开销可能高一到两个数量级:未抢到的线程要进等待队列、要 park、要在释放时被唤醒并重新竞争。卷二第 1 章讲过这条路径为什么必须跨线程交接,本卷第 3、4 章讲它在生产上长什么样。
有界队列不消除排队,只决定在哪里拒绝。 把 LinkedBlockingQueue 的无界改成 ArrayBlockingQueue(200),内存不会涨了,但请求会在队列满的那一刻被拒。容量没变,只是把「慢」换成了「失败」。第 5.3 节把这条曲线画出来。
公平不等于可预测。 公平锁保证先来先得,代价是每次唤醒都要做一次线程交接,吞吐通常掉一个量级。而「可预测」在尾延迟上还有另一层含义:公平锁能把 P50 压平,却压不住一次 STW 或一次页缺失带来的长尾。第 4.2 节讲饥饿,第 12.3 节讲为什么告警要看分位而不是均值。
诊断闭环:五步,每一步都会失败
本卷每一篇事故文都走同一条链。写出来是为了让你在读到一半时知道作者走到哪一步了。
| 环节 | 要产出的东西 | 典型失败模式 |
|---|---|---|
| 症状 | 一句可证伪的描述 + 时间窗 + 影响面 | 把「慢」当症状。「慢」不是症状,「256 并发下 P99 从 38 ms 涨到 1.4 s,第 3 分钟起」才是 |
| 证据 | 至少两份互相独立的观测 | 采样时刻错。线程转储抓的是健康时刻,因为卡住时人还没登录上来 |
| 根因 | 一句能推出症状的因果链 | 把相关当因果。CPU 高和 RT 高同时出现,不代表前者导致后者 |
| 修复 | 一处改动 + 它为什么能切断因果链 | 修了症状。调大队列让拒绝延后,根因还在 |
| 验证 | 对照组 + 同一口径的前后数据 | 没有对照组,或者只跑了一次 |
五个环节里最容易糊弄的是第五步。修复上线后 RT 恢复了,可能只是因为流量低谷到了。本卷要求每篇的验证段必须包含「同样的压测脚本、同样的并发档位、同样的时长」,缺一不可。
还有一条:证据必须是可复现的产物。一段口述的「当时看到线程都在等锁」不算证据,一份带时间戳的 jcmd Thread.print -l 输出算。
为什么本书的数字不能当基准
这台机器是 Apple M1 Pro、10 核、32 GB,但它是工作机,不是实验机。写作期间 uptime 的常态是这样:
1:44 up 9 days, 19:07, 6 users, load averages: 151.22 133.51 118.32
load average 在 70 到 170 之间波动是常态。在这种机器上做计时,结果会自相矛盾。下面这个探针比较「一把全局锁」和「16 路分片锁」在同一并发下的延迟分布,同一份代码、同一台机器、前后脚跑不同档位:
static void run(String name, int conc, int stripes) throws Exception {
Object[] locks = new Object[stripes];
for (int i = 0; i < stripes; i++) locks[i] = new Object();
long[] counts = new long[stripes];
long[] lat = new long[OPS];
ExecutorService pool = Executors.newFixedThreadPool(conc);
CountDownLatch done = new CountDownLatch(conc);
try {
long t0 = System.nanoTime();
for (int t = 0; t < conc; t++) {
final int id = t;
pool.execute(() -> {
int per = OPS / conc;
java.util.SplittableRandom rnd = new java.util.SplittableRandom(id);
for (int i = 0; i < per; i++) {
int slot = (int) (rnd.nextLong() & (stripes - 1));
long s = System.nanoTime();
synchronized (locks[slot]) { counts[slot]++; }
lat[id * per + i] = System.nanoTime() - s;
}
done.countDown();
});
}
done.await();
long elapsed = System.nanoTime() - t0;
long[] sorted = lat.clone();
Arrays.sort(sorted);
System.out.printf("%-12s p50=%5.0f us p99=%7.1f us p999=%8.1f us%n",
name, sorted[sorted.length / 2] / 1e3,
sorted[(int) (sorted.length * 0.99)] / 1e3,
sorted[(int) (sorted.length * 0.999)] / 1e3);
} finally {
pool.shutdown();
}
}
64 并发那一轮的输出:
single-lock conc=64 ops=200000 wall= 40.3 ms thr= 4960574 op/s p50= 0 us p99= 3.8 us p999= 1608.0 us
striped-16 conc=64 ops=200000 wall= 28.4 ms thr= 7031123 op/s p50= 0 us p99= 1.0 us p999= 1.5 us
看起来是个漂亮的结论:P99.9 差一千倍。但把并发调到 1024 再跑,单锁那一档的 P99.9 反而降到了 5.9 us。这不是「并发越高越快」,是 1024 个线程把慢样本稀释掉了,加上机器本身在抖。三个档位放在一起,任何「A 比 B 快 N 倍」的结论都不成立。
所以本卷对数字有四条硬规矩:
- 任何墙钟时间都注明「单次运行、随调度波动、不作基准」;
- 只用数字说明结构性差异(一个数量级、两个数量级),不说「12.3 万 ops/s」这种精确值;
- 引用单次计时的地方,必须同时给出对照组;
- 拿不到稳定数据的结论,改写成「按实现推导」,并标明推导依据。
五部分与十五章
本卷 50 篇:序、前言、目录 3 篇,正文 15 章 45 节,附录 A/B 2 篇。
| 部分 | 章 | 主题 | 读完能回答 |
|---|---|---|---|
| 一 | 1–3 | 性能模型、基准与诊断工具 | 这台机器的上限在哪、数字可不可信、卡在哪一行 |
| 二 | 4–6 | 活性、容量与规模化 | 为什么不走了、池该多大、虚拟线程能不能救 |
| 三 | 7–9 | 模型对比、正确性测试与可见性事故 | 该不该换模型、怎么证明它是错的、读到旧值怎么办 |
| 四 | 10–12 | 队列、锁边界与可观测性 | 积压从哪来、锁和事务怎么叠加、什么该报警 |
| 五 | 13–15 | GC、容器与工程规范 | 分配怎么影响停顿、容器里参数怎么给、怎么验收 |
顺序是按「先量、再救、后防」排的。第一部分建立度量能力,没有它后面全是猜;第二部分处理最紧急的三类现场;第三部分回到正确性,因为很多「性能问题」最后查出来是可见性 bug;第四、五部分是把前面所有结论固化成规范和基线。
主线:libraryd-conc 在本卷怎么变
前两卷的 libraryd-conc 是一个图书借阅服务,六个并发实现从「故意有竞态」一路演进到「虚拟线程」。本卷不改业务,改的是观察它的方式:新增第三个模块 conc-ops。
| 模块 | 包 | 本卷的用途 |
|---|---|---|
conc-core | com.plume.conc.core | 六个实现保持不变,只增加可注入的故障点(延迟、锁顺序、队列容量) |
conc-lab | com.plume.conc.lab | JCStress 与 JMH 实验、竞态复现、事故最小化用例 |
conc-ops | com.plume.conc.ops | 指标采集(端口 8091)、线程诊断、压测入口、报告生成 |
选三个实现当事故载体,不是随便挑的:
PooledLoanService:线程池 + 有界队列,天然适合做队列积压(10.1)、拒绝雪崩(10.2)、池容量(5.2)的现场。LockedLoanService:ReentrantLock+ 条件队列,适合做锁竞争(3.1)、死锁(4.1)、锁顺序(4.3)、锁与事务叠加(11.1)。VirtualLoanService:虚拟线程 + 连接池,适合做规模化(6.1)、pinning(6.2)、上下文传递(6.3)。
所有事故都是构造场景。 本卷不会出现「某公司线上事故」这种写法,也不会编造一个不存在的团队。每一篇的事故都写成「把 PooledLoanService 放到 256 并发下、队列容量设为 200、下游延迟注入 50 ms」,你可以在自己的机器上原样重放。
三卷边界:本卷不重讲什么
三卷按能力切,不按知识点切。同一件事在三卷里的角度不同,本卷明确不做四件事:
- 不重推 happens-before。 卷一的八条规则是本卷全部归因的依据,需要时用一句话回指,例如「
stock的可见性由卷一 3.2 的监视器规则保证」。 - 不重讲 JUC 实现。 为什么
ReentrantLock的唤醒要跨线程交接、ThreadPoolExecutor的ctl怎么编码状态,卷二已经讲透。本卷只讲这些实现对故障形态的影响。 - 不写框架集成。 不引 Spring、Netty、gRPC、Resilience4j,不写 Reactor 的代码依赖。第 7 章对比响应式时只做模型对照。
- 不做工具说明书。 每个工具的用法只讲到「能拿到本节需要的那份证据」为止,完整命令表在附录 A。
本书不承诺什么
- 不承诺数字可横向引用。 理由见上文,一台 load 150 的笔记本给不出可复现的基准。
- 不承诺覆盖全部故障类型。 数据库死锁、网络分区、时钟回拨只在第 11 章作为边界提到,它们的主战场不在 Java 并发。
- 不承诺每个实验都能一键跑通。
async-profiler本机未安装,只给安装方式与命令;Lincheck 需要 Kotlin 运行时,本机未执行,相关结论标注为「未在本机执行」。 - 不承诺事故复盘等同于真实事故。 构造场景能复现机制,复现不了真实系统里的信息缺失和人为判断失误。
怎么开始
已经读过卷一和卷二主干的人,直接进「前言」,把工具链和口径对齐,然后从 1.1 开始。
只想解决手头问题的人,翻到「目录与学习路径」,用「症状 → 章节」对照表跳读。三条路径的取舍在那篇里讲。
想先确认这本书值不值得读的人,读 3.1:那一节用 JFR 抓一次真实的锁竞争,命令、事件名、输出都在,你可以在自己机器上跑出同一条记录。如果对得上,后面的章节也值得信任。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。