跳至主要内容

使用JMH对Java应用进行微基准测试

Welcome file

简介

JMH(Java Microbenchmark Harness)是一个帮助开发者更好的实现微基准测试(microbenchmark)的工具,属于OpenJDK项目的一部分。虽然名字叫微基准测试工具,但JMH也可以适用于其他类型的基准测试,对较大型项目的基准测试同样也有帮助。

为什么需要用JMH来做基准测试

由于JVM会对代码做各种优化,而自己写的基准测试很难考虑到各方面的优化,这样会导致测试的结果可能跟线上的相差较大。JMH本身虽然不能完全消除这些优化的影响,但是它能帮助减轻这方面的影响。

本文使用环境:Java 8

使用Maven构建运行JMH

使用Maven构建运行JMH是被OpenJDK团队推荐的使用方式,因为该方式能产生更加可靠的结果。

使用Maven创建新项目

使用该命令可以创建一个基于Maven的JMH模板项目:

mvn archetype:generate 
  -DinteractiveMode=false
  -DarchetypeGroupId=org.openjdk.jmh
  -DarchetypeArtifactId=jmh-java-benchmark-archetype
  -DarchetypeVersion=1.25.2
  -DgroupId=com.nereusyi.demos
  -DartifactId=jmh-template
  -Dversion=1.0

官方示例的命令行参数中,没有-DarchetypeVersion=1.25.2这行,那样默认会生成较老版本的模板项目,需要较多改动才能适配新版本的Java,所以最好加上该参数。也可以在maven仓库上查看JMH的最新版本并替换

部分输出如下:

[INFO] ----------------------------------------------------------------------------
[INFO] Using following parameters for creating project from Archetype: jmh-java-benchmark-archetype:1.25.2
[INFO] ----------------------------------------------------------------------------
[INFO] Parameter: groupId, Value: com.nereusyi.demos
[INFO] Parameter: artifactId, Value: jmh-template
[INFO] Parameter: version, Value: 1.0
[INFO] Parameter: package, Value: com.nereusyi.demos
[INFO] Parameter: packageInPathFormat, Value: com/nereusyi/demos
[INFO] Parameter: package, Value: com.nereusyi.demos
[INFO] Parameter: groupId, Value: com.nereusyi.demos
[INFO] Parameter: artifactId, Value: jmh-template
[INFO] Parameter: version, Value: 1.0
[INFO] Project created from Archetype in dir: G:\jmh-template
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------

此时可以在执行命令的目录下看到文件夹名字为jmh-template的Maven项目。

编写测试方法

在刚生成的jmh-template里,可以看到一个生成的MyBenchmark.java类:

package com.nereusyi.demos;

import org.openjdk.jmh.annotations.Benchmark;

public class MyBenchmark {

    @Benchmark
    public void testMethod() {
        // This is a demo/sample template for building your JMH benchmarks. Edit as needed.
        // Put your benchmark code here.
    }

}

可以把需要测试的方法放到testMethod中:

@Benchmark
public int[] testMethod() {
    // This is a demo/sample template for building your JMH benchmarks. Edit as needed.
    // Put your benchmark code here.
    int[] input = IntStream.range(1, 10000).toArray();
    int inputLength = input.length;
    int temp;
    boolean is_sorted;
    for (int i = 0; i < inputLength; i++) {
        is_sorted = true;
        for (int j = 1; j < (inputLength - i); j++) {
            if (input[j - 1] > input[j]) {
                temp = input[j - 1];
                input[j - 1] = input[j];
                input[j] = temp;
                is_sorted = false;
            }
        }
        if (is_sorted) break;
    }
    return input;
}

这里把原来的void返回值修改了,是为了避免JVM的Dead Code优化。下面会介绍更多相关技巧。

构建JMH项目

现在可以在命令行进入jmh-template项目(cd jmh-template)构建:

mvn clean package

构建之后,会生成target/benchmarks.jar文件。

benchmarks.jar文件包含运行基准测试所需要的所有依赖,所以可以把该文件复制到其他机器上运行测试。

运行基准测试

java -jar运行就可以:

java -jar tartget/benchmarks.jar

