《Java 并发编程·性能诊断与事故》前言:实验环境、工具与数据口径

《Java 并发编程·性能诊断与事故》前言:钉死本机的 JDK、Maven 与工具版本,交代 load average 常年 70–170 对计时的影响,给出 JMH、JCStress、JFR 的可用命令与实测事件名,说明 async-profiler 未安装、Lincheck 未执行,并定义本卷四类数据标注与五个指标口径。

这一篇解决什么问题

读一本讲性能与事故的书,最先要判断的不是内容对不对,是数据可不可信。同一个「P99 是 1.4 秒」,可能来自一台独占的压测机,也可能来自一台开着 IDE、跑着构建、load average 150 的笔记本。两者差三个数量级。

这一篇把环境、工具和口径定下来。读完你应该能:

  • 在自己的机器上复现本卷的实验,知道版本不对时哪些结论会失效;
  • 判断一篇里的数字属于哪一类(本机实跑、示例值、构造场景、按实现推导);
  • 用 JMH、JCStress、JFR 拿到本卷用到的那几份证据,也知道哪些工具本卷没有实跑。

机器与版本基线

本卷所有代码、命令、输出都来自同一台机器、同一套工具链:

Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8
Maven 3.9.12

依赖库的版本与卷一、卷二保持一致,跨卷引用代码时不会出现 API 差异:

组件版本本卷用途
JDKTemurin 25.0.5+8,--release 25全部代码与 JFR 事件
Maven3.9.12三个模块的构建
JCStress0.16第 8 章的竞态验证,JUnit Jupiter 5.14.4 做回归
JMH1.37第 2 章、第 5 章、第 13 章的基准
Lincheck2.44第 8.2 节,未在本机执行

java -version 的本机输出:

openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode, sharing)

环境变量按下面的方式设好,后文的命令都假设它们存在:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"

为什么把版本钉死

卷二的教训在这里同样成立,而且更严重。卷二讲的是「源码里怎么写的」,版本不对会读出不同的实现;本卷讲的是「运行时是什么表现」,版本不对会让同一段代码出现不同的故障形态。

一个具体的例子:偏向锁在 JDK 15 之后被逐步废弃、在更新的版本里彻底移除。如果你照着旧资料去分析「为什么这个 synchronized 块在单线程下几乎没有开销」,会得出「偏向锁生效了」的结论,而在本机的 JDK 25 上,-XX:+UseBiasedLocking 直接报 Unrecognized VM option。锁的升级路径只剩「无锁 → 轻量级锁 → 重量级锁」,第 3 章的锁竞争分析全部按这条路径展开。

另一处是虚拟线程的 pinning。JDK 24 起 synchronized 不再导致载体线程被固定,JDK 25 里剩下的 pin 场景主要是本地方法调用和 Object.wait。第 6.2 节的所有结论都基于这个前提,在 JDK 21 上不成立。

另外两条版本事实会影响写法。ScopedValue 在 JDK 25 已经转正,javac --release 25 不加参数即可编译运行,第 6.3 节用它传上下文时不带 preview。StructuredTaskScope 仍是 preview API,凡是用到它的代码围栏都会同时出现 --enable-preview,并标注「JDK 25 preview,未来版本 API 可能变化」。

这台机器的负载常态

这是本卷最重要的一段声明。这台机器是工作机,写作期间同时跑着别的进程。uptime 的常态:

 1:44  up 9 days, 19:07, 6 users, load averages: 151.22 133.51 118.32

load average 在 70 到 170 之间波动。10 核机器上的 load 150,意味着随时有一百多个可运行线程在排队等 CPU。这个事实带来三条硬约束。

第一,任何墙钟时间都不可复现。 同一段程序间隔五分钟跑两次,结果可能差一倍。本卷所有计时数字都会注明「单次运行、随调度波动、不作基准」。

第二,绝对吞吐没有意义,只有相对差异有意义。 「这个实现 496 万 ops/s」这种句子在本卷不会出现——同一个探针换个并发档位就只剩 59 万。出现的是「两者差一个数量级」,这个结论在 load 70 和 load 170 下都成立。

第三,涉及尾延迟的实验必须给对照组。 一次 P99.9 尖刺可能来自锁竞争,也可能来自 GC,也可能来自操作系统把线程调走了。只有同一时刻跑的对照组能区分。这也是为什么第 1.3 节要用并发阶梯而不是单点测量。

一个具体的例子说明这种噪声有多大。下面这个探针比较「一把全局锁」和「16 路分片锁」,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。这不是「并发越高越快」,而是样本被稀释、机器在抖、测量口径本身失效。

结论:本卷不做排行榜。凡是超过一个数量级的差异,我会给出机制解释;凡是只有一个数量级的,一律标注为「本轮观测,未重复验证」。

