Java 基础体系 · 第 82/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java JMH 基准测试:预热、黑洞、分叉、死代码和结果解释
JMH(Java Microbenchmark Harness)是 OpenJDK 项目提供的 Java 微基准测试工具。它解决的不是“如何调用一段代码并计时”,而是如何在 JIT 编译、运行时优化、线程调度、垃圾回收和测量误差共同存在的条件下,尽量测量目标操作本身。
本文以 Java 25 LTS 为运行环境,重点解释以下问题:
- 为什么一次方法调用的计时通常不可信;
- 预热到底预热了什么;
- JMH 的黑洞如何避免死代码消除;
- 分叉为什么比单个 JVM 运行更可靠;
- 为什么“写了循环”不代表真的执行了循环;
- 如何阅读 JMH 的
Score、Error和不同测量模式; - 哪些结果可以用于工程决策,哪些结果只是测试程序自身的产物。
1. 基准测试首先要定义“被测操作”
假设要比较两种字符串拼接方式:
String result = "Java " + 25;
和:
String result = String.valueOf("Java ") + 25;
一个最直接的想法是:
long start = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
String result = "Java " + i;
}
long elapsed = System.nanoTime() - start;
System.out.println(elapsed);
这个程序至少存在四类问题。
第一,循环体产生的 result 没有被使用。编译器或 JIT 编译器可能判断这些计算不会影响程序的可观察行为,从而删除全部或部分代码。
第二,第一次执行可能包含类加载、方法解析、解释执行和 JIT 编译,测到的并不只是字符串拼接。
第三,System.nanoTime() 的调用、循环控制和分支判断也包含在测量范围中。若目标操作很快,测量开销可能与目标操作处于同一数量级。
第四,单次运行还会受到操作系统调度、后台线程、垃圾回收和 CPU 频率变化影响。即使代码完全不变,两次结果也可能不同。
因此,基准测试必须先明确:
JMH 的目标不是让后半项变成零,而是通过生成测试代码、预热、多轮测量和多个 JVM 进程,把它控制在可解释的范围内。
2. JIT 编译使“同一段 Java 代码”存在多个执行阶段
Java 源代码通常不会直接变成始终相同的机器码。以 HotSpot 为例,方法可能经历以下状态:
flowchart LR
A[类加载与链接] --> B[解释执行]
B --> C[调用计数增加]
C --> D[分层编译触发]
D --> E[生成优化机器码]
E --> F[继续执行优化代码]
F --> G[假设失效]
G --> H[去优化 Deoptimization]
H --> B
图中的实现细节属于 HotSpot 的常见行为,而不是 Java 语言规范对所有 JVM 的保证。Java 语言规范要求程序保持规定的可观察语义,但不规定某个方法必须解释执行、使用 C1 还是 C2,也不规定具体的内联阈值。
常见的优化包括:
- 方法内联:把被调用方法的代码嵌入调用者;
- 常量折叠:编译期或运行期直接计算常量表达式;
- 公共子表达式消除:复用已经计算出的相同结果;
- 循环优化:消除无效循环、展开循环或改变循环结构;
- 逃逸分析:判断对象是否逃出当前范围;
- 标量替换:如果对象不需要真实存在,可能只保留其字段;
- 死代码消除:删除不会影响可观察结果的计算;
- 去优化:运行时发现优化假设不成立时,退回解释执行或其他编译版本。
这意味着基准测试不能只问“源代码执行了多少次”,还必须问:
JIT 最终是否仍然需要执行我以为测量的那段工作?
3. 死代码:源代码存在,不等于机器码仍然存在
3.1 什么是死代码
如果一段计算的结果不会影响程序的可观察行为,这段计算就可能成为死代码。
例如:
@Benchmark
public void wrongBenchmark() {
long value = 0;
for (int i = 0; i < 1_000; i++) {
value += i * i;
}
}
value 在方法返回前没有被读取,也没有写入外部状态。对调用者而言,方法执行前后没有任何可观察差异,因此 JIT 可能删除循环。
这不是 Java “违反了代码语义”,而是优化器利用了代码语义:既然结果不可观察,保留计算没有必要。
3.2 返回结果通常是最简单的修正方式
在 JMH 中,@Benchmark 方法可以返回结果:
import org.openjdk.jmh.annotations.Benchmark;
public class ReturnValueBenchmark {
@Benchmark
public long sum() {
long value = 0;
for (int i = 0; i < 1_000; i++) {
value += (long) i * i;
}
return value;
}
}
返回值会被 JMH 生成的测量代码接收,结果因此具有可观察性。这里的 long 强制转换也避免了 int 乘法溢出后再转换的问题。
3.3 结果仍然可能被过度简化
返回结果并不意味着所有预期工作都必然发生。例如:
@Benchmark
public int constantExpression() {
return 1000 * 1000;
}
这个方法的返回值是编译期常量,基准测试测到的可能只是返回一个常量的成本。
再看:
@Benchmark
public int predictableLoop() {
int result = 0;
for (int i = 0; i < 1_000; i++) {
result += i;
}
return result;
}
如果循环边界和每次计算都完全固定,编译器可能把一部分计算提前折叠,或者以比源代码更简单的机器指令实现。这个结果不一定错误,但它回答的问题是:
对固定输入,优化后的这段代码有多快?
它不一定回答:
对真实业务输入,逐项执行这个算法有多快?
3.4 外部副作用也不能简单等同于“测量目标”
下面的代码通常不会被完全删除,因为它修改了共享状态:
private int field;
@Benchmark
public void writeField() {
field++;
}
但它可能测到的是字段写入、内存可见性和竞争行为,而不是原本想测的计算。若多个线程共享这个对象,还可能引入数据竞争或缓存一致性影响。
因此,避免死代码的目标不是“随便产生一个副作用”,而是让目标计算结果可观察,同时不引入不属于目标的问题。
4. 黑洞:让结果可观察而不改变主要工作
JMH 提供:
org.openjdk.jmh.infra.Blackhole
黑洞通常用于消费计算结果:
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.infra.Blackhole;
public class BlackholeBenchmark {
@Benchmark
public void sum(Blackhole blackhole) {
long value = 0;
for (int i = 0; i < 1_000; i++) {
value += (long) i * i;
}
blackhole.consume(value);
}
}
blackhole.consume(value) 的语义是:告诉 JMH,这个结果必须被当作已使用,从而降低结果被优化器证明为无效并删除的可能。
黑洞不是一个业务对象,也不是“把结果丢进某个垃圾桶”。它是 JMH 为基准测试生成代码提供的特殊辅助设施。应使用 JMH 注入的 Blackhole 实例,而不是自己构造或用普通静态变量模拟。
4.1 黑洞不能让不合理的基准自动变正确
下面的代码测量的是一个固定常量:
@Benchmark
public void wrongUse(Blackhole blackhole) {
blackhole.consume(42);
}
黑洞确实防止了 42 被删除,但没有让它代表任何有意义的业务工作。
再例如:
@Benchmark
public void sameObject(Blackhole blackhole) {
Object value = new Object();
blackhole.consume(value);
}
这可能测量对象分配,也可能在特定条件下受到逃逸分析和分配消除影响。若目标是测量“对象必须逃逸到外部”的场景,就需要让对象真正进入被测 API 或返回到基准框架,而不是只消费一个局部引用。
4.2 返回值和黑洞如何选择
可以按以下逻辑选择:
@Benchmark
public int returnResult() {
return calculate();
}
如果只需要消费一个结果,返回值通常更直接。
@Benchmark
public void consumeResult(Blackhole blackhole) {
blackhole.consume(calculate());
}
如果需要消费多个中间结果,或者方法返回类型不便表达结果,可以使用黑洞。
@Benchmark
public void consumeMultiple(Blackhole blackhole) {
blackhole.consume(calculateA());
blackhole.consume(calculateB());
}
但不要为了“保险”把每一个局部变量都放进黑洞。额外的消费操作本身也可能改变测量结果。应让黑洞位于合理的结果边界,并确认它没有替代真实业务中的数据流。
4.3 consumeCPU 不是通用的业务模拟器
JMH 还提供 Blackhole.consumeCPU(long tokens),用于消耗一定计算资源。它适合在需要构造“计算占位”时使用,但它不是某种业务算法的等价物,也不能据此推导字符串处理、加密或数据库访问的性能。
如果目标是测量真实算法,就应执行真实算法并消费其结果,而不是用 consumeCPU 代替算法。
5. 预热:让测量进入稳定阶段
5.1 预热预热的是什么
JMH 的预热(warmup)是在正式测量之前运行基准方法,但不把这些迭代纳入最终成绩。
预热期间可能发生:
- 类加载和初始化完成;
- 方法从解释执行逐渐进入编译执行;
- 常用调用路径被内联;
- 类型分派信息变得更稳定;
- JIT 根据运行时数据进行优化;
- 线程、分配路径和部分运行时结构进入相对稳定状态;
- 某些优化假设失效后发生去优化,再重新编译。
因此,“预热”不是简单地把缓存装进 CPU,也不只是预先调用几次方法。它主要是在等待运行时编译和优化状态接近目标运行状态。
5.2 一个可运行的 JMH 示例
下面的类比较“手写循环求和”和 IntStream 求和。这个示例的重点不是宣布某种写法永远更快,而是展示完整的 JMH 生命周期。
package example;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 2)
@State(Scope.Thread)
public class SumBenchmark {
@Param({"10", "1000", "100000"})
int size;
private int[] values;
@org.openjdk.jmh.annotations.Setup
public void setup() {
values = IntStream.range(0, size).toArray();
}
@Benchmark
public long forLoop() {
long result = 0;
for (int value : values) {
result += value;
}
return result;
}
@Benchmark
public long stream() {
return IntStream.of(values).asLongStream().sum();
}
}
这里有几个关键点:
@Param让 JMH 分别使用三种输入规模;@Setup在测量前准备数据,避免把数据构造成本混入每次基准调用;@State(Scope.Thread)为每个基准线程提供独立状态,避免多个线程共享values;- 返回值使求和结果保持可观察;
@Warmup指定预热 5 轮,每轮 1 秒;@Measurement指定正式测量 5 轮,每轮 1 秒;@Fork(2)在两个独立 JVM 进程中重复执行;Mode.AverageTime的结果单位是每次调用的平均时间。
@Setup 的默认级别是 Level.Trial,也就是每个参数组合、每个 fork 的一次测试试验开始前执行。若数据必须每个测量迭代重建,可以使用 Level.Iteration;若每次 @Benchmark 调用都需要重建,则使用 Level.Invocation,但这会显著改变被测成本,必须确认这是目标行为。
5.3 Maven 配置和运行方式
一个最小 Maven 配置可以这样写:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>jmh-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jmh.version>1.37</jmh.version>
</properties>
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation=
"org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
JMH 版本应根据实际项目和仓库可用版本确认;这里的 1.37 是示例版本,不代表 Java 25 对它提供语言级兼容承诺。运行环境应确认:
java -version
mvn -version
mvn clean package
java -jar target/jmh-demo-1.0-SNAPSHOT.jar
也可以只执行指定基准:
java -jar target/jmh-demo-1.0-SNAPSHOT.jar 'example.SumBenchmark.*'
若希望查看 JMH 支持的参数:
java -jar target/jmh-demo-1.0-SNAPSHOT.jar -h
5.4 预热轮数不等于固定正确值
@Warmup(iterations = 5, time = 1) 只是实验配置。预热不足时,结果可能仍然包含解释执行或编译过程;预热过长则增加测试时间,但不一定带来更多信息。
应观察结果是否随预热轮次趋于稳定。对于:
- 启动成本很高的方法;
- 大量泛型、反射或动态调用的方法;
- 有明显分配行为的方法;
- 会触发多次编译和去优化的方法;
5 秒可能不足。
另一方面,预热过度也可能不符合目标。例如要测量冷启动延迟,就不应使用长时间预热后再报告结果,而应使用 Mode.SingleShotTime 并明确测试冷启动语义。
6. 分叉:用独立 JVM 隔离测试状态
6.1 什么是分叉
JMH 的 fork(分叉)是启动独立 JVM 进程运行基准测试。@Fork(value = 2) 表示同一个基准配置运行两个独立 JVM 进程。
每个 fork 都拥有自己的:
- 堆;
- JIT 编译器状态;
- 类加载器状态;
- GC 历史;
- 线程;
- 系统属性和 JVM 参数;
- 运行时优化决策。
每个 fork 通常会独立执行预热和正式测量,不能把第一个 fork 的 JIT 状态带给第二个 fork。
6.2 为什么不建议把多个基准都放进同一个 JVM
如果在同一个 JVM 中依次运行两个不同基准:
BenchmarkA -> BenchmarkB
BenchmarkB 可能受到以下因素影响:
BenchmarkA已经加载了相关类;- 共享类已经被编译;
- JIT 已经建立了类型画像;
- 堆中已经存在缓存或其他对象;
- GC 历史不同;
- 线程和 CPU 状态不同。
这会使基准之间产生顺序依赖。JMH 的 fork 通过进程边界减少这种污染。
6.3 fork = 0 的含义和风险
可以这样运行:
java -jar target/jmh-demo-1.0-SNAPSHOT.jar \
'example.SumBenchmark.forLoop' \
-f 0
-f 0 表示不启动独立 fork,而在当前 JVM 中运行。它适合:
- 调试基准代码;
- 查看生成代码是否正确;
- 快速验证参数和注解;
- 使用调试器定位问题。
它不适合作为最终性能结论的默认配置,因为当前 JVM 可能已经运行过其他代码,JIT 和堆状态也可能被污染。
6.4 fork 与线程数不是一回事
以下三个概念必须区分:
fork:独立 JVM 进程数量;threads:一个 fork 内的基准线程数量;@State的Scope:状态在实例、线程或组之间如何共享。
例如:
@Fork(2)
@Threads(4)
通常表示启动两个 JVM,每个 JVM 内用四个 JMH 工作线程运行。它不是“启动八个互相共享状态的线程”,而是两个独立实验,每个实验有四个线程。
7. JMH 的测量阶段和计时边界
JMH 不是简单包住一个 nanoTime()。它生成专门的 harness,控制迭代、调用次数、批处理、线程和结果消费。
一次典型测试可抽象为:
sequenceDiagram
participant J as JMH 启动器
participant P as Fork JVM
participant W as Warmup
participant M as Measurement
participant R as Result
J->>P: 启动独立 JVM
P->>W: 执行预热迭代
W-->>P: JIT 与运行时状态逐渐稳定
P->>M: 执行正式测量迭代
M-->>R: 收集每轮样本
R-->>J: 汇总 Score、Error 等结果
默认情况下,JMH 会在每轮迭代中多次调用基准方法,以避免单次计时开销主导结果。对于极快的方法,即使如此也可能受到测量分辨率、调用边界和编译器优化影响。
如果基准方法太快,应考虑:
- 让每次调用处理一批输入;
- 使用
@OperationsPerInvocation说明一次调用中包含多少逻辑操作; - 选择合适的时间单位;
- 避免人为加入没有业务含义的复杂操作。
例如:
import org.openjdk.jmh.annotations.OperationsPerInvocation;
@OperationsPerInvocation(1_000)
@Benchmark
public long sumBatch() {
long result = 0;
for (int i = 0; i < 1_000; i++) {
result += i;
}
return result;
}
此注解告诉 JMH:一次基准方法调用代表 1000 个逻辑操作。它改变的是结果的归一化解释,不会自动让代码执行 1000 次;代码本身必须确实完成对应批量工作。
8. 基准模式决定结果的含义
8.1 AverageTime
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
结果表示平均每次基准方法调用需要多长时间。
输出可能类似:
Benchmark (size) Mode Cnt Score Error Units
SumBenchmark.forLoop 10 avgt 10 8.20 ± 0.30 ns/op
8.20 ns/op 表示每次调用平均约 8.20 纳秒。这里的 op 是一次 @Benchmark 方法调用,不一定是业务中的一次请求、一次元素处理或一次数据库操作。
如果一次方法调用处理 1000 个元素,那么 ns/op 是“每批 1000 个元素”的成本,不能直接当作“每元素成本”。
8.2 Throughput
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.OPERATIONS_PER_SECOND)
结果表示单位时间内完成多少次基准方法调用:
Score = operations / time
如果一次调用的工作量固定,吞吐和平均时间大致互为倒数;但在并发、批处理或不同操作定义下,不能机械地把两个结果直接比较。
8.3 SampleTime
SampleTime 对调用耗时进行采样,可以帮助观察延迟分布,例如高分位延迟。它适合目标操作耗时变化较大、平均值会掩盖长尾的场景。
但它不是自动生成生产环境 P99 的工具。样本数量、采样方式、GC、线程调度和测试时长都会影响分布。报告 P99 前必须说明样本和实验配置。
8.4 SingleShotTime
SingleShotTime 测量单次调用,适合:
- 冷启动;
- 类加载;
- 首次初始化;
- 一次性操作。
它通常不适合用来测量稳定吞吐,因为单次结果极易受调度、页面缺页、JIT 和后台活动影响。
9. 结果中的 Score、Error 和 Cnt
以如下结果为例:
Benchmark Mode Cnt Score Error Units
Example.test avgt 10 42.50 ± 1.20 ns/op
9.1 Score
Score 是 JMH 根据测量样本汇总出的主要统计值。对于 avgt,通常是平均耗时;对于 thrpt,通常是吞吐量;对于其他模式,含义随模式变化。
它不是“代码理论上的固定成本”,而是当前实验环境、JVM、输入、线程数和配置下的统计结果。
9.2 Cnt
Cnt 是参与最终汇总的测量样本数,通常与 measurement 迭代数量、fork 数量及统计方式有关。它不是业务调用次数。
9.3 Error
Error 是 JMH 对结果不确定性的估计,常见输出形式为:
42.50 ± 1.20 ns/op
它通常不是标准差,也不是“这段代码每次最多偏差 1.20 纳秒”。JMH 的结果误差与统计置信区间有关,具体计算受基准模式、迭代样本和 JMH 版本实现影响。默认配置下通常使用较高的置信水平,但报告结果时应以实际 JMH 配置和输出为准,不应把 Error 当作精确的物理误差上限。
如果两个结果是:
A: 42.50 ± 1.20 ns/op
B: 43.10 ± 1.10 ns/op
不能仅凭两个区间是否重叠就断言“完全没有差异”或“B 一定更慢”。更可靠的做法是:
- 增加 fork 数量;
- 延长测量时间;
- 检查输入是否一致;
- 检查是否发生 GC、去优化或系统干扰;
- 查看原始结果或使用 JMH 的结果文件做统计比较;
- 判断差异是否大于实验噪声,并且是否具有工程意义。
如果两个版本只相差 0.5%,而环境噪声达到 2%,这个差异通常不够支撑优化决策,即使某次运行显示了更小的数字。
10. 一个“看似正确但实际失真”的完整反例
下面的基准有多个问题:
@Benchmark
public void badBenchmark() {
for (int i = 0; i < 1_000_000; i++) {
new String("Java");
}
}
问题一:创建的字符串引用没有被消费,整个分配可能被优化掉,或者部分对象被消除。
问题二:new String("Java") 不是通常业务中最有代表性的字符串创建方式。它明确创建一个新的 String 对象,但其内部字符数据是否复制、对象如何布局和分配,取决于 JDK 实现和运行时优化。
问题三:它没有说明要测量的是“分配速度”“字符串内容复制”“GC 压力”还是“业务字符串构造”。
一个更明确的版本是:
@Benchmark
public String createString() {
return new String("Java");
}
它至少让返回值可观察。但如果目标是测量持续分配对 GC 的影响,还应明确:
- 对象是否必须逃逸;
- 是否要包含回收成本;
- 堆大小是否固定;
- 是否需要比较不同 GC;
- 测试时间是否足以产生稳定的回收行为。
若每次基准调用都创建大量对象,应使用 JMH 的 GC 相关选项查看分配和回收情况,而不能只根据 ns/op 推断生产环境 GC 成本。可以尝试:
java -jar target/jmh-demo-1.0-SNAPSHOT.jar \
'example.AllocationBenchmark.*' \
-prof gc
该 profiler 是否可用、输出字段如何显示,取决于 JMH 版本和运行环境;应以命令实际输出为准。
11. 状态、输入和线程共享会改变结果
基准测试不能只关注被测方法,还要定义数据从哪里来、由谁拥有。
11.1 Scope.Thread
@State(Scope.Thread)
public class StateBenchmark {
int value;
}
每个基准线程拥有自己的状态实例,适合避免不必要的共享和锁竞争。它测量的是线程私有数据路径。
11.2 Scope.Benchmark
@State(Scope.Benchmark)
public class SharedStateBenchmark {
int value;
}
一个 fork 内的所有基准线程共享状态。若多个线程修改它,就可能产生竞争、伪共享或数据竞争。此时共享本身可能正是要测量的内容,否则应避免无意引入。
11.3 @Param 不是随机输入
@Param({"10", "1000"}) 产生的是可重复的固定输入。它适合比较输入规模,但不代表真实输入分布。
如果算法对数据分布敏感,例如哈希表、分支预测、排序或压缩,应该同时设计:
- 随机但可复现的输入;
- 已排序输入;
- 逆序输入;
- 重复值很多的输入;
- 真实生产采样输入。
随机输入必须固定种子,否则每次实验可能测到不同数据,无法区分代码变化和输入变化。
12. 预热、分叉和测量配置之间的关系
可以把一次 JMH 试验近似表示为:
其中:
- 表示第几个 fork;
- 表示该 fork 中的第几个 measurement iteration;
- 表示该轮收集到的样本;
statistic表示平均值、吞吐量或其他模式对应的统计量。
最终结果还要在 fork 或迭代之间继续汇总。
如果没有预热,样本可以表示为:
这时结果混合了多个阶段。
如果只有一个 fork:
结果可能偶然受到某次 JIT 决策或系统状态影响。
如果存在多个 fork:
可以观察不同独立 JVM 的结果是否一致。fork 越多,实验时间越长,但通常能更好地暴露不稳定性。
13. 如何诊断异常结果
13.1 结果随预热轮次持续下降
可能原因:
- JIT 尚未完成优化;
- 方法调用路径逐步内联;
- 输入类型画像尚未稳定;
- 第一次运行包含类初始化或缓存建立。
处理方式:
- 增加预热时间或轮数;
- 查看不同预热配置下的趋势;
- 使用 JMH 的编译日志或 JVM 诊断参数确认编译行为;
- 不要直接取最后一轮的最低值作为结论。
13.2 不同 fork 之间差异很大
可能原因:
- 机器受到其他进程干扰;
- CPU 频率或温度变化;
- GC 时机不同;
- 输入或参数没有真正保持一致;
- 基准代码存在共享状态;
- JIT 做出了不同优化决策。
处理方式:
- 增加 fork;
- 延长每轮测量时间;
- 关闭无关后台任务;
- 记录 CPU、操作系统、JDK、JMH 和 JVM 参数;
- 使用 profiler 检查 GC、分配和编译;
- 将输入准备从被测路径中分离或明确纳入。
13.3 结果异常快
优先怀疑以下问题:
- 计算结果没有返回或消费,触发死代码消除;
- 输入是常量,触发常量折叠;
- 循环被消除或展开;
- 对象没有逃逸,分配被消除;
- 测量的只是缓存命中,而生产路径包含 I/O 或锁;
@Setup准备的数据没有被实际使用;- 基准方法调用的是错误的重载或空实现。
可以通过修改输入、返回中间结果、使用黑洞、增加不同参数和查看生成代码来验证。JMH 提供的 -prof perfasm 等 profiler 在 Linux 和相关工具可用时可以辅助查看汇编,但它不是所有平台都能直接使用,且需要额外权限和工具。
13.4 结果异常慢
可能原因:
- 把输入构造、日志、对象清理或随机数生成混入了被测方法;
- 多线程共享了本应线程私有的状态;
- 发生频繁 GC;
- 测量的是同步、锁竞争或缓存失效;
- 数据集超过缓存,正在测量内存层级变化;
- 开启了不符合目标场景的诊断参数。
异常慢不一定是错误。关键是确认它是否符合实验问题的定义。
14. 不要把 JMH 微基准结果直接等同于生产性能
JMH 适合回答局部、可控的问题,例如:
- 某个集合遍历方式在给定数据规模下的成本;
- 某种编码方式的吞吐;
- 某个对象创建路径的分配行为;
- 某个算法在不同输入上的延迟差异;
- 某个同步策略在指定线程数下的开销。
它不适合单独回答:
- 一个完整 HTTP 请求的端到端延迟;
- 数据库连接池、网络、磁盘共同作用下的吞吐;
- 真实流量下的尾延迟;
- 分布式系统发生重试、超时和故障时的性能;
- 生产机器上的所有 NUMA、容器和调度影响。
例如,微基准显示某个 JSON 解析方法快 10%,并不意味着接口 QPS 一定快 10%。如果接口总耗时由网络、数据库和锁等待主导,解析只占总时间的 5%,根据近似的 Amdahl 定律:
其中:
- 是可优化部分占总时间的比例;
- 是该部分的加速比。
若 ,解析部分加速 ,则:
总体只改善约 0.46%。微基准的局部差异因此必须结合调用链占比解释。
15. 影响结果的 JVM 参数必须记录
Java 25 LTS 只表示语言和标准库的目标版本,不等于所有运行时实现都相同。至少应记录:
java -version
以及:
- JMH 版本;
- 操作系统和架构;
- CPU 型号与核心数;
- JVM 供应商;
- 堆大小和 GC 选择;
- 是否运行在虚拟机或容器中;
- fork、线程、预热和测量配置;
- 输入数据规模与分布;
- 是否使用 profiler 或特殊 JVM 参数。
JIT 编译器、垃圾回收器、CPU 微架构和 JVM 参数都可能改变结果。Java 语言规范不会保证“某个方法必须分配对象”或“某个循环必须保留”,这些属于实现和优化层面的行为。
如果使用:
java -jar target/jmh-demo-1.0-SNAPSHOT.jar \
'example.SumBenchmark.*' \
-jvmArgsAppend '-Xms2g -Xmx2g'
这表示向 fork JVM 追加堆参数。修改堆大小可能改变 GC 频率、对象晋升和缓存行为,因此必须把它作为实验条件的一部分记录,而不是只记录最终数字。
16. 一份可复用的实验检查过程
正式比较两个实现时,可以按以下因果顺序执行:
第一步:定义一次操作
明确 op 是:
- 一次方法调用;
- 一批元素处理;
- 一次完整请求模拟;
- 还是一次算法阶段。
若定义不清,ns/op 和 ops/s 没有稳定含义。
第二步:检查结果是否可观察
确认:
- 方法返回了结果,或使用了
Blackhole; - 结果不是固定常量;
- 循环确实依赖输入;
- 对象分配是否符合目标;
- 没有把结果消费操作误当成业务操作。
第三步:隔离准备阶段
使用 @Setup 准备输入。然后判断准备成本是否应该包含在测量中:
- 若要测算法本身,放在
@Setup; - 若要测“构造输入加算法”的总成本,放入
@Benchmark; - 若两者都重要,分别建立两个基准。
第四步:设置预热和分叉
先用较短配置验证代码,再用多个 fork 和足够长的 measurement 做正式实验。不要在 fork=0 的调试结果上做性能结论。
第五步:比较稳定性而不是单个最小值
观察:
- 不同 fork 是否接近;
- 预热后结果是否稳定;
Error是否明显小于实现之间的差异;- 是否出现 GC 或异常长尾;
- 输入规模变化是否符合算法复杂度预期。
第六步:把微观结果映射回生产路径
确认该操作在生产总耗时中占多大比例,是否存在并发、I/O、缓存和数据分布差异。只有在实验问题与生产问题边界一致时,微基准结果才具有直接决策价值。
17. 结论:正确结果来自正确的问题边界
预热解决的是 JVM 执行阶段尚未稳定的问题;分叉解决的是不同实验之间共享 JVM 状态的问题;黑洞解决的是计算结果不可观察导致的优化问题;识别死代码解决的是“源代码看起来执行了,但机器码可能没有执行”的问题;结果解释解决的是“一个数字到底代表什么,以及差异是否可信”的问题。
一个可信的 JMH 基准至少应满足:
- 被测操作定义明确;
- 输入可重复且与问题匹配;
- 结果被返回或通过黑洞消费;
- 准备成本和测量成本边界清楚;
- 有足够预热;
- 使用独立 fork 隔离 JVM 状态;
- 根据测量模式解释
Score; - 不把
Error当作绝对误差; - 记录 Java 25、JMH、JVM、硬件和运行参数;
- 对异常快、异常慢和跨 fork 差异进行诊断;
- 最后结合生产调用链判断局部优化是否值得。
JMH 不能替工程师决定“哪个实现更好”,但它可以把一个容易被误导的计时实验,转化为具有生命周期、统计边界和运行时语义的可重复实验。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 代码质量:Checkstyle、SpotBugs、Error Prone、覆盖率和门禁
- 下一篇:Java 属性测试与模糊测试:生成器、不变量、缩减和回归
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论