2025-06-13
工具运维
0

目录

JMH (Java Microbenchmark Harness)
JMH 有什么用
JMH核心概念解剖
测试模式(Benchmark Mode)
生命周期控制
状态作用域(State Scope)
注解
SpringBoot 集成实践
依赖
JMH 生态
JMH 测试陷阱
死代码消除
恒定折叠
创建JMH测试
字符串拼接性能对决
测试Spring Bean性能
官方样例
总结

在各种各样的并发工具类中,如何确定我们代码的并发性能呢?这个时候就需要来聊下 Java 中提供的微基准测试工具 JMH, 它主要是基于方法层面的基准测试,精度可以达到纳秒级。JMH由Oracle内部实现JIT的大牛们编写,他们比任何人都了解JIT以及JVM对于基准测试的影响

JMH (Java Microbenchmark Harness)

在现代Java开发中,性能优化不再是凭直觉猜测的游戏。JMH(官网 )作为由Oracle JIT团队开发的专业级微基准测试框架,提供了纳秒级精度的代码性能分析能力,成为衡量方法级性能的黄金标准。本文将全面解析JMH的核心原理、实践技巧与避坑指南。

JMH 有什么用

JMH通过科学控制JVM变量(如JIT编译、垃圾回收)解决传统手工测试的三大痛点:

  • 消除干扰因素:独立的测试进程隔离避免方法间优化干扰

  • 预热机制:通过预热迭代触发JIT编译,使结果反映稳定性能

  • 多维度统计:支持吞吐量(OPS)、平均时间、百分位数(TP99)等丰富指标

典型应用场景:

  • 🔍 量化对比算法实现(如String拼接 vs StringBuilder)

  • ⚡ 检测方法执行时间与输入规模的相关性

  • 📊 验证性能优化效果(如缓存引入前后的吞吐量变化)

  • 🧪 多线程并发场景下的性能压测

JMH核心概念解剖

测试模式(Benchmark Mode)

模式度量指标适用场景
Throughput每秒操作数(ops/s)高并发接口吞吐评估
AverageTime单次操作平均耗时算法效率对比
SampleTime响应时间分布(TP99)SLA合规性验证
SingleShotTime冷启动耗时初始化性能测试

生命周期控制

预热(Warmup):触发JIT编译,避免冷启动扭曲结果9

java
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)

测量(Measurement):实际统计阶段

java
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)

状态作用域(State Scope)

作用域实例分配规则使用场景
Scope.Thread每线程独立实例无状态服务测试
Scope.Benchmark全局共享实例单例组件性能
Scope.Group线程组共享实例生产者-消费者模型

注解

因为我们主要利用JMH提供的注解来进行基准测试,因此我们有必要了解一下JMH一些常用注解

  • @State: 表明类的所有属性的作用域。只能用于类上。它有如下选项

    Scope.Thread: 默认的State,每个测试线程分配一个实例; Scope.Benchmark: 所有测试线程共享一个实例,用于测试有状态实例在多线程共享下的性能; Scope.Group: 每个线程组共享一个实例;
  • @BenchmarkMode: 用于指定基准测试的执行模式,如吞吐量、平均执行时间。可用于类或者方法上,它有如下模式

    Throughput:整体吞吐量,每秒执行了多少次调用,单位为 ops/time AverageTime:用的平均时间,每次操作的平均时间,单位为 time/op SampleTime:随机取样,最后输出取样结果的分布 SingleShotTime:只运行一次,往往同时把 Warmup 次数设为 0,用于测试冷启动时的性能 All:上面的所有模式都执行一次
  • @Measurement: 用于控制压测的次数、时间和批处理数量。可用于类或者方法上,它有如下参数

    iterations:测量的次数 time:每次测量持续的时间 timeUnit:时间的单位,默认秒 batchSize:批处理大小,每次操作调用几次方法
  • @Warmup: 预热,可用于类或者方法上

    由于JVM会使用JIT对热点代码进行编译,因此同一份代码可能由于执行次数的增加而导致执行时间差异太大,因此我们可以让代码先预热几轮,预热时间不算入测量计时。@WarmUp 的使用和 @Measurement 一致。
  • @Fork: 用于指定fork出多少个子进程来执行同一基准测试方法,可用于类或者方法上。例如@Fork指定数量为2,则 JMH 会 fork 出两个进程来进行测试

  • @Threads: 用于指定使用多少个线程来执行基准测试方法,可用于类或者方法上。例如@Threads 指定线程数为 2 ,那么每次测量都会创建两个线程来执行基准测试方法

  • @OutputTimeUnit: 可以指定输出的时间单位,可用于类或者方法注解

  • @Param: 指定某项参数的多种情况,特别适合用来测试一个函数在不同的参数输入的情况下的性能,只能作用在字段上,使用该注解必须定义 @State 注解。

  • @Setup: 用于基准测试前的初始化动作,只能用于方法

  • @TearDown 用于基准测试后执行,主要用于资源的回收,只能用于方法

