《Java 并发编程·性能诊断与事故》序:规则在压力下才见真章

《Java 并发编程·性能诊断与事故》开篇:从一个 CPU 只有三成、GC 正常、日志无异常却 P99 涨了三十倍的构造场景出发,分清正确性、性能、容量三类失效,给出从症状到验证的五步诊断闭环,交代三卷边界与本卷构造事故的口径,并说明为什么本书的数字只讲结构性差异。

一次「所有指标都正常」的故障

构造场景: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 倍」的结论都不成立。

所以本卷对数字有四条硬规矩:

  1. 任何墙钟时间都注明「单次运行、随调度波动、不作基准」;
  2. 只用数字说明结构性差异(一个数量级、两个数量级),不说「12.3 万 ops/s」这种精确值;
  3. 引用单次计时的地方,必须同时给出对照组;
  4. 拿不到稳定数据的结论,改写成「按实现推导」,并标明推导依据。

五部分与十五章

本卷 50 篇:序、前言、目录 3 篇,正文 15 章 45 节,附录 A/B 2 篇。

部分章主题读完能回答
一1–3性能模型、基准与诊断工具这台机器的上限在哪、数字可不可信、卡在哪一行
二4–6活性、容量与规模化为什么不走了、池该多大、虚拟线程能不能救
三7–9模型对比、正确性测试与可见性事故该不该换模型、怎么证明它是错的、读到旧值怎么办
四10–12队列、锁边界与可观测性积压从哪来、锁和事务怎么叠加、什么该报警
五13–15GC、容器与工程规范分配怎么影响停顿、容器里参数怎么给、怎么验收

顺序是按「先量、再救、后防」排的。第一部分建立度量能力,没有它后面全是猜;第二部分处理最紧急的三类现场;第三部分回到正确性,因为很多「性能问题」最后查出来是可见性 bug;第四、五部分是把前面所有结论固化成规范和基线。

主线:libraryd-conc 在本卷怎么变

前两卷的 libraryd-conc 是一个图书借阅服务,六个并发实现从「故意有竞态」一路演进到「虚拟线程」。本卷不改业务,改的是观察它的方式:新增第三个模块 conc-ops。

模块包本卷的用途
conc-corecom.plume.conc.core六个实现保持不变,只增加可注入的故障点(延迟、锁顺序、队列容量)
conc-labcom.plume.conc.labJCStress 与 JMH 实验、竞态复现、事故最小化用例
conc-opscom.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 抓一次真实的锁竞争,命令、事件名、输出都在,你可以在自己机器上跑出同一条记录。如果对得上,后面的章节也值得信任。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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