三个模块

工程结构沿用前两卷的约定,本卷新增第三个模块:

模块包职责
conc-corecom.plume.conc.core图书借阅领域、六个并发实现、可注入的故障点
conc-labcom.plume.conc.labJCStress、JMH、竞态复现、事故最小化
conc-opscom.plume.conc.ops指标采集、线程诊断、压测入口、报告(端口 8091)

conc-ops 的指标端口是 8091,刻意避开 linkd-v3 占用的 9093–9095。它用 com.sun.net.httpserver 暴露一个 /metrics 端点返回纯文本,不引 Micrometer 或 Prometheus 客户端——本卷不引第三方库,接自己的监控系统时按格式转换即可。

领域类型、实现类命名、默认参数与前两卷完全一致(quota = 5、通知扇出 mail/sms/push、压测档位 64/256/1024、虚拟线程实验 10 000/100 000)。跨卷引用 PooledLoanService 时,指的是卷一第 10 章那个实现,没有第二份。

工具矩阵

工具版本 / 来源本机状态本卷用法是否实跑
JMH1.37(Maven 中央仓库)已装,本地仓库有微基准与对照实验是
JCStress0.16(Maven 中央仓库)已装,本地仓库有竞态与可见性验证是
JFRJDK 25 内置可用锁竞争、pinning、GC、分配是
jcmd / jstackJDK 25 内置可用线程转储、堆转储、诊断命令是
vmstat / pidstat系统自带可用上下文切换、CPU 配额是
async-profiler4.x未安装锁与分配的火焰图否,只给命令
Lincheck2.44未安装,需 Kotlin 运行时线性一致性检查否,标注未执行

「是否实跑」这一列是这本书的诚实性底线。标「否」的工具,正文只给安装方式、命令和读图方法,不会出现任何自称来自它的输出。

JMH 与 JCStress 的最小工程

conc-lab 的 pom.xml 关键部分:

<dependencies>
  <dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
  </dependency>
  <dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
    <scope>provided</scope>
  </dependency>
  <dependency>
    <groupId>org.openjdk.jcstress</groupId>
    <artifactId>jcstress-core</artifactId>
    <version>0.16</version>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <configuration>
        <release>25</release>
        <proc>full</proc>
        <annotationProcessorPaths>
          <path>
            <groupId>org.openjdk.jmh</groupId>
            <artifactId>jmh-generator-annprocess</artifactId>
            <version>1.37</version>
          </path>
        </annotationProcessorPaths>
      </configuration>
    </plugin>
  </plugins>
</build>

<proc>full</proc> 这一行不能省。JDK 23 起 javac 默认不再隐式运行注解处理器,JCStress 的 @JCStressTest 靠它生成 TestList,少了这一行,编译能过、运行时报「找不到任何测试」。已在本机实测。

跑 JCStress 的主类是 org.openjdk.jcstress.Main,不是 jcstress-core 的某个 Runner。直接跑 jar:

java -cp conc-lab/target/classes:$HOME/.m2/repository/org/openjdk/jcstress/jcstress-core/0.16/jcstress-core-0.16.jar \
     org.openjdk.jcstress.Main --verbose

JMH 用 Maven 的 shade 打一个可执行 jar,Main-Class 是 org.openjdk.jmh.Main,本卷的基准都放在 com.plume.conc.lab.bench 包下。参数口径固定为 -f 1 -wi 3 -i 5 -r 1s,因为这台机器跑不起多 fork,多 fork 只会把噪声放大。

JFR:JVM 内置,但默认口径会骗你

JFR 是本卷的主力工具,因为它同时能给出锁、线程、GC、分配四类证据,且不需要额外安装。代价是它的默认阈值很高,高到会让你以为「没有问题」。

先把一个容易踩的坑说清楚:JDK 25 的 jfr 命令行没有 start 子命令。 它只有 print、view、summary、configure、metadata、scrub、assemble、disassemble。启动录制有两种方式:

# 方式一:启动时挂参数
java -XX:StartFlightRecording=filename=app.jfr,settings=profile,dumponexit=true -cp . Main

# 方式二:对运行中的进程
jcmd <pid> JFR.start name=probe settings=profile filename=/tmp/probe.jfr
jcmd <pid> JFR.dump name=probe filename=/tmp/probe.jfr
jcmd <pid> JFR.stop name=probe

阈值不调,等于没录

下面这个对照能说明问题。一个 8 线程抢同一把监视器锁的程序,每一轮临界区只有微秒级,先按默认 profile 配置录 2 秒:

java -XX:StartFlightRecording=filename=lock.jfr,settings=profile,dumponexit=true -cp . LockProbe
jfr view --width 100 contention-by-class lock.jfr

