JMH 实战:Java 微基准测试精确测量性能

为什么需要 JMH?手写 for 循环计时的坑

JMH(Java Microbenchmark Harness)是 OpenJDK 官方出品的 Java 微基准测试框架,专门用来精确测量一小段代码的性能。很多人习惯用 System.nanoTime() 包一层 for 循环就敢下结论,但 JVM 的 JIT 编译、方法内联、死代码消除、常量折叠会在你毫无察觉时把“测试结果”变成“幻觉”。要可信地量化 Java 代码性能差异,JMH 几乎是事实标准。

典型的手写基准有三个致命问题:一是没有预热(warmup),测到的全是解释执行或未优化版本;二是被测计算过程的结果没有被使用,JIT 直接把整段代码优化没了;三是循环变量被当成常量折叠。JMH 通过注解和独立的 Fork 进程把这些坑逐一堵上。很多团队上线前用 System.currentTimeMillis() 粗略对比两个实现,得出的“快 20%”往往只是测量噪声,真正上线后毫无区别,反而误导了技术选型。

快速开始:引入依赖与第一个 @Benchmark

在 Maven 项目里引入 jmh-core 与注解处理器 jmh-generator-annprocess。注意 scope 用 provided 即可,它只在编译期生成基准 runner。

<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>

写第一个基准类,用 @Benchmark 标注被测方法,main 里用 org.openjdk.jmh.runner.Runner 启动。JMH 会自动完成预热、测量、多 Fork。

import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

public class StringConcatBenchmark {
    @Benchmark
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public String testStringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 10; i++) {
            sb.append(i);
        }
        return sb.toString();
    }

    public static void main(String[] args) throws Exception {
        org.openjdk.jmh.runner.Runner runner =
            new org.openjdk.jmh.runner.Runner(new OptionsBuilder()
                .include(StringConcatBenchmark.class.getSimpleName())
                .forks(1)
                .build());
        runner.run();
    }
}

核心注解一次说清

JMH 的能力几乎都靠注解声明。@State 定义基准的状态对象(数据从哪来),@Setup 在每次迭代或调用前准备数据,@BenchmarkMode 指定测量维度,@Warmup 与 @Measurement 控制迭代轮数。下面是一段带状态与准备逻辑的例子。

@State(Scope.Benchmark)
public class MapBenchmark {
    private Map<Integer, Integer> map;

    @Setup(Level.Trial)
    public void setup() {
        map = new HashMap<>();
        for (int i = 0; i < 10000; i++) {
            map.put(i, i);
        }
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public Integer testGet() {
        return map.get(5000);
    }
}

@State 的 Scope 有三个级别:Benchmark 所有线程共享一个实例,Thread 每线程一个,Group 同一线程组内共享。并发基准里选错 Scope,测出来的其实是“共享变量竞争”而不是你想要的“单操作耗时”,这是最常见的误读来源之一。

四大基准模式与输出时间单位

@BenchmarkMode 决定 JMH 报告什么指标。不同模式适合不同的性能问题:吞吐看“单位时间能做多少”,平均时间看“单次多慢”,采样时间能看到分布长尾,单次模式适合测冷启动。

模式含义适用场景
Throughput单位时间内的操作次数(ops/s)比较两种实现谁更快
AverageTime单次调用的平均耗时看典型延迟
SampleTime采样每次调用耗时,给出分位数排查长尾毛刺(P99/P999)
SingleShotTime只跑一次,测冷启动/单次开销框架初始化、对象创建

结合 @OutputTimeUnit 指定单位(纳秒/微秒/毫秒/秒),再配合 @Warmup(iterations=3, time=1) 与 @Measurement(iterations=5, time=1),一个可信的基准就成型了。想了解 JVM 怎么为这些代码做运行时优化,可以参考我们讲 Java 21 虚拟线程 时提到的运行时模型。

避坑一:死代码消除与 Blackhole

如果你的基准方法返回一个值但调用方(JMH 框架)没有使用它,JIT 可能判定“这个结果没人要”而把整个计算删掉。正确做法是把返回值交给 Blackhole.consume(),让 JIT 无法证明它能消除。

@Benchmark
public int testComputeWrong() {
    return heavyCompute(1024); // 危险:结果未使用,可能被优化掉
}

@Benchmark
public void testComputeRight(Blackhole bh) {
    bh.consume(heavyCompute(1024)); // 安全:强制保留计算
}

避坑二:常量折叠与循环展开

把循环上界写成字面量常量(如 for (int i = 0; i < 1024; i++)),JIT 可能把循环完全展开成顺序指令,甚至把可静态求值的部分直接算成常量——这根本不是你线上“输入不确定”的真实路径。务必用 @State 里的非 final 字段或 @Param 把输入变成运行时值。

@State(Scope.Benchmark)
public class LoopBenchmark {
    @Param({"16", "256", "1024"})
    public int size; // 运行时注入,避免常量折叠

    private int[] data;

    @Setup
    public void setup() {
        data = new int[size];
        for (int i = 0; i < size; i++) data[i] = i;
    }

