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

在这里插入图片描述

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。

https://hg.openjdk.org/code-tools/jmh/file/3769055ad883/jmh-samples/src/main/java/org/openjdk/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结果我们可以上传到网站进行可视化分析

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

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