欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。
引言
本篇适合Java开发者快速上手JMH。
JMH(Java Microbenchmark Harness)是专门用于Java代码微基准测试的工具套件。为什么不用简单的 System.currentTimeMillis() 呢?因为JVM(Java虚拟机)非常复杂,它会做很多优化,比如:
一个JMH的错误示例:
public static void main(String[] args) {
long start = System.currentTimeMillis();
String result = "";
for (int i = 0; i < 1000; i++) {
result += "s";
}
long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start) + " 毫秒");
}
上述方法可能导致结果不可靠,原因包括:
- JIT编译(Just-In-Time):代码在运行多次后会被编译成本地机器码,性能会发生变化。JMH会先进行“预热”(Warmup)来确保测试的是优化后的代码。
- 死码消除(Dead Code Elimination):如果JVM发现你的计算结果没有被使用,它可能会直接“删除”这部分代码,导致你测试了个寂寞。
- 其他优化:如循环展开、方法内联和常量折叠等。
JMH通过以下方式解决这些问题,让我们能更准确地测量代码的真实性能:
- 预热迭代(Warmup):稳定JIT编译效果。
- Blackhole机制:防止死码消除。
- 精确控制:支持配置线程数、迭代次数等参数。
资料:
segmentfault.com/a/119000004…
github.com/lexburner/J…
openjdk.org/projects/co…
cloud.tencent.com/developer/a…(推荐)
www.cnblogs.com/wupeixuan/p…
jenkov.com/tutorials/j…(推荐)
JMH是OpenJDK下的一个独立项目,用于提供精准的基准测试。无论使用哪个Java版本,我们都需要在项目中添加相应依赖。本篇将以Java 8环境为例进行演示。
1 基础概念
1.1 注解
JMH提供有一些注解:
| 注解 | 作用范围 | 描述 | 示例 |
|---|---|---|---|
| @State | 类 | 定义类字段的作用域: - Scope.Thread:每个线程一个实例(默认)。 - Scope.Benchmark:所有线程共享一个实例。 - Scope.Group:每个线程组共享一个实例。 | @State(Scope.Thread) |
| @BenchmarkMode | 类/方法 | 指定测试模式: - Throughput:吞吐量(ops/time)。 - AverageTime:平均每次操作时间(time/op)。 - SampleTime:操作时间分布。 - SingleShotTime:单次运行,常用于冷启动测试。 - All:运行所有模式。 | @BenchmarkMode(Mode.AverageTime) |
| @Measurement | 类/方法 | 配置测试迭代: - iterations:迭代次数。 - time:每次迭代时间。 - timeUnit:时间单位(默认秒)。 - batchSize:每次操作的批处理大小。 | @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) |
| @Warmup | 类/方法 | 配置预热迭代以稳定JVM优化,参数同@Measurement。 | @Warmup(iterations = 3, time = 1) |
| @Fork | 类/方法 | 指定fork出的子进程数量以隔离测试。 | @Fork(1) |
| @Threads | 类/方法 | 指定测试的线程数。 | @Threads(2) |
| @OutputTimeUnit | 类/方法 | 指定输出结果的时间单位。 | @OutputTimeUnit(TimeUnit.NANOSECONDS) |
| @Param | 字段 | 为字段指定多个值,用于参数化测试,需搭配@State。 | @Param({"10", "100"}) |
| @Setup | 方法 | 测试前初始化。级别: - Level.Trial:整个测试运行前后各一次。 - Level.Iteration:每轮迭代前后。 - Level.Invocation:每次方法调用前后(开销大,慎用)。 | @Setup(Level.Trial) |
| @TearDown | 方法 | 测试后清理资源,级别同@Setup。 | @TearDown(Level.Trial) |
示例:@Param参数化测试,此示例测试不同数组大小的求和性能。:
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ParamBenchmark {
@Param({"10", "100", "1000"})
private int size;
private int[] array;
@Setup
public void setup() {
array = new int[size];
for (int i = 0; i < size; i++) {
array[i] = i;
}
}
@Benchmark
public int sumArray() {
int sum = 0;
for (int value : array) {
sum += value;
}
return sum;
}
}
示例:@Group并发测试,此示例测试同一线程组内并发执行的add和size操作。:
@State(Scope.Group)
@BenchmarkMode(Mode.Throughput)
public class GroupBenchmark {
private List<Integer> list = new ArrayList<>();
@Benchmark
@Group("listOps")
@GroupThreads(2)
public void add() {
list.add(1);
}
@Benchmark
@Group("listOps")
@GroupThreads(2)
public void size(Blackhole bh) {
bh.consume(list.size());
}
}
1.2 JMH参数
可通过命令行或代码配置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);
}
}
2 JMH使用注意事项
JMH的死码消除会优化一些注释代码+不可达代码,如下:
@Benchmark
public void testMethod() {
int a = 1;
int b = 2;
int sum = a + b; // 未使用sum,可能被优化
}
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 来避免优化消除。
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 隔离多个测试方法、方法内联、伪共享与缓存行、分支预测、多线程测试等。
-
常量折叠:JVM可能预计算常量表达式,如int x = 1 + 2变为int x = 3。
-
循环展开:循环可能被优化,影响性能测量。
-
方法内联:小型方法可能被内联,减少调用开销。
-
伪共享:多线程测试中,线程访问相邻内存可能导致缓存竞争。
更多示例见:
另外,我们在实际测试中需要注意,不要测试“过小”的代码,测试 a+b 这种操作意义不大,因为JMH本身的开销可能会影响结果。通常测试一个有业务意义的方法或一个循环。
3 测试基础代码
以下示例比较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输出:
{
"jmhVersion": "1.37",
"benchmark": "StringBenchmark.testStringAdd",
"mode": "avgt",
"threads": 1,
"forks": 1,
"warmupIterations": 3,
"measurementIterations": 5,
"primaryMetric": {
"score": 1234.567,
"scoreError": 12.345,
"scoreUnit": "ns/op",
"rawData": [[...]]
}
}
生成的json结果我们可以上传到网站进行可视化分析
- JMH Visual Chart:deepoove.com/jmh-visual-…
- Visualizer:jmh.morethan.io/
4 测试并发代码
以下测试ConcurrentHashMap与同步HashMap在并发读写场景下的性能:
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));
// 关键:使用Blackhole.consume()来避免死码消除
bh.consume(map.get(key));
}
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();
}
}
注意事项:
-
@Group和@GroupThreads支持在同一线程组内并发执行读写操作。
-
使用Scope.Group确保线程组共享同一map实例。
-
可通过调整@GroupThreads模拟实际场景的读写比例(如80%读、20%写)。
-
使用-prof async分析线程竞争和锁开销。
5 SpringBoot集成: @Beanchmark + MockMvc
我们需要将JMH与SpringBoot的测试框架(MockMvc)集成起来,做到能测试各个复杂业务方法,又避免真实的网络开销和延迟。
5.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>
注意:生产环境中,spring-boot-starter-test应使用test作用域,以避免污染主应用。
一般我们可以创建一个新的源码目录,如src/jmh/java,然后在Maven中配置build-helper-maven-plugin插件,将这个src/jmh/java目录添加为额外的测试源码目录。这样,JMH打包时能找到它,同时它又不会污染主源码或常规的单元测试。
5.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);
}
}
5.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进行测试。