    @Benchmark
    public long testSum(Blackhole bh) {
        long sum = 0;
        for (int v : data) sum += v;
        bh.consume(sum);
        return sum;
    }
}

用 @Param 做参数化基准

@Param 让同一方法在不同参数组合下自动跑多组基准,非常适合对比集合初始容量、线程数、数据规模对性能的影响。配合 @CompilerControl 还能控制方法是否内联,定位“内联导致的性能差异”。

@State(Scope.Benchmark)
public class CollBenchmark {
    @Param({"16", "128", "1024"})
    public int capacity;

    @Benchmark
    public Map<Integer, Integer> testHashMap() {
        Map<Integer, Integer> m = new HashMap<>(capacity);
        for (int i = 0; i < capacity; i++) m.put(i, i);
        return m;
    }
}

运行方式与结果解读

打成可执行的 benchmark jar 后用 java -jar 运行最干净(独立进程避免类加载干扰)。输出里重点关注 Score(指标值)、Error(置信区间)和单位。组内多行代表不同参数组合,CI 误差带越小结果越可信。

# 打包(maven-shade-plugin 把 jmh 依赖打进 fat jar)
mvn clean package
java -jar target/benchmarks.jar -wi 3 -i 5 -f 1

# 只跑某个类、输出 JSON 便于做回归对比
java -jar target/benchmarks.jar MyBenchmark -rf json

结果解读铁律:只比较“误差带不重叠”的差异。JMH 输出里两个 Score 的 Error 区间一旦重合,就说明这次测不出显著差别,别硬下“快 3%”的结论。把基准跑在 GitHub Actions 上做性能回归门禁,比在本机随手跑更有价值。

与 Spring Boot 项目集成

把 JMH 放进独立的测试/基准模块,用 test scope 的 JMH 依赖,避免污染生产 classpath。可以在 @Setup 里用 Spring 的 ApplicationContext 取出 Bean 再测其真实调用耗时,注意 Benchmark 类本身不要被 Spring 托管,否则 Fork 进程会重复启动容器拖慢测试。

@State(Scope.Benchmark)
public class RepoBenchmark {
    private MyRepository repo;

    @Setup(Level.Trial)
    public void setup() {
        AnnotationConfigApplicationContext ctx =
            new AnnotationConfigApplicationContext(AppConfig.class);
        repo = ctx.getBean(MyRepository.class);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @OutputTimeUnit(TimeUnit.MILLISECONDS)
    public List<Order> testQuery() {
        return repo.findRecentOrders(100);
    }
}

若项目是 Spring Boot 3 技术栈,注意 Jakarta 命名空间与 Java 17+ 基线,JMH 1.36+ 完全兼容,无需额外配置。

JMH 与手写计时器的本质区别

同样是“跑 N 次取平均”,为什么 JMH 的结论可信而手写 for 循环不可信?核心差异在于 JMH 在 JVM 层面做了控制:它在独立进程里预热、按统一规则迭代、用 Blackhole 锁住结果,还把 GC、类加载、JIT 编译阶段对测量的污染降到最低。手写计时把这些因素全部混在一起,平均值根本分不清“冷启动慢”还是“真实代码慢”。

维度手写 System.nanoTimeJMH
预热无,首轮最慢@Warmup 多轮,JIT 稳定后才测
死代码结果未使用易被删Blackhole 强制保留
常量折叠循环上界写死易触发@Param 注入运行时值
进程隔离与业务同进程,类加载干扰独立 Fork 进程
误差无置信区间输出 Score ± Error

一句话:手写计时适合“大概感受一下有没有数量级差异”,而任何要写进方案评审、要据此替换核心实现的结论,都必须用 JMH 这类受控基准来支撑。

统计显著性与两个常见误区

误区一:在 @Benchmark 里调用 System.gc() 想“清干净再测”。这恰恰会引入一次不可控的全堆回收,让你的分数剧烈抖动,JMH 官方明确建议不要在基准里手动 GC。误区二:只看 Score 数字大小就宣布胜负。正确做法是看两个参数组合的 Error(95% 置信区间)是否重叠——重叠就说明差异不在测量能力之内,应判定为“无显著差异”。把基准固定在同样的机器规格、同样的 JVM 版本、同样的 -Xmx 上跑,才能保证跨次结果可比较。

生产落地检查清单

检查项为什么
结果必须交给 Blackhole 或返回防止死代码消除
输入用 @Param / 非 final 字段避免常量折叠与循环展开
至少 3 次 warmup + 5 次 measurement等 JIT 稳定后再测量
forks ≥ 1,最好独立 jar 运行隔离类加载与 GC 干扰
只看误差带不重叠的差异避免把噪声当结论
固定 JVM 参数(-Xms=-Xmx、关闭 Tiered)保证可比性

把这份清单当成发布前的性能 PR 检查项,配合 CI 跑 JSON 结果做前后版本 diff,就能让“这段代码更快”从拍脑袋变成有数据支撑的结论。关于 JVM 运行时的其它细节,我们也写过 Java 模式匹配 与 Java 21 虚拟线程,可以和本文对照着读。

上一篇 Astro 实战:内容优先架构打造极速 SEO 站点
下一篇 Polars 实战:用 Rust 引擎极速处理海量 DataFrame
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]