2024-10-04
工具运维
0

目录

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

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

在现代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 许可协议。转载请注明出处!