SpringBoot 集成实践

依赖

添加需要添加两个依赖:jmh-core (jmh的核心)、jmh-generator-annprocess(注解处理包)

最新版本可以在Maven仓库 https://mvnrepository.com/ 中找到。

xml
<properties> <jmh.version>1.36</jmh.version> </properties> <dependencies> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-core</artifactId> <version>${jmh.version}</version> </dependency> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-generator-annprocess</artifactId> <version>${jmh.version}</version> <scope>provided</scope> </dependency> </dependencies>
gradle
// Gradle 示例 dependencies { implementation 'org.openjdk.jmh:jmh-core:LATEST_VERSION' annotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:LATEST_VERSION' }

JMH 生态

JMH 是 OpenJDK 项目的一部分,广泛用于 Java 性能测试。以下是一些与 JMH 相关的项目和工具:

  • JMH Visualizer:一个用于可视化 JMH 测试结果的工具。
  • jmh-gradle-plugin:一个用于在 Gradle 项目中集成 JMH 的插件。
  • jmh-compare-gui:一个用于比较不同 JMH 测试结果的图形界面工具。

通过这些工具和插件,可以更全面地进行 Java 性能测试和分析。

Idea 中的 JMH Java Microbenchmark Harness 插件官网

JMH 在去使用的时候,应该将项目构建成jar包,然后在服务器上进行微基准测试,服务器性能上约接近生产越好,如果测试性能完全ok,那到生产服务器上也大致相同。

在开发的过程中,如果说边开发边进行这种微基准的测试实际上是不准确的,毕竟开发环境会对结果产生影响。平时你要想进行一些微基准的测试的话,要是每次打个包来进行正规一个从头到尾的测试 ,完了之后发现问题不对再去重新改,效率太低了,这个时候就需要用到我们的插件。

JMH 测试陷阱

死代码消除

运行微基准测试时,了解优化非常重要。否则,它们可能会以非常误导的方式影响基准测试结果。

为了使事情更具体一些,让我们考虑一个例子:

java
@Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public void doNothing() { } @Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public void objectCreation() { new Object(); }

我们期望对象分配的成本高于什么都不做。但是,如果我们运行基准测试:

text
Benchmark Mode Cnt Score Error Units BenchMark.doNothing avgt 40 0.609 ± 0.006 ns/op BenchMark.objectCreation avgt 40 0.613 ± 0.007 ns/op

显然,在 TLAB 中找到一个位置,创建和初始化一个对象几乎是免费的!仅通过查看这些数字,我们应该知道这里有些东西并没有完全加起来。

在这里,我们是死代码消除的受害者。编译器非常擅长优化冗余代码。事实上,这正是 JIT 编译器在这里所做的。

为了防止这种优化,我们应该以某种方式欺骗编译器并使其认为代码被其他组件使用。 实现此目的的一种方法是返回创建的对象:

java
@Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public Object pillarsOfCreation() { return new Object(); }

此外,我们可以让黑洞消耗它:

java
@Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public void blackHole(Blackhole blackhole) { blackhole.consume(new Object()); }

让黑洞使用对象是说服 JIT 编译器不应用死代码消除优化的一种方法。无论如何,如果我们再次运行这些基准测试,这些数字将更有意义:

text
Benchmark Mode Cnt Score Error Units BenchMark.blackHole avgt 20 4.126 ± 0.173 ns/op BenchMark.doNothing avgt 20 0.639 ± 0.012 ns/op BenchMark.objectCreation avgt 20 0.635 ± 0.011 ns/op BenchMark.pillarsOfCreation avgt 20 4.061 ± 0.037 ns/op