本机输出:

                                      Contention by Lock Class

Lock Class                                                            Count    Avg.     P90    Max.
--------------------------------------------------------------------- ----- ------- ------- -------
jdk.jfr.internal.PlatformRecorder                                         1 75.6 ms 75.6 ms 75.6 ms

只有一条,还是 JFR 自己的锁。看起来「这个程序没有锁竞争」。实际上 jdk.JavaMonitorEnter 的默认阈值是 20 ms,那把被抢了上万次的锁,单次等待从没超过阈值,一条都没进。

把阈值调到 0 再录一次:

java -XX:StartFlightRecording=filename=lock2.jfr,settings=profile,jdk.JavaMonitorEnter#threshold=0ms,dumponexit=true -cp . LockProbe
jfr view --width 100 contention-by-class lock2.jfr

本机输出:

                                      Contention by Lock Class

Lock Class                                                         Count      Avg.      P90      Max.
---------------------------------------------------------------- ------- --------- -------- -------
jdk.jfr.internal.PlatformRecorder                                      3    149 ms   441 ms  441 ms
java.lang.Object                                                 655,594 0.0673 ms 0.215 ms 18.2 ms

65 万个事件,平均 0.067 ms,P90 是 0.215 ms,最长 18.2 ms。同一次负载,两种口径,结论完全相反。

这条经验直接决定了第 3.1 节的写法:先确认阈值,再看数据。阈值 0 的代价是事件量爆炸,录制文件变大、录制本身产生开销,所以生产上一般取 1 ms 到 10 ms,具体取多少取决于你想抓什么。

事件名核对表

JFR 的事件名必须写对,写错了 --events 过滤器会静默返回空。本机用 jfr summary 核对过的、本卷会用到的名字:

事件名含义本卷章节
jdk.JavaMonitorEnter进入监视器时发生竞争3.1、4.1、11.1
jdk.JavaMonitorWaitObject.wait 的等待3.1、4.2
jdk.ThreadParkLockSupport.park 的阻塞3.1、5.3、10.3
jdk.SocketRead / jdk.SocketWritesocket 读写耗时12.2、7.3
jdk.VirtualThreadPinned虚拟线程被固定在载体上6.2
jdk.ExecutionSample采样栈2.2、7.3
jdk.ObjectAllocationSample分配采样13.1

jfr print --events <名字> file.jfr 用于看单条事件的细节,jfr view 用于看聚合视图。view 是较新的子命令,提供了 contention-by-class、contention-by-site、thread-count、pinned-threads、latencies-by-type 等几十个开箱视图,读起来比 print 快得多。本卷两者都用:找原因用 print,看分布用 view。

async-profiler:本机未安装

async-profiler 能出锁与分配的火焰图,是本卷很想用但没有在本机跑过的工具。它需要单独下载,且不同 JDK 版本要匹配。安装方式:

# Homebrew 是 4.5,官方 release 是 4.6(本机 brew info 与 GitHub API,2026-10-29)
brew install async-profiler
curl -L -o /tmp/ap.zip https://github.com/async-profiler/async-profiler/releases/download/v4.6/async-profiler-4.6-macos.zip
unzip -q /tmp/ap.zip -d /opt && export PATH="/opt/async-profiler-4.6/bin:$PATH"

本卷会给出的命令(asprof 是 3.x 之后的主命令):

# 锁竞争:谁在等锁、等在哪(attach 到运行中的进程,采样 30 秒)
asprof -e lock -d 30 -f /tmp/lock.html <pid>

# 分配:谁在分配、分配了什么
asprof -e alloc -d 30 -f /tmp/alloc.html <pid>

两条路线版本号不一致是真实的:Homebrew 停在 4.5,GitHub release 已是 4.6,书里提到的 4.6 都指官方 release。第 3.2 节只讲三件事:怎么装、命令怎么给、火焰图怎么读(锁视图里 [locked] 与 [unlock] 两个伪帧的含义)。不会出现任何声称来自它的火焰图或统计数字,因为本机没有跑过。你在自己机器上跑出来的图,和书里描述的形状对不对得上,需要你自己判断。

Lincheck:未在本机执行

Lincheck 用模型检验的方式找线性一致性违反,对并发容器的正确性验证很有价值。它依赖 Kotlin 运行时,conc-lab 是纯 Java 工程,引入它会把构建链拉长。本机没有安装,也没有执行过任何 Lincheck 测试。

依赖形态(写在独立的 lincheck-lab 子模块里,不影响主构建):

dependencies {
    testImplementation("org.jetbrains.kotlinx:lincheck:2.44")
}

