性能剖析与压测方法论JMH、async-profiler 与分层瓶颈定位的可复现实验性能问题最容易被处理坏的地方不是工具不会用而是顺序错了还没确认慢在哪一层就先换线程池参数还没测过基线就开始“优化”拿一次接口耗时当作结论改完再看一眼“好像快了”。这类操作往往能讲出一个自洽的故事却经不起复测也无法告诉别人改动到底改变了什么。本文围绕读者要完成的一件事展开面对一个说不清原因的变慢现象建立一条从现象到证据、从证据到改动、从改动到量化的可复现路径。工具只服务于这条路径包括微基准工具 JMH、采样剖析器 async-profiler以及压测本身。文中示例按 Java 21、Spring Boot 3.5 的环境描述代码是完整示例但本文没有在本次写作过程中实际运行任何程序所有“预期可观察结果”都应视为需要你自己复现验证的预期而不是已实测数据。一、先把问题写成可以被推翻的句子“系统变慢了”不是问题是感受。能被观测推翻的句子至少包含四要素在什么条件下、哪个指标、从多少变成多少、和什么比较。例如“在 8 个并发客户端、每请求返回 50 条记录的条件下接口 P99 延迟从 40ms 升到 180ms而 CPU 利用率稳定在 30% 以下单机吞吐没有上升。”这句描述已经排除了“机器打满导致的排队”把注意力推向锁竞争、下游等待或 GC 停顿。反过来看一个不能被推翻的句子长这样“最近系统有点慢优化一下。”它没有条件没有指标没有基线与对比也没有时间范围。你无法据此判断改动是否成功因为失败与成功可以用同一句模糊的话来收尾。写作排查记录时第一个有价值的产出往往不是结论而是把这句话改写成上面那种可以被证伪的形式并标注“我还不确定的部分”。分层排查的出发点是一个请求的耗时可以被拆成若干段每段的主导因素不同。常见分层如下这是工程上的划分不是严格分类用户视角延迟 ├─ 客户端/网络段 连接建立、TLS、跨机房 RTT、带宽 ├─ 容器与内核段 CPU 限流、cgroup 配额、上下文切换、软中断 ├─ JVM 运行时段 GC 停顿、JIT 预热、类加载、安全点 ├─ 应用线程段 锁竞争、线程池排队、同步阻塞调用 └─ 下游资源段 数据库慢查询、连接池耗尽、远程调用超时这张图的价值在于每往下一层你需要的观测手段就不同。客户端测出来的延迟无法直接告诉你是不是 JVM 的 GCJVM 的火焰图也看不到数据库执行计划。很多“排查了一天没结论”的情况实际是把不同层的证据混用了。比如拿火焰图里很宽的socketRead去论证“代码写得差”其实那一层只说明请求在等下游返回真正的解释要在数据库或远程服务那一层去找。因此动工具之前先回答三个问题现象是延迟升高、吞吐下降、错误率上升中的哪一类三者根因常常不同。现象是持续存在还是周期性出现周期性往往指向 GC、定时任务、缓存集中失效或限流窗口。现象在单机复现还是只在集群出现只在集群出现就先怀疑限流、负载均衡倾斜与共享资源。这三个问题的作用是缩小证据的搜索范围而不是直接给出答案。比如第 2 问回答“周期性出现、周期约等于 100ms 的若干倍”这只是一个线索指向限流窗口或调度节拍仍需要用 cgroup 计数或采样数据去证实贸然把它当成结论就会变成“看起来像限流于是改配额”改完可能只是让周期变化问题仍然存在。还有一层常被忽略的区分单次请求变慢与整体吞吐变慢不是同一件事。单次请求变慢而吞吐不变可能是个别请求命中了慢路径、锁争用或下游抖动吞吐下降而单次延迟尚可可能是线程池饱和导致排队、连接池不够、或入口被限流。把两类现象混在一句话里描述后面的观测手段就选不对。二、环境与版本边界本文所有机制讨论都限定在以下环境范畴超出范围需要重新验证项目本文假设为什么重要JDKJava 21LTSGC 日志格式、JFR 能力、JMH 依赖版本都有变化框架Spring Boot 3.5自动配置的线程池与指标暴露方式随版本演进剖析器async-profiler 3.x该工具与内核 perf 事件、JVM 版本存在兼容要求微基准JMH 1.37 及以上注解与Fork/State行为在旧版本有差异运行形态容器cgroup v2CPU 限流识别依赖 cgroup 暴露的指标这里要明确区分三类陈述官方保证如 JMH 官方文档对Blackhole与死代码消除的说明、特定条件下的行为如某次采样得到的火焰图只代表采样窗口、工程建议如“先用 JFR 粗定位再上采样剖析”。官方文档与发行说明是首选依据厂商博客与个人经验可作为线索但不应作为结论的唯一支撑。由于本次提供的资料区没有给出可引用的节选本文不虚构具体链接与版本号细节涉及具体参数名、镜像标签与工具栏位时请以对应版本的官方文档为准并在你的环境中实测确认。一个容易被忽略的边界DEBUG 与生产环境的结论不能互推。开启 JFR、采集堆栈、拉高日志级别都会改变系统行为容器 CPU 配额不同同样的代码可能一边跑得顺、一边被限流。复现实验必须把环境写成清单否则“我这里没复现”只是信息缺失不是反驳。环境清单至少应包含JDK 发行版与版本、容器 CPU 配额与周期、内存上限、宿主机核数与型号、压测客户端所在机器规格与网络位置、数据规模与预热方式。少写一项别人的复现结果就可能落在完全不同的区间里而你还以为差异来自代码。三、核心机制一微基准为什么会骗人微基准microbenchmark指只测一段很小代码的方法比如“字符串拼接哪种写法快”“这个锁实现的开销是多少”。它看起来最简单实际是最容易得到错误结论的测量形式。原因有四个层次。第一层是测量本身有开销。你在循环里调用System.nanoTime()记录时间它本身就是一次系统调用耗时可能与待测方法同量级。测出来的差值里混进了计时成本。第二层是 JIT 编译改变了代码。Java 方法在解释执行、C1 编译、C2 编译三个阶段的行为不同。一个跑 100 次的方法可能大部分时间在解释执行测出来的是“JIT 还没优化的版本”而不是你关心的稳态性能。第三层是死代码消除。如果计算结果没有被使用编译器可以把整个计算删掉。你测到的“极快”可能因为算法根本没执行。JMH 用Blackhole黑洞对象消费结果来对抗这一点把返回值交给一个不会被优化掉的对象编译器就无法证明结果无用。第四层是常量折叠与循环展开。如果你的输入是编译期可见的常量编译器可能直接在编译期算出答案如果你用同一个对象反复调用缓存效应和分支预测会把结果美化。JMHJava Microbenchmark Harness是 OpenJDK 提供的微基准框架它解决的问题正是“让基准测试的噪声可解释”。它负责多轮预热、多 fork 隔离 JVM 状态、控制并发与状态共享并把统计结果以平均值、分位数、置信区间输出。注意JMH 让测量更可信但不能让一个错误的问题变正确。如果你测的场景本身不构成线上瓶颈测得再准也只是把无关紧要的数字变得更精确。JMH 的几个关键概念先用白话解释Fork分叉每个 fork 启动一个全新的 JVM 跑一组迭代。因为 JIT 编译状态、堆布局会互相影响不同基准放在同一个 JVM 里可能互相污染。Fork(1)表示至少一个独立 JVM。Warmup预热正式测量前先跑若干轮让 JIT 完成编译、缓存就位。预热轮的结果不计入统计。Mode模式Throughput测单位时间完成的操作数AverageTime测单次平均耗时SampleTime采样并给出分位数。选错模式会得到无法比较的数字。State状态State(Scope.Thread)表示每个线程一份实例适合测无共享的路径Scope.Benchmark表示所有线程共享一份用来暴露竞争。这几个概念之间是互相配合的不是可选项菜单缺少 fork多个基准会在同一 JVM 里共享 JIT 与堆状态缺少预热你测到的是编译前的半成品状态作用域选错你测的根本不是想测的那条路径模式选错你拿到的数字连单位含义都可能被误读。把它们一起用对才谈得上“这个基准的噪声是可解释的”。另外要警惕“微基准测出了差异所以线上慢就是它”的跳跃。微基准回答的是隔离条件下某段代码的相对开销它不能回答这段代码在整条请求链路上占多少比例。一个纳秒级的差异在毫秒级的请求里可能完全不构成问题反过来一个在微基准里看不出差异的路径在真实并发与资源限制下可能成为排队点。四、核心机制二采样剖析器如何变成火焰图async-profiler 是一个低开销的 Java 采样剖析工具它解决的问题是“我无法给每一次方法调用都打点计时但我可以按固定频率偷看当前线程在干什么”。它结合了两种事件来源基于AsyncGetCallTrace的 Java 栈采样以及基于perf_events的原生栈与内核采样。官方强调它的低开销与低 safepoint 偏差但这些保证都有前提采样频率、JVM 版本、容器权限都会影响结果。一个采样剖析器的工作方式可以这样理解每隔一小段时间如 10ms中断一次记录“此刻所有线程的调用栈”然后大量样本堆叠起来。某个方法出现在 80% 的样本里不代表它自己花了 80% 的时间——可能是它调用的子方法花的时间。火焰图flame graph因此采用自顶向下、按调用栈聚合的方式[总宽度 全部样本] ├── doRequest ← 宽但大部分时间在子调用 │ ├── parseJson ← 真正的热点自身宽度大 │ └── queryDb │ └── socketRead ← 等待类宽但 CPU 不忙 └── gcThread ← 单独线程栈读火焰图的三个要点看宽度不看高度。宽度代表样本占比高度只是调用深度。找“自身宽”的帧。如果一个方法的子调用都很窄而它本身很宽说明时间花在它自己的代码上如循环、计算、字符串处理。区分 CPU 型和等待型。默认事件是 CPU 采样看到socketRead、park、epollWait很宽时说明线程在等而不是在算。这时应该换用 wall-clock墙钟采样看的是“线程在哪里被卡住”而不是“CPU 花在哪”。这一步常被说成“火焰图找热点”但更准确的说法是火焰图告诉你时间分布的形状解释这个形状的原因仍要靠你对代码和运行环境的理解。看到synchronized宽可能是锁竞争也可能只是临界区里有 IO看到 GC 线程宽可能是内存分配率高也可能是堆太小看到大量park可能是线程池在等任务也可能是等条件变量。同一个形状对应多种原因需要在相邻层的证据里挑一个能同时解释多个现象的解释。采样还有两个常被忽略的性质。其一是窗口有限30 秒的采样只描述那 30 秒如果故障发生在别的时间段你看到的是健康画像。其二是低频事件被稀释采样周期为 10ms 时一个每次只消耗几十微秒但每秒发生很多次的路径可能在图上并不显眼却在累计时间上很可观。读图时把“样本占比”与“发生频率”分开想能减少误判。五、核心机制三容器里的 CPU 限流容器里最常见的“莫名其妙变慢”来自 CPU 限流CPU throttling。机制是这样的cgroup 给容器一个 CPU 配额按固定周期通常 100ms重置。容器在一个周期内用超配额就会被强制暂停到下一个周期。从外部看进程“还活着、没满核、但就是慢”而 JVM 内部看到的可用处理器数可能和宿主机一致线程池按宿主机核数配置结果每个周期都被掐一下。识别限流的证据链很直接cgroup v2 下cpu.stat里会出现nr_throttled与throttled_usec前者累计被限流次数后者累计被限流的微秒数。cpu.max描述配额与周期例如200000 100000表示每 100ms 最多用 200ms CPU 时间即 2 核。JVM 侧可用Runtime.getRuntime().availableProcessors()观察它看到的核数与配额是否一致。一个常见误解是容器里 CPU 使用率不高就代表没被限流。限流看的不是平均值而是周期内的突发。一个 100ms 周期里前 30ms 用掉全部配额、后 70ms 被冻结的进程平均 CPU 可能只有 30%但延迟已经很糟。所以限流排查要看nr_throttled是否随时间增长而不是看监控面板上的平均利用率。把机制再往前推一步为什么限流会放大锁竞争因为被冻结的不是单个线程而是容器内全部可运行线程一起停。持锁线程正好在临界区里被暂停其他等在锁上的线程也停在同一时刻恢复后它们同时被唤醒又同时去抢同一把锁于是争用被集中到一个很短的时间窗里。周期越长、突发越猛这种“先冻结再集中释放”的形态越明显。这也解释了一个现象加了机器、降了并发单次延迟却还是有周期性尖峰——因为限流仍按周期发生。需要说明的是JVM 对容器配额的感知与版本、运行时参数有关不能一概而论。识别时以 cgroup 读数为主证据以 JVM 观测到的核数作辅证两者不一致只说明“线程池规模可能按较大核数配置”是否真的要调整还要结合压测结果。六、贯穿实验一个会被限流放大的锁竞争场景下面构造一个完整、可复现的最小场景把前三个机制串起来。场景是一个计数器服务多个请求线程对同一个计数做原子累加并附带少量字符串处理。这个例子是示意的数字与阈值需在你自己的机器上重新标定。6.1 前置环境与依赖假设环境Java 21Maven 3.9项目采用 Spring Boot 3.5 的spring-boot-starter-parentJMH 以独立模块或独立子工程引入。JMH 必须放在独立模块里跑避免与应用依赖互相干扰。!-- pom.xml 片段仅示意结构版本请以你本地仓库与官方发布说明为准 --propertiesjmh.version1.37/jmh.versionmaven.compiler.release21/maven.compiler.release/propertiesdependenciesdependencygroupIdorg.openjdk.jmh/groupIdartifactIdjmh-core/artifactIdversion${jmh.version}/version/dependencydependencygroupIdorg.openjdk.jmh/groupIdartifactIdjmh-generator-annprocess/artifactIdversion${jmh.version}/versionscopeprovided/scope/dependency/dependencies这段配置有三个容易被省略但会影响结论的点maven.compiler.release固定编译目标避免本地 JDK 与目标版本不一致导致基准跑在非预期字节码上jmh-generator-annprocess用provided作用域只参与编译期生成基准入口不进入运行期依赖JMH 独立模块隔离应用依赖否则 Spring 的自动配置、日志实现与字节码增强可能改变基准的行为。6.2 被测量的代码与基准importorg.openjdk.jmh.annotations.*;importorg.openjdk.jmh.infra.Blackhole;importjava.util.concurrent.TimeUnit;importjava.util.concurrent.atomic.AtomicLong;// 被测量的共享计数逻辑classCounter{privatefinalAtomicLongvaluenewAtomicLong();longincrement(){returnvalue.incrementAndGet();}}BenchmarkMode(Mode.AverageTime)OutputTimeUnit(TimeUnit.NANOSECONDS)Warmup(iterations3,time1)Measurement(iterations5,time1)Fork(1)Threads(4)State(Scope.Benchmark)publicclassCounterBenchmark{privateCountercounter;Setup(Level.Trial)publicvoidsetup(){counternewCounter();}BenchmarkpublicvoidsharedCounter(Blackholebh){bh.consume(counter.increment());}}这段代码的输入是空状态变化是共享的AtomicLong每次加一测量目标是4 个线程争用同一个原子变量时每次操作的平均耗时。State(Scope.Benchmark)让四个线程共享同一个counter这是刻意制造竞争。Blackhole.consume保证返回值被使用防止 JIT 消除。如果你想观察“无竞争”的对照把State改为Scope.Thread每个线程持有自己的计数器或者干脆在方法内使用局部变量。对照组的用途是证明差异确实来自共享而不是来自AtomicLong本身。要注意对照组的改写必须只改一处如果同时改了作用域、线程数和模式差异的来源就说不清了这也是“对照实验只允许一个变量”的具体应用。还有一点因果边界需要写清楚JMH 测的是单次操作的平均耗时当线程数增加时平均耗时的上升既包含原子操作本身的争用成本也包含线程在 JVM 与操作系统层面的调度成本。因此这个数字不能用线性外推的方式换算成“线上并发 100 时就会慢多少倍”。它的用途是在同一台机器、同一 JVM 参数下比较相对变化而不是预测生产延迟。6.3 运行与观察用 Maven 打包后运行mvn-q-pljmh-module clean packagejava-jarjmh-module/target/benchmarks.jar CounterBenchmark-wi3-i5-f1-wi、-i、-f分别覆盖预热轮数、测量轮数与 fork 数便于快速迭代正式比较时应使用注解中的完整参数并多次重复。预期可观察结果需实测确认输出每个 fork 的平均耗时与置信区间4 线程共享原子计数在普通机器上常见为几十纳秒每次操作量级单线程对照通常更低。如果两组数字几乎一样先怀疑你的机器核数不足、被测代码被消除、或参数覆盖过于激进导致预热不足而不是立刻下“锁不慢”的结论。判读这些数字时有三条实务规则。第一比较区间而不是单点如果两组结果的置信区间大面积重叠就不能宣称有差异。第二看轮次之间是否稳定某一 fork 明显偏离其余 fork往往说明该次运行受到了外部干扰例如同时跑了别的编译任务或容器发生了配额争抢此时应重跑而不是取平均。第三记录命令与环境同一条基准在-f 1与-f 3下给出的稳定性不同不记录参数就无法复现。这段 JMH 实验的边界它测量的是纯内存竞争没有网络、没有 GC 压力、没有容器配额。真实服务的慢往往不是原子操作本身而是它引发的排队。下面用压测把这一层补上。6.4 最小服务与压测importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RestController;importjava.util.concurrent.atomic.AtomicLong;SpringBootApplicationpublicclassPerfLabApplication{publicstaticvoidmain(String[]args){SpringApplication.run(PerfLabApplication.class,args);}}RestControllerclassCounterController{privatefinalAtomicLongcounternewAtomicLong();GetMapping(/api/counter)publicStringincrement(){longvcounter.incrementAndGet();// 少量 CPU 工作模拟序列化/业务计算StringBuildersbnewStringBuilder();for(inti0;i50;i){sb.append(v).append(-);}returnsb.toString();}}注意Spring Boot 3.5 默认使用虚拟线程需要显式开启spring.threads.virtual.enabledtrue本文示例保持默认平台线程模型以便观察线程池与限流的关系。如果改用虚拟线程原本“请求线程受容器 CPU 配额限制”的推理仍然成立但排队形态会变化因为大量虚拟线程可以由较少的载体线程轮流执行阻塞点会从线程池队列转移到载体线程调度上需要重新做一遍观测。压测工具不限定wrk、hey或 JMeter 均可命令行示意以wrk为例# 8 个连接、持续 60 秒端口按你的配置替换wrk-t4-c8-d60s--latencyhttp://127.0.0.1:8080/api/counter这里的-t4是压测客户端的线程数-c8是并发连接数二者都不是服务端的配置不要把它们与服务端线程池大小混为一谈。压测客户端自身也会消耗 CPU如果它与服务端跑在同一台机器、且共享同一组核心客户端就会和服务端争抢资源测出来的延迟既包含服务端处理时间也包含客户端排队时间。严格的对比实验应把客户端放在独立机器或至少在报告中注明共置这一事实。启动方式示意mvn -q package后以java -jar target/perf-lab.jar运行确认日志中出现端口监听后再发压。压测前做一次单请求冒烟如curl -s http://127.0.0.1:8080/api/counter确认返回值符合预期避免把 404 或启动失败当成“零延迟”。清理压测结束后确认进程退出、端口释放JMH 的 fork 进程如果异常中断用jps查残留并结束容器内实验结束后删除临时容器避免下一轮实验继承了上一轮的配额与缓存状态。验证清单这是实验是否成立的判据而不是结论服务端日志没有异常堆栈压测工具报告的请求数随时间线性增长延迟分位数有完整输出而不是只有平均值压测期间采集到的cpu.stat与采样文件确实落在同一时间段。四条里缺一条后续对比就要打折扣。6.5 从压测到剖析制造并确认限流在容器内运行并把 CPU 配额设得明显低于需求例如 2 核机器上给容器 0.5 核。容器与配额的具体配置随运行时不同这里只描述意图不承诺某条命令在某个版本上的确切效果。运行中采集两样东西# 观察 cgroup v2 的限流累计路径随容器运行时不同cat/sys/fs/cgroup/cpu.stat# 观察 JVM 看到的可用处理器数jcmdpidVM.info|head-n20在压测期间反复读取cpu.stat如果你看到nr_throttled持续增长就得到了“被限流”的直接证据此时再对比availableProcessors()与实际配额若二者不一致说明 JVM 的线程池规模是按宿主机核数配的会更容易在每个周期前段耗尽配额。整个推理链是配额不足 → 周期内突发耗尽 → 被冻结 → 线程池线程被一起停住 → 延迟抬升但平均 CPU 不高。需要补一句边界nr_throttled增长只能证明“发生过限流”不等于“限流是当前延迟的主因”。如果限流次数很少、throttled_usec累计值相对总运行时间可以忽略那么限流只是背景噪声。判断是否为主因要把它与延迟的时间分布对齐限流尖峰出现的时刻P99 是否同步抬升把配额放宽后尖峰是否随消失。这一步是把“相关”推向“因果”的必要动作也是很多排查报告最薄弱的环节。6.6 用 async-profiler 看“时间花在哪”# CPU 采样生成火焰图路径与参数随版本不同以官方文档为准./asprof-d30-f/tmp/cpu.htmlpid# 墙钟采样观察阻塞与等待./asprof-ewall-d30-f/tmp/wall.htmlpid预期在 CPU 火焰图里看到请求线程的栈集中在计数与字符串拼接如果看到大量Unsafe.park或线程池队列相关的帧说明线程在排队而不是在计算如果 GC 线程占比异常则需要转向内存分配问题。墙钟火焰图对限流场景更有解释力被冻结的线程在墙钟采样中会表现为宽帧而 CPU 采样里看不到。采样时间窗口必须覆盖故障时段否则你得到的是空闲期的健康画像。两张图要对照着读而不是只看其中一张。CPU 图回答“CPU 在算什么”墙钟图回答“线程的时间去了哪里”。当 CPU 图看起来很干净、请求线程的栈几乎都在业务代码里而墙钟图里出现大段与被限流时刻重合的宽帧时一个能同时解释两者的假设就是线程并没有在算而是在等待被重新调度。反过来如果 CPU 图里 GC 线程占比很高那么先处理内存分配与堆配置比调线程池更靠前。当然采样结果仍受限于权限与内核能力。在部分受限容器里原生栈或内核事件可能采集不到此时 CPU 图只包含 Java 栈不能据此断言“没有系统调用开销”。遇到采集报错或输出明显缺帧先解决采集条件再解读图形否则会得出比不采样更糟的结论。七、失败与反例结论是怎么被做坏的反例一拿 JMH 结果直接推断线上性能。上面的 JMH 测的是纳秒级原子操作线上 P99 是毫秒级。中间隔着序列化、网络、GC、限流、连接池。JMH 的结论只能回答“这段代码在同 JVM 条件下相对快慢”不能回答“接口该不该优化锁”。反例二把火焰图当热点排行榜。火焰图里出现很宽的HashMap.get不一定说明哈希表有问题可能是它被调用在热点路径上。要看的是调用链形状同样的宽度挂在parse下面和挂在socketRead下面原因完全不同。反例三用错误会话或不同版本对比“调优前后”。改动前后如果 JVM 参数、容器配额、数据量、压测客户端机器有一项不同对比就不成立。另一个常见错误是压测客户端本身成为瓶颈客户端 CPU 打满时服务端的延迟数字仍会“改善”因为压力没打上去。反例四把一次优化当成通用结论。“把线程池从 200 调到 50 后 P99 下降”可能只说明当时的瓶颈是线程过多导致的上下文切换与竞争换一个 IO 密集、下游慢的场景这个数字可能反而恶化。参数的合法性依附于场景这是必须写进结论的边界。反例五只看平均值。平均值掩盖分布形态是最常见的误读方式。平均延迟从 50ms 降到 45ms看似改善但如果 P99 从 180ms 升到 400ms用户体感会更差。报告里若只出现一个代表值应追问它是哪个统计量、样本量多少、是否丢弃了预热段。反例六一次采样就下结论。单次 30 秒的采样可能恰好跨过了一段空闲期也可能被某个一次性的定时任务污染。可复现的做法是在故障时段重复采样两次以上比较形状是否稳定形状稳定才值得进一步解释形状随时间漂移则说明采样窗口或负载本身不具代表性。八、量化对比怎么写才算数调优前后的对比至少固定以下变量并记录原始输出维度必须固定或说明记录方式负载并发数、持续时间、请求分布压测命令与参数环境JDK 版本、容器配额、CPU 型号、客户端规格环境清单数据数据规模、缓存命中率、是否冷启动数据准备脚本指标P50/P90/P99、吞吐、错误率、CPU、GC 次数原始日志或报告文件统计重复轮数、是否丢弃预热轮每轮结果全部保留只报“优化后快了 40%”是不够的必须给出基线绝对值和波动范围。若两轮结果的置信区间重叠就不能宣称有提升。压测工具与 JMH 都输出统计量理解“平均值的差异可能只是抖动”是写结论的基本功。一个可操作的写法是先写假设例如“瓶颈在周期性限流导致的冻结”再写判据nr_throttled增长、墙钟火焰图出现宽冻结帧然后写改动提高配额或减少周期内 CPU 突发最后写结果与反证nr_throttled停止增长、P99 下降、吞吐上升若吞吐未变而延迟下降要解释是否因为排队减少。这条链条让读者能自行检查而不是相信作者的印象。对比表的一个实用写法是“改动前 / 改动后 / 变化幅度 / 是否超出波动范围”四列每列都来自原始输出的具体字段而不是重新概括的形容词。例如 P99 一列写 178ms 与 96ms并注明这是多轮中的中位数轮次吞吐一列写请求数每秒并注明压测客户端是否饱和错误率一列若前后都是 0应写明“未观察到错误”而不是省略。这样写出来的表别人可以用同样的命令去核对。还要区分两类改动降低单次成本与减少排队。提高配额、减少周期内 CPU 突发属于后者它可能让延迟明显下降而单次 CPU 消耗几乎不变优化字符串拼接属于前者它会让 CPU 图变窄但若瓶颈本来在等待延迟可能几乎不动。把改动归类能解释“为什么这个指标动了、那个指标没动”也能防止把无关改善归功于自己的改动。如果结果与假设相反这同样是有效产出。例如放宽配额后nr_throttled停止增长但 P99 没有变化那么限流不是主因应回到采样数据里重新找证据而不是继续调配额直到数字好看。记录反证能让后来的读者少走一遍同样的弯路。九、适用条件与结论把本文的路径压缩成一句先分层再取证再改动最后用可复现的量化对比确认或推翻假设。具体地说JMH 适合隔离 JVM 内部的相对性能问题前提是你接受它与生产环境的差距并正确使用预热、fork 与 Blackholeasync-profiler 适合在真实负载下观察时间分布前提是采样窗口覆盖故障、并能区分 CPU 与墙钟事件容器限流排查必须看 cgroup 的限流计数而不是平均利用率前提是你知道自己的配额与 JVM 看到的核数是否一致。不适用的情况也要说清如果问题只在多机集群出现单机复现实验无法证明根因需要先查负载均衡与共享存储如果延迟波动来自下游数据库应用层火焰图只会显示等待应转向慢查询日志与执行计划如果压测客户端能力不足任何“服务端改善”都不可信。另外三种边界值得提前写明第一若系统在观测期间发生了扩容、发布或配置变更前后对比就不再是同一环境需重新建立基线第二若故障是低频偶发短窗口采样很可能抓不到应延长观测或改用持续记录方式而不是反复重试直到抓到一次“看起来像”的图第三若瓶颈来自外部依赖的配额或网络路径应用层能做的往往只是降级与隔离此时把结论写成“应用代码需要优化”会误导后续工作。最后本文所有数字都是示意未在本次写作中实际运行。请在你的 Java 21 与 Spring Boot 3.5 环境中按第六节的顺序复现把原始输出保留下来再决定哪个参数值得改。性能工作的可信度来自可被他人重跑的过程而不是来自结论本身有多响亮。
阅读完成 · 觉得有帮助?