这一篇解决什么问题
读一本讲性能与事故的书,最先要判断的不是内容对不对,是数据可不可信。同一个「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 差异:
| 组件 | 版本 | 本卷用途 |
|---|---|---|
| JDK | Temurin 25.0.5+8,--release 25 | 全部代码与 JFR 事件 |
| Maven | 3.9.12 | 三个模块的构建 |
| JCStress | 0.16 | 第 8 章的竞态验证,JUnit Jupiter 5.14.4 做回归 |
| JMH | 1.37 | 第 2 章、第 5 章、第 13 章的基准 |
| Lincheck | 2.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-core | com.plume.conc.core | 图书借阅领域、六个并发实现、可注入的故障点 |
conc-lab | com.plume.conc.lab | JCStress、JMH、竞态复现、事故最小化 |
conc-ops | com.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 章那个实现,没有第二份。
工具矩阵
| 工具 | 版本 / 来源 | 本机状态 | 本卷用法 | 是否实跑 |
|---|---|---|---|---|
| JMH | 1.37(Maven 中央仓库) | 已装,本地仓库有 | 微基准与对照实验 | 是 |
| JCStress | 0.16(Maven 中央仓库) | 已装,本地仓库有 | 竞态与可见性验证 | 是 |
| JFR | JDK 25 内置 | 可用 | 锁竞争、pinning、GC、分配 | 是 |
jcmd / jstack | JDK 25 内置 | 可用 | 线程转储、堆转储、诊断命令 | 是 |
vmstat / pidstat | 系统自带 | 可用 | 上下文切换、CPU 配额 | 是 |
| async-profiler | 4.x | 未安装 | 锁与分配的火焰图 | 否,只给命令 |
| Lincheck | 2.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.JavaMonitorWait | Object.wait 的等待 | 3.1、4.2 |
jdk.ThreadPark | LockSupport.park 的阻塞 | 3.1、5.3、10.3 |
jdk.SocketRead / jdk.SocketWrite | socket 读写耗时 | 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 的机器。
时间非常有限、只想解决手头问题的人,直接去「目录与学习路径」,用「症状 → 章节」对照表跳读。那张表按现象而不是按知识点组织,是这本书最实用的入口。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。