第 8.2 节会给一个最小示例(一个自定义队列的三个操作、@Operation、@Validate),并说明它期望的输出形状。所有结论标注为「按 Lincheck 文档与论文推导,未在本机执行」。如果你跑通了并且和书里不一致,以你的结果为准。

数据口径:四种标注

本卷的每一个数字都必须属于下面四类之一,读者看到标注就知道该信到什么程度。

标注含义能不能引用
本机输出在这台 M1 Pro 上真实执行过,输出原样粘贴可复现,但受 load 影响,只信数量级
示例值为了让公式可算而设的假想输入不能,它只是算术演示
构造场景人为设计的故障现场,用于演示机制机制可信,数字不可信
按实现推导从源码或规范推出来的结论,没有实测逻辑可信,量级待验证

最容易出问题的是第三类和第四类的混淆。「把队列设成 200、下游注入 50 ms 延迟、256 并发」是一个构造场景,它能稳定复现「拒绝率随并发上升」这个形状,但拒绝率的具体数值取决于你机器的调度,和书里不会一样。

指标口径:五个数

第 12 章整章讲指标,这里先把本卷统一使用的五个口径定下来,避免各章各说各话。

指标定义常见误用
P50 / P99 / P99.9分位数,按完成时刻的耗时统计用平均值代替,掩盖长尾
吞吐单位时间内完成的请求数用「提交数」代替,把排队中的也算进去
拒绝率被拒绝数 / 提交数只统计 RejectedExecutionException,漏掉 CallerRunsPolicy 的降速
队列水位queue.size() / capacity无界队列上算这个值,永远接近 0,会误判成「很空」
活跃线程getActiveCount()当成「正在干活的线程数」,它包含正在等 I/O 的

采集代码只用 JDK API,不引第三方库:

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;

public record PoolSnapshot(
        int poolSize, int activeCount, int largestPoolSize,
        int queueSize, int queueRemaining, long completed, long submitted) {

    public static PoolSnapshot of(ThreadPoolExecutor pool) {
        BlockingQueue<Runnable> q = pool.getQueue();
        return new PoolSnapshot(
                pool.getPoolSize(),
                pool.getActiveCount(),
                pool.getLargestPoolSize(),
                q.size(),
                q.remainingCapacity(),
                pool.getCompletedTaskCount(),
                pool.getTaskCount());
    }

    public double saturation() {
        int capacity = queueSize + queueRemaining;
        return capacity <= 0 ? 1.0 : (double) queueSize / capacity;
    }
}

两个必须知道的边界。第一,remainingCapacity() 在无界 LinkedBlockingQueue 上返回 Integer.MAX_VALUE,saturation() 会算出接近 0 的值——这不是「队列很空」,是「没有上限」,这个指标在无界队列上无意义。第二,getTaskCount() 是累计提交数,getCompletedTaskCount() 是累计完成数,两者之差不等于当前排队数,因为还有正在执行的任务和被拒绝的任务。

事故场景的写法

本卷每一篇事故文都按同一个骨架写,格式固定,便于对照阅读:

症状:一句可证伪的描述 + 时间窗 + 影响面
证据:至少两份互相独立的观测(JFR + 转储 / 指标 + 日志 / 压测 + 火焰图)
根因:一条能推出症状的因果链,每一环对应卷一或卷二的具体规则
修复:一处改动 + 它切断了链条的哪一环
验证:同样的脚本、同样的并发、同样的时长,前后对照

举个例子(对应 10.1 节的场景):

症状:256 并发下,PooledLoanService 的 P99 在第 4 分钟从 45 ms 涨到 2.1 s,队列水位从 0.1 涨到 1.0 并保持,进程 RSS 每分钟增长约 180 MB。
证据:jcmd <pid> GC.heap_info 显示老年代持续增长且 Full GC 后不回落;jfr view contention-by-class app.jfr 显示锁竞争平稳;线程转储里 8 个 pool-1-thread-* 全部 RUNNABLE,栈顶在库存扣减。
根因:队列无界 + 生产速率长期高于消费速率,任务对象在堆上累积;线程没有阻塞,所以任何「线程卡住」的直觉都不成立。

这种写法的好处是:你可以只读「证据」一段,就判断自己的现场是不是同一个问题。

接下来

环境配好之后,从 1.1 开始。第 1 章讲性能模型,是全书唯一一章「不需要现场、只需要公式」的内容,适合在通勤时读。第 2 章讲基准与压测,是从「会跑」到「测得准」的分水岭。第 3 章开始进入真实诊断,需要你手边有一台能跑 Java 的机器。

时间非常有限、只想解决手头问题的人,直接去「目录与学习路径」,用「症状 → 章节」对照表跳读。那张表按现象而不是按知识点组织,是这本书最实用的入口。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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