Java 基础体系 · 第 82/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java JMH 基准测试:预热、黑洞、分叉、死代码和结果解释

JMH(Java Microbenchmark Harness)是 OpenJDK 项目提供的 Java 微基准测试工具。它解决的不是“如何调用一段代码并计时”,而是如何在 JIT 编译、运行时优化、线程调度、垃圾回收和测量误差共同存在的条件下,尽量测量目标操作本身。

本文以 Java 25 LTS 为运行环境,重点解释以下问题:

  • 为什么一次方法调用的计时通常不可信;
  • 预热到底预热了什么;
  • JMH 的黑洞如何避免死代码消除;
  • 分叉为什么比单个 JVM 运行更可靠;
  • 为什么“写了循环”不代表真的执行了循环;
  • 如何阅读 JMH 的 ScoreError 和不同测量模式;
  • 哪些结果可以用于工程决策,哪些结果只是测试程序自身的产物。

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 频率变化影响。即使代码完全不变,两次结果也可能不同。

因此,基准测试必须先明确:

测量结果=目标操作的耗时或吞吐+测量系统引入的影响\text{测量结果} = \text{目标操作的耗时或吞吐} + \text{测量系统引入的影响}

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)是在正式测量之前运行基准方法,但不把这些迭代纳入最终成绩。

预热期间可能发生:

  1. 类加载和初始化完成;
  2. 方法从解释执行逐渐进入编译执行;
  3. 常用调用路径被内联;
  4. 类型分派信息变得更稳定;
  5. JIT 根据运行时数据进行优化;
  6. 线程、分配路径和部分运行时结构进入相对稳定状态;
  7. 某些优化假设失效后发生去优化,再重新编译。

因此,“预热”不是简单地把缓存装进 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 内的基准线程数量;
  • @StateScope:状态在实例、线程或组之间如何共享。

例如:

@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. 结果中的 ScoreErrorCnt

以如下结果为例:

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 一定更慢”。更可靠的做法是:

  1. 增加 fork 数量;
  2. 延长测量时间;
  3. 检查输入是否一致;
  4. 检查是否发生 GC、去优化或系统干扰;
  5. 查看原始结果或使用 JMH 的结果文件做统计比较;
  6. 判断差异是否大于实验噪声,并且是否具有工程意义。

如果两个版本只相差 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 试验近似表示为:

Rf,i=statistic(Sf,i,1,Sf,i,2,)R_{f,i} = \operatorname{statistic}(S_{f,i,1}, S_{f,i,2}, \ldots)

其中:

  • ff 表示第几个 fork;
  • ii 表示该 fork 中的第几个 measurement iteration;
  • SS 表示该轮收集到的样本;
  • statistic 表示平均值、吞吐量或其他模式对应的统计量。

最终结果还要在 fork 或迭代之间继续汇总。

如果没有预热,样本可以表示为:

S=S解释执行+S编译+S优化后执行S = S_{\text{解释执行}} + S_{\text{编译}} + S_{\text{优化后执行}}

这时结果混合了多个阶段。

如果只有一个 fork:

R=R单个 JVM 状态R = R_{\text{单个 JVM 状态}}

结果可能偶然受到某次 JIT 决策或系统状态影响。

如果存在多个 fork:

R=aggregate(R1,R2,,Rn)R = \operatorname{aggregate}(R_1, R_2, \ldots, R_n)

可以观察不同独立 JVM 的结果是否一致。fork 越多,实验时间越长,但通常能更好地暴露不稳定性。


13. 如何诊断异常结果

13.1 结果随预热轮次持续下降

可能原因:

  • JIT 尚未完成优化;
  • 方法调用路径逐步内联;
  • 输入类型画像尚未稳定;
  • 第一次运行包含类初始化或缓存建立。

处理方式:

  • 增加预热时间或轮数;
  • 查看不同预热配置下的趋势;
  • 使用 JMH 的编译日志或 JVM 诊断参数确认编译行为;
  • 不要直接取最后一轮的最低值作为结论。

13.2 不同 fork 之间差异很大

可能原因:

  • 机器受到其他进程干扰;
  • CPU 频率或温度变化;
  • GC 时机不同;
  • 输入或参数没有真正保持一致;
  • 基准代码存在共享状态;
  • JIT 做出了不同优化决策。

处理方式:

  • 增加 fork;
  • 延长每轮测量时间;
  • 关闭无关后台任务;
  • 记录 CPU、操作系统、JDK、JMH 和 JVM 参数;
  • 使用 profiler 检查 GC、分配和编译;
  • 将输入准备从被测路径中分离或明确纳入。

13.3 结果异常快

优先怀疑以下问题:

  1. 计算结果没有返回或消费,触发死代码消除;
  2. 输入是常量,触发常量折叠;
  3. 循环被消除或展开;
  4. 对象没有逃逸,分配被消除;
  5. 测量的只是缓存命中,而生产路径包含 I/O 或锁;
  6. @Setup 准备的数据没有被实际使用;
  7. 基准方法调用的是错误的重载或空实现。

可以通过修改输入、返回中间结果、使用黑洞、增加不同参数和查看生成代码来验证。JMH 提供的 -prof perfasm 等 profiler 在 Linux 和相关工具可用时可以辅助查看汇编,但它不是所有平台都能直接使用,且需要额外权限和工具。

13.4 结果异常慢

可能原因:

  • 把输入构造、日志、对象清理或随机数生成混入了被测方法;
  • 多线程共享了本应线程私有的状态;
  • 发生频繁 GC;
  • 测量的是同步、锁竞争或缓存失效;
  • 数据集超过缓存,正在测量内存层级变化;
  • 开启了不符合目标场景的诊断参数。

异常慢不一定是错误。关键是确认它是否符合实验问题的定义。


14. 不要把 JMH 微基准结果直接等同于生产性能

JMH 适合回答局部、可控的问题,例如:

  • 某个集合遍历方式在给定数据规模下的成本;
  • 某种编码方式的吞吐;
  • 某个对象创建路径的分配行为;
  • 某个算法在不同输入上的延迟差异;
  • 某个同步策略在指定线程数下的开销。

它不适合单独回答:

  • 一个完整 HTTP 请求的端到端延迟;
  • 数据库连接池、网络、磁盘共同作用下的吞吐;
  • 真实流量下的尾延迟;
  • 分布式系统发生重试、超时和故障时的性能;
  • 生产机器上的所有 NUMA、容器和调度影响。

例如,微基准显示某个 JSON 解析方法快 10%,并不意味着接口 QPS 一定快 10%。如果接口总耗时由网络、数据库和锁等待主导,解析只占总时间的 5%,根据近似的 Amdahl 定律:

总体加速比=1(1p)+p/s\text{总体加速比} = \frac{1}{(1-p)+p/s}

其中:

  • pp 是可优化部分占总时间的比例;
  • ss 是该部分的加速比。

p=0.05p=0.05,解析部分加速 s=1.1s=1.1,则:

10.95+0.05/1.11.0046\frac{1}{0.95+0.05/1.1} \approx 1.0046

总体只改善约 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/opops/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、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。