Java性能测试利器:JMH性能基准测试

103 阅读10分钟

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

引言

本篇适合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。

  • 循环展开:循环可能被优化,影响性能测量。

  • 方法内联:小型方法可能被内联,减少调用开销。

  • 伪共享:多线程测试中,线程访问相邻内存可能导致缓存竞争。

更多示例见:

github.com/lexburner/J…

hg.openjdk.org/code-tools/…

另外,我们在实际测试中需要注意,不要测试“过小”的代码,测试 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结果我们可以上传到网站进行可视化分析

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进行测试。