运行基准测试需要一些时间,因为JMH会做预热等操作。同时也需要在运行基准测试时,关闭其他应用程序,以便生成更可靠的基准测试结果。

测试结果输出:

Benchmark                Mode  Cnt       Score     Error  Units
MyBenchmark.testMethod  thrpt   25  142673.012 ± 369.963  ops/s

在应用代码中运行JMH

也可以通过编程方式运行JMH基准测试。首先需要在pom.xml中引入JMH依赖:

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.25.2</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.25.2</version>
    <scope>provided</scope>
</dependency>

测试代码如下:

public class JmhDemo {

    @Benchmark
    public int[] testMethod(){
        int[] input = IntStream.range(1, 10000).toArray();
        int inputLength = input.length;
        int temp;
        boolean is_sorted;

        for (int i = 0; i < inputLength; i++) {
            is_sorted = true;
            for (int j = 1; j < (inputLength - i); j++) {
                if (input[j - 1] < input[j]) {
                    temp = input[j - 1];
                    input[j - 1] = input[j];
                    input[j] = temp;
                    is_sorted = false;
                }
            }
            if (is_sorted) break;
        }
        return input;
    }

    @Benchmark
    public int[] testMethod2() {
        int[] input = IntStream.range(1, 10000).toArray();
        for (int i = 1; i < input.length; i++) {
            int key = input[i];
            int j = i - 1;
            while (j >= 0 && input[j] < key) {
                input[j + 1] = input[j];
                j = j - 1;
            }
            input[j + 1] = key;
        }
        return input;
    }


    public static void main(String[] args) throws IOException {
        org.openjdk.jmh.Main.main(args);
    }
}

直接运行main方法就可以运行JMH测试。

还有一种可以传入各种构建参数的API:

Options options = new OptionsBuilder()
                .jvmArgs("-Xmx64m")
                .shouldFailOnError(true)
                .build();

new Runner(options).run();

配置JMH

JMH还有很多配置,可以根据需要进行配置。

BenchmarkMode

JMH可以用不同的模式运行,不同模式测试不同的指标,具体模式如下:

  • Throughput :测试方法的吞吐量
  • AverageTime:测试方法的平均执行时间
  • SampleTime:测试方法的最长时间、最短时间等
  • SingleShotTime:测试方法单次执行的时间(没有预热的情况下的表现,通常用于测试方法冷启动的性能)
  • All : 测试以上所有情况

默认模式是Throughput。使用示例片段如下:

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Mode;

...

@Benchmark
@BenchmarkMode(Mode.Throughput)
public int[] testMethod() {
    int[] input = IntStream.range(1, 10000).toArray();
    int inputLength = input.length;
    int temp;
    boolean is_sorted;

    for (int i = 0; i < inputLength; i++) {
        is_sorted = true;
        for (int j = 1; j < (inputLength - i); j++) {
            if (input[j - 1] < input[j]) {
                temp = input[j - 1];
                input[j - 1] = input[j];
                input[j] = temp;
                is_sorted = false;
            }
        }
        if (is_sorted) break;
    }
    return input;
}

State

State用于初始化一些参数。如果生成数据的代码不需要加入基准测试的执行时间,可以用State。State需要用Class定义,使用的时候作为方法参数传入测试的方法。示例如下:

public class JmhDemo {

    @State(Scope.Benchmark)
    public static class InputState {
        int[] input = IntStream.range(1, 10000).toArray();
    }

    @Benchmark
    @BenchmarkMode(Mode.Throughput)
    public int[] testMethod(InputState inputState) {
        int[] input = inputState.input;
        int inputLength = input.length;
        int temp;
        boolean is_sorted;

        for (int i = 0; i < inputLength; i++) {
            is_sorted = true;
            for (int j = 1; j < (inputLength - i); j++) {
                if (input[j - 1] < input[j]) {
                    temp = input[j - 1];
                    input[j - 1] = input[j];
                    input[j] = temp;
                    is_sorted = false;
                }
            }
            if (is_sorted) break;
        }
        return input;
    }

