在各种各样的并发工具类中,如何确定我们代码的并发性能呢?这个时候就需要来聊下 Java 中提供的微基准测试工具 JMH, 它主要是基于方法层面的基准测试,精度可以达到纳秒级。JMH由Oracle内部实现JIT的大牛们编写,他们比任何人都了解JIT以及JVM对于基准测试的影响
在现代Java开发中,性能优化不再是凭直觉猜测的游戏。JMH(官网 )作为由Oracle JIT团队开发的专业级微基准测试框架,提供了纳秒级精度的代码性能分析能力,成为衡量方法级性能的黄金标准。本文将全面解析JMH的核心原理、实践技巧与避坑指南。
JMH通过科学控制JVM变量(如JIT编译、垃圾回收)解决传统手工测试的三大痛点:
消除干扰因素:独立的测试进程隔离避免方法间优化干扰
预热机制:通过预热迭代触发JIT编译,使结果反映稳定性能
多维度统计:支持吞吐量(OPS)、平均时间、百分位数(TP99)等丰富指标
典型应用场景:
🔍 量化对比算法实现(如String拼接 vs StringBuilder)
⚡ 检测方法执行时间与输入规模的相关性
📊 验证性能优化效果(如缓存引入前后的吞吐量变化)
🧪 多线程并发场景下的性能压测
模式 | 度量指标 | 适用场景 |
---|---|---|
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)
作用域 | 实例分配规则 | 使用场景 |
---|---|---|
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 用于基准测试后执行,主要用于资源的回收,只能用于方法
添加需要添加两个依赖: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 是 OpenJDK 项目的一部分,广泛用于 Java 性能测试。以下是一些与 JMH 相关的项目和工具:
通过这些工具和插件,可以更全面地进行 Java 性能测试和分析。
Idea 中的 JMH Java Microbenchmark Harness 插件官网
JMH 在去使用的时候,应该将项目构建成jar包,然后在服务器上进行微基准测试,服务器性能上约接近生产越好,如果测试性能完全ok,那到生产服务器上也大致相同。
在开发的过程中,如果说边开发边进行这种微基准的测试实际上是不准确的,毕竟开发环境会对结果产生影响。平时你要想进行一些微基准的测试的话,要是每次打个包来进行正规一个从头到尾的测试 ,完了之后发现问题不对再去重新改,效率太低了,这个时候就需要用到我们的插件。
运行微基准测试时,了解优化非常重要。否则,它们可能会以非常误导的方式影响基准测试结果。
为了使事情更具体一些,让我们考虑一个例子:
java@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void doNothing() {
}
@Benchmark
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
public void objectCreation() {
new Object();
}
我们期望对象分配的成本高于什么都不做。但是,如果我们运行基准测试:
textBenchmark 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 编译器不应用死代码消除优化的一种方法。无论如何,如果我们再次运行这些基准测试,这些数字将更有意义:
textBenchmark 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);
}
如果我们相互运行这些基准:
textBenchmark 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
测试+拼接 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());
}
}
典型结果分析
textBenchmark (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
, 揭示性能差距随数据量指数级扩大。
通过@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();
}
}
运行测试类,如果遇到下面的错误
javaERROR: 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 许可协议。转载请注明出处!