Java性能测试利器:JMH性能基准测试
欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。

目录
1 引言
JMH(Java Microbenchmark Harness)是专门用于Java代码微基准测试的工具套件。为什么不用简单的 System.currentTimeMillis() 呢?因为JVM(Java虚拟机)非常复杂,它会做很多优化,比如:
- JIT编译(Just-In-Time):代码在运行多次后会被编译成本地机器码,性能会发生变化。JMH会先进行“预热”(Warmup)来确保测试的是优化后的代码。
- 死码消除(Dead Code Elimination):如果JVM发现你的计算结果没有被使用,它可能会直接“删除”这部分代码,导致你测试了个寂寞。
- 其他优化:循环展开、方法内联等。
JMH处理了这些复杂性,让我们能更准确地测量代码的真实性能。
资料:
https://segmentfault.com/a/1190000045043032
https://github.com/lexburner/JMH-samples
https://openjdk.org/projects/code-tools/jmh/
https://cloud.tencent.com/developer/article/1760933(推荐)
https://www.cnblogs.com/wupeixuan/p/13091381.html
https://jenkov.com/tutorials/java-performance/jmh.html(推荐)
JMH是OpenJDK下的一个独立项目,用于提供精准的基准测试。无论使用哪个Java版本,我们都需要在项目中添加相应依赖。本篇将以Java 8环境为例进行演示。
2 基础概念
2.1 注解
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: 用于基准测试前的初始化动作,只能用于方法。有以下颗粒度:
- Level.Trial:最高级别,在整个基准测试(Benchmark)的运行前后,只会执行一次。非常适合执行那些开销巨大且只需一次的初始化/销毁操作,例如启动和关闭Spring应用上下文。
- Level.Iteration:在每一轮迭代(Iteration)前后执行。如果你有多个@Measurement迭代,它会执行多次。
- Level.Invocation:最低级别,在每次调用(Invocation)@Benchmark标记的方法前后都会执行。注意:这个级别会带来巨大的性能开销,通常不推荐使用,除非你的初始化/销毁动作本身就是测试的一部分。
@TearDown 用于基准测试后执行,主要用于资源的回收,只能用于方法。颗粒度和@Setup是一样的。
- Level.Trial: 这个注解标记 tearDown() 方法在所有测试结束后运行一次,确保我们能干净地关闭Spring上下文。
- Level.Iteration:执行迭代级别。
- Level.Invocation:每次方法调用级别。
2.2 jmh参数
public static void main(String[] args) throws IOException, RunnerException {
// 可以在这里配置 JMH 参数
String[] jmhArgs = {
// 指定运行的 benchmark 类(可选,不指定则运行所有)
".ChainControllerBenchmark.*",
// 预热迭代次数
"-wi", "3",
// 测试迭代次数
"-i", "5",
// 每次迭代时间(秒)
"-r", "10s",
// 预热每次迭代时间(秒)
"-w", "5s",
// 并发线程数
"-t", "1",
// 输出格式
"-rf", "json",
// 输出文件
"-rff", "benchmark-results.json"
};
// 如果命令行传入了参数,则使用命令行参数,否则使用默认配置
if (args.length > 0) {
Main.main(args);
} else {
Main.main(jmhArgs);
}
}
3 JMH使用注意事项
JMH的死码消除会优化一些注释代码+不可达代码,如下:
@Benchmark
public void testMethod() {
int a = 1;
int b = 2;
int sum = a + b;
}
JVM可以检测到分配给sum的a+b的计算从未被使用。因此,JVM可以完全取消a+b的计算。它被认为是死代码。JVM然后可以检测到sum变量从未被使用,并且随后a和b也从未被使用。他们也可以被淘汰。上面的例子最终会被优化成
@Benchmark
public void testMethod() {
}
这样会影响测试结果。JMH提供了如下两种方法来避免死码。一种是将变量当成返回值返回。示例
@Benchmark
public int testMethod() {
int a = 1;
int b = 2;
int sum = a + b;
return sum;
}
一种是利用Blackhole 的 consume 来避免 JIT 的优化消除。
Blackhole.consume(result): 用它来“消费”掉结果,让JVM认为这个结果被使用了,从而避免把我们的方法调用当作无用代码优化掉。
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.infra.Blackhole;
public class MyBenchmark {
@Benchmark
public void testMethod(Blackhole blackhole) {
int a = 1;
int b = 2;
int sum = a + b;
blackhole.consume(sum);
}
}
其他陷阱还有常量折叠与常量传播、永远不要在测试中写循环、使用 Fork 隔离多个测试方法、方法内联、伪共享与缓存行、分支预测、多线程测试等。
有一些陷阱示例在https://github.com/lexburner/JMH-samples。
另外,我们在实际测试中需要注意,不要测试“过小”的代码,测试 a+b 这种操作意义不大,因为JMH本身的开销可能会影响结果。通常测试一个有业务意义的方法或一个循环。
4 测试基础代码
比如经典测试,String的+操作和StringBuilder.append()的性能。
引入依赖:
<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>
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime) // 测试模式:平均耗时
@OutputTimeUnit(TimeUnit.NANOSECONDS) // 输出结果的时间单位:纳秒
@State(Scope.Thread) // 状态管理:每个线程持有一份实例
public class StringBenchmark {
// 使用@Param注解可以进行参数化测试
@Param({"10", "50", "100"})
private int iterations;
@Benchmark
public String testStringAdd() {
String result = "";
for (int i = 0; i < iterations; i++) {
result = result + "s";
}
return result;
}
@Benchmark
public String testStringBuilderAppend() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < iterations; i++) {
sb.append("s");
}
return sb.toString();
}
public static void main(String[] args) throws RunnerException {
String report = System.currentTimeMillis() + "-jmhReport.json";
Options opt = new OptionsBuilder()
.include(StringBenchmark.class.getSimpleName())
.forks(1)
.result(report)
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(opt).run();
}
}
输出参数解释如下:
- Benchmark:测试的方法名。
- (iterations):测试时使用的参数值。
- Mode:测试模式 (avgt 代表 AverageTime)。
- Cnt:总共执行了多少轮测试。
- Score:基准得分,在这里就是平均执行时间。这个值越小越好。
- Error:误差范围。
- Units:得分的单位 (ns/op 表示“纳秒/每次操作”)。
生成的json结果我们可以上传到网站进行可视化分析
- JMH Visual Chart:http://deepoove.com/jmh-visual-chart/
- Visualizer:https://jmh.morethan.io/
5 测试并发代码
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.Throughput) // 模式改为吞吐量,看单位时间内的操作次数
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1) // 预热3轮,每轮1秒
@Measurement(iterations = 5, time = 1) // 正式测试5轮,每轮1秒
@Fork(1) // fork出一个子进程来执行,避免互相干扰
@State(Scope.Group) // 关键:Scope.Group让同一组的线程共享实例
public class ConcurrentMapBenchmark {
private Map<String, String> map;
@Param({"sync", "concurrent"})// 参数sync:使用同步的HashMap
private String mapType;
@Setup
public void setup() {
if ("sync".equals(mapType)) {
map = Collections.synchronizedMap(new HashMap<>());
} else {
map = new ConcurrentHashMap<>();
}
// 预填充数据
for (int i = 0; i < 10000; i++) {
map.put(String.valueOf(i), String.valueOf(i));
}
}
// 使用@Group注解,让多个测试方法在同一个线程组中运行
@Benchmark
@Group("RW_Group")
@GroupThreads(4) // 4个线程执行写操作
public void writer() {
String key = String.valueOf((int)(Math.random() * 10000));
map.put(key, "value");
}
@Benchmark
@Group("RW_Group")
@GroupThreads(4) // 4个线程执行读操作
public void reader(Blackhole bh) {
String key = String.valueOf((int)(Math.random() * 10000));
String value = map.get(key);
// 关键:使用Blackhole.consume()来避免死码消除
bh.consume(value);
}
public static void main(String[] args) throws RunnerException {
String report = System.currentTimeMillis() + "-jmhReport.json";
Options opt = new OptionsBuilder()
.include(ConcurrentMapBenchmark.class.getSimpleName())
.forks(1)
.result(report)
.resultFormat(ResultFormatType.JSON)
.build();
new Runner(opt).run();
}
}
在多线程的测试中,我们使用Scope.Group。
6 SpringBoot集成: @Beanchmark + MockMvc
我们需要将JMH与SpringBoot的测试框架集成起来,做到能测试各个复杂业务方法,又避免真实的网络开销和延迟。
6.1 引入依赖
<!-- JMH 依赖 -->
<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>
<!-- Spring Boot Test 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>compile</scope>
</dependency>
这里只是做演示,生产中绝对不要设置test为compile。
一般我们可以创建一个新的源码目录,如src/jmh/java,然后在Maven中配置build-helper-maven-plugin插件,将这个src/jmh/java目录添加为额外的测试源码目录。这样,JMH打包时能找到它,同时它又不会污染主源码或常规的单元测试。
6.2 测试Controller接口
因为启动和停止一个SpringBoot应用的开销非常大,基准测试只需要在运行期间做一次,而不是每次调用测试方法都做。
因此,我们需要创建一个特殊的@State类来管理Spring应用的生命周期。
举例,我接手的controller中有这么一个方法
@PostMapping("/getExpandedIdstTree")
@ResponseBody
@ApiOperation(value = "6-7-9_产业图谱查询", notes = "产业图谱查询")
public ResultData<IdstTreeVO> getExpandedIdstTree(@RequestBody @Valid IdstTreeQueryReq req) {
try {
return ResultData.success(industrialService.getExpandedIdstTree(req));
} catch (Exception e) {
log.error("图谱查询报错:{}", e);
return ResultData.error("图谱查询报错:" + e);
}
}
⬆️这个方法url命名、注释和编写规范都有问题,如果感兴趣可以评论,以后可以分享一下。
这里我们先对这个方法进行测试。
// 测试模式改为吞吐量,这对于测试API接口很常见
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS) // 输出单位为:每秒操作数
@State(Scope.Benchmark) // 关键:State在所有线程中共享,且整个基准测试只创建一次实例
public class ChainControllerBenchmark {
private ConfigurableApplicationContext context;
private MockMvc mockMvc;
// @Setup 在整个基准测试运行前执行一次
@Setup(Level.Trial)
public void setup() {
// 启动Spring Boot应用
// 我们将主程序类传入
this.context = SpringApplication.run(IndustrialAmsApplication.class);
// 获取 WebApplicationContext 来构建 MockMvc
WebApplicationContext webAppContext = this.context.getBean(WebApplicationContext.class);
// 构建 MockMvc
this.mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
}
// @TearDown 在整个基准测试运行后执行一次
@TearDown(Level.Trial)
public void tearDown() {
// 关闭应用上下文,释放资源
this.context.close();
}
/**
* 图谱查询(全查)基准测试
*/
@Benchmark
public void testGetExpandedIdstTree(Blackhole blackhole) throws Exception {
MvcResult result = mockMvc.perform(post("/industrial/getExpandedIdstTree")
.contentType("application/json")
.content("{\"idstNo\":\"TEST001\",\"relaTypeCd\":\"UPSTREAM\",\"depth\":3}"))
.andExpect(status().isOk())
.andReturn();
blackhole.consume(result);
}
}
6.2.1 启动测试
有很多种方式启动jmh测试。
我们可以和之前一样使用main方法启动测试,也可以通过jar包方式启动。
- 基准测试Jar包
我们可以通过maven 打包,然后使用java命令指定Beanchmark类进行测试。
mvn clean package
java -jar target/benchmarks.jar ChainControllerBenchmark -rf json -rff ./jmhResult.json
使用这种方法需要在maven中配置插件,这里我写成了一个profile,直接使用build也行。
因为我们springboot运行会依赖一些自动装配,因此我们也需要将相关的配置比如spring.factories装载进去。不然打包的时候可能会报错。
因为springboot本身也有依赖shade插件,因此我们自己的shade插件要指定id。
<profiles>
<profile>
<id>jmh</id>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<id>shade-jmh-jar</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>springboot-jmh</finalName>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/spring.handlers</resource>
</transformer>
<transformer
implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
<resource>META-INF/spring.factories</resource>
</transformer>
<transformer
implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/spring.schemas</resource>
</transformer>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
- 使用JMH插件进行测试
在idea中,我们可以通过插件JMH Java Microbenchmark Harness进行测试。
更多推荐

所有评论(0)