    @Benchmark
    @BenchmarkMode(Mode.Throughput)
    public int[] testMethod2(InputState inputState) {
        int[] input = inputState.input;
        for (int i = 1; i < input.length; i++) {
            int key = input[i];
            int j = i - 1;
            while (j >= 0 && input[j] < key) {
                input[j + 1] = input[j];
                j = j - 1;
            }
            input[j + 1] = key;
        }
        return input;
    }


    public static void main(String[] args) throws IOException {
        org.openjdk.jmh.Main.main(args);
    }
}

在这个示例中,类InputState作为参数传入Benchmark方法。

State Scope

@State注解接收一个Scope参数。State对象可以被多次初始化,Scope定义了具体的初始化行为,具体值如下:

  • Thread:每个线程创建自己的State对象
  • Group:一组线程创建一个State对象
  • Benchmark:所有在线程在一个Benchmark中,共享同一个State对象

State类

被@State注解的类需要遵守以下几条规则:

  • 类必须被public修饰
  • 如果是嵌套类,必须被static修饰
  • 类必须要有无参构造器

如果没有遵守以上规则,编译代码时会报错。

State类的@Setup和@TearDown

@Setup表示在传入Benchmark方法前,调用被注解的方法。@TearDown表示在执行完Benchmark方法后,调用被注解的方法。被@Setup和@TearDown注解的方法,其执行时间不会被算在Benchmark方法中。使用示例如下:

@State(Scope.Benchmark)
public static class InputState {

    @Setup(Level.Trial)
    public void setup(){
        System.out.println("setup");
    }

    int[] input = IntStream.range(1, 10000).toArray();

    @TearDown(Level.Trial)
    public void tearDown(){
        System.out.println("tearDown");
    }
}

这两个注解可以传入一个Level的参数,这个参数有3个值:

  • Level.Trial:在预热和循环执行Benchmark方法时调用一次
  • Level.Iteration:每次开始循环执行Benchmark方法时调用一次
  • Level.Invocation:每次调用Benchmark方法时调用一次

Benchmark TimeUnit

在JMH中,使用@OutputTimeUnit注解可以指定输出结果时间显示的单位,它接收java.util.concurrent.TimeUnit作为参数。可以注解在类或具体方法上,示例如下:

@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class JmhDemo {
    ...
}

JMH基准测试代码注意事项

虽然JMH会对基准测试的代码做大部分工作,但想要写一个好的JMH基准测试代码,还是需要注意一些JVM的优化,不然测试结果很可能没有参考意义。

无用代码(Dead Code)优化

JVM如果发现计算的结果没有被使用,那么JVM就会认为这是一段无用代码并优化掉,导致基准测试结果没有参考意义。示例如下:

@Benchmark
public void testMethod3() {
    int a = 1 ;
    int b = 2 ;
    int sum = a + b;
}

在这个示例中,结果sum没有被其他地方使用,JVM会发现这个情况,然后优化掉这段代码,导致实际上被测试的方法里没有代码被执行。

下面介绍避免无用代码优化的办法。

把结果作为Benchmark方法返回值

可以把上面的testMethod3改为下面的形式:

@Benchmark
public int testMethod3() {
    int a = 1 ;
    int b = 2 ;
    int sum = a + b;
    return sum;
}

现在sum作为返回值返回,因为这个值在调用方可能会用到,所以JVM就不会优化这段代码了。

把结果传入Blackhole中

也可以把结果放入JMH提供的Blackhole对象中,示例如下:

@Benchmark
public void testMethod3(Blackhole blackhole) {
    int a = 1 ;
    int b = 2 ;
    int sum = a + b;
    blackhole.consume(sum);
}

现在sum作为参数传入Blackhole对象中,因为这个值可能在Blackhole的consume方法中使用,所以JVM也不会优化这段代码。

常量折叠(Constant Folding)优化

如果一个方法是基于常量的计算,并且结果总是同一个值,那么JVM发现之后,会在之后的调用中,直接把结果作为返回值返回,这就是常量折叠优化。拿上节的testMethod3作为示例:

@Benchmark
public int testMethod3() {
    int a = 1 ;
    int b = 2 ;
    int sum = a + b;
    return sum;
}

在JVM发现sum的计算是基于常量,并且结果也总是同一个值时,该方法有可能会被优化成如下代码:

@Benchmark
public int testMethod3() {
    return 3;
}