恒定折叠

让我们考虑另一个例子:

java
@Benchmark public double foldedLog() { int x = 8; return Math.log(x); }

基于常量的计算可能会返回完全相同的输出,而不管执行次数如何。 因此,JIT 编译器很有可能将对其结果替换对数函数调用:

java
@Benchmark public double foldedLog() { return 2.0794415416798357; }

这种形式的部分评估称为恒定折叠。在这种情况下,不断折叠完全避免了 Math.log 调用,这是基准测试的重点。

为了防止常量折叠,我们可以将常量状态封装在一个状态对象中:

java
@State(Scope.Benchmark) public static class Log { public int x = 8; } @Benchmark public double log(Log input) { return Math.log(input.x); }

如果我们相互运行这些基准:

text
Benchmark Mode Cnt Score Error Units BenchMark.foldedLog thrpt 20 449313097.433 ± 11850214.900 ops/s BenchMark.log thrpt 20 35317997.064 ± 604370.461 ops/s

创建JMH测试

字符串拼接性能对决

测试+拼接 vs StringBuilder在不同数据量下的性能:

java
@State(Scope.Benchmark) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public class StringConnectTest { @Param({"10", "100", "1000"}) private int length; @Benchmark public void testStringAdd(Blackhole bh) { String a = ""; for (int i = 0; i < length; i++) { a += i; } bh.consume(a); } @Benchmark public void testStringBuilder(Blackhole bh) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < length; i++) { sb.append(i); } bh.consume(sb.toString()); } }

典型结果分析

text
Benchmark (length) Mode Cnt Score Error Units testStringAdd 10 avgt 5 161.5 ± 17.1 ns/op testStringBuilder 10 avgt 5 34.2 ± 0.7 ns/op testStringAdd 100 avgt 5 14768.9 ± 452.3 ns/op testStringBuilder 100 avgt 5 317.5 ± 5.2 ns/op

数据表明:超过100次拼接时,StringBuilder比+快46倍以上,也就是指标 14768.9 / 317.5 = 46, 揭示性能差距随数据量指数级扩大。

测试Spring Bean性能

通过@Setup初始化Spring上下文,实现真实环境下的组件性能验证

java
@State(Scope.Benchmark) public class ServiceBenchmark { private ConfigurableApplicationContext context; private UserService userService; @Setup public void init() { context = SpringApplication.run(MainApp.class); userService = context.getBean(UserService.class); } @Benchmark public void testUserQuery() { userService.findById(1L); // 测试DAO方法性能 } @TearDown public void close() { context.close(); } }

运行测试类,如果遇到下面的错误

java
ERROR: org.openjdk.jmh.runner.RunnerException: ERROR: Exception while trying to acquire the JMH lock (C:\WINDOWS\/jmh.lock): C:\WINDOWS\jmh.lock (拒绝访问。), exiting. Use -Djmh.ignoreLock=true to forcefully continue. at org.openjdk.jmh.runner.Runner.run(Runner.java:216) at org.openjdk.jmh.Main.main(Main.java:71)

这个错误是因为JMH运行需要访问系统的TMP目录,解决办法是:打开 RunConfiguration -> Environment Variables -> include system environment viables

官方样例

如果说大家对JMH有兴趣,你们在工作中可能会有用的上大家去读一下 官方 的例子,官方大概有好几十个例子程序,你可以自己一个一个的去研究。

总结

JMH 将性能测试从“经验猜测”提升到科学度量层面,使用时需注意:

测试策略:

  • 优先使用Throughput模式评估服务容量

  • 用SampleTime分析长尾请求

配置原则:

  • 预热迭代≥3次,持续≥1秒19

  • 测量迭代≥5次确保统计显著性

结果解读:

  • 关注Score而非绝对时间

  • 误差值(Error)超过10%需增加测试迭代7

性能优化应遵循 “测量-优化-验证” 闭环。JMH提供了这个闭环中最可靠的测量工具,让每一次优化都有据可循。真正的性能洞察始于精确测量,而非直觉猜测。掌握JMH,让性能调优从玄学走向科学。

本文作者:柳始恭

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!