甚至有可能在调用的地方直接把结果3内联起来。

为了避免JVM的常量折叠优化,可以把方法中的常量抽取成从其他对象(如:State对象)中获取。示例如下:

@State(Scope.Benchmark)
public static class InputState2 {
    int a = 1;
    int b = 2;
}

@Benchmark
public void testMethod3(InputState2 inputState2,Blackhole blackhole) {
    int sum = inputState2.a + inputState2.b;
    blackhole.consume(sum);
}

循环优化

JVM会对循环有一定的优化,所以最好不要在被基准测试的方法外再套一层循环,避免对测试结果的准确性有负面影响。

总结

本文主要总结了JMH的使用方法和注意事项,示例工程在Github上。

参考资料

[1]JMH的Github仓库:https://github.com/openjdk/jmh

[2]https://www.oracle.com/technical-resources/articles/java/architect-benchmarking.html

[3]http://tutorials.jenkov.com/java-performance/jmh.html

[4]Spring Framework的JMH实践策略:https://github.com/spring-projects/spring-framework/wiki/Micro-Benchmarks

评论

此博客中的热门博文

国密SM2签名封装成PKCS7格式

在国内做金融行业,难免会有被强制使用国密算法的情况,而且一般还会指定必须使用硬件加密机之类的设备,所以我也稍微的研究了一下国密算法,使用软算法签名并封装 PKCS7 格式(文档中的一个交互)。 以下是基于 Bouncy Castle 的示例,密钥对的生成可以参考 Bouncy Castle 中 test 包下 SM2 相关代码 public static String sign ( ) throws Exception { //加载公钥 byte [ ] plainText = "hello, world" . getBytes ( ) ; FileInputStream input = new FileInputStream ( "F:\\certificate\\public.cer" ) ; CertificateFactory certificateFactory = new CertificateFactory ( ) ; X509Certificate certificate = ( X509Certificate ) certificateFactory . engineGenerateCertificate ( input ) ; input . close ( ) ; //加载私钥,private为换成实际的私钥 PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec ( "private" . getBytes ( ) ) ; //SM2算法实际上为ECC算法,并指定了一些参数值,所以这里的参数是EC KeyFactory factory = KeyFactory . getInstance ( "EC" , "BC" ) ; PrivateKey privateKey = factory . generatePrivate ( spec ) ; //以下为签名并封装成PKCS7格式 byte [ ] signedMessag

Spring Boot Actuator 2 示例

Welcome file 简介 Spring Boot Actuator为应用程序提供了各种开箱即用的运维特性,可以与应用方便的交互和监控。 使用环境:Java 11 和 Spring Boot 2.4.3.RELEASE 集成Spring Boot Actuator 在Spring Boot中集成Spring Boot Actuator与集成其他的框架类似,在 pom.xml 里引入相关的starter就可以: < dependency > < groupId > org.springframework.boot </ groupId > < artifactId > spring-boot-starter-actuator </ artifactId > </ dependency > < dependency > < groupId > org.springframework.boot </ groupId > < artifactId > spring-boot-starter-web </ artifactId > </ dependency > 由于大部分的使用场景还是web,所以这里也用Spring MVC做示例。 配置好 pom.xml 后,默认actuator仅暴露一些基本功能,实际使用中,根据需求暴露对应功能。为了简便测试,这里在 application.yml 中配置暴露全部功能: management : endpoints : web : exposure : include : "*" endpoint : health : enabled : true show-details : always probes : enabled : true shutdown : enabled : true metr

NextCloud数据目录迁移

最近服务器的环境坏了,所以迁移了NextCloud的数据目录。不过在迁移过程中有点小问题。 环境: Ubuntu 18.04 Docker 19.03.7 1.NextCloud页面不正常,Docker日志显示XX目录permission denied 参考了 这里 的做法,不过是把  /var/www/html/   整个目录的权限都修改为  chown -R www-data:www-data ,之后就不再报权限问题了。 2.数据库配置修改 因为NextCloud是在初始化时填的数据库连接信息,所以直接迁移数据目录的情况下,会导致应用连不到新的数据库环境。此时可以找到数据目录下的  config/config.php 文件,直接修改数据库连接配置。