蹲厕所的熊

benjaminwhx

使用JMH做基准测试

2018-06-15 作者: 吴海旭


  1. 1、简介
  2. 2、第一个例子
  3. 3、生成jar包执行
  4. 4、IDEA插件
  5. 5、注解分析
    1. @BenchmarkMode
    2. @Warmup
    3. @Measurement
    4. @Threads
    5. @Fork
    6. @Benchmark
    7. @Param
    8. @Setup&@TearDown
    9. @State
    10. @Group
    11. @GroupThreads
    12. @OutputTimeUnit
    13. @CompilerControl
  6. 6、避免JIT优化
  7. 7、实战

1、简介

在使用Java编程过程中,我们对于一些代码调用的细节有多种编写方式,但是不确定它们性能时,往往采用重复多次计数的方式来解决。但是随着JVM不断的进化,随着代码执行次数的增加,JVM会不断的进行编译优化,使得重复多少次才能够得到一个稳定的测试结果变得让人疑惑,这时候有经验的同学就会在测试执行前先循环上万次并注释为预热。

没错!这样做确实可以获得一个偏向正确的测试结果,但是我们试想如果每到需要斟酌性能的时候,都要根据场景写一段预热的逻辑吗?当预热完成后,需要多少次迭代来进行正式内容的测量呢?每次测试结果的输出报告是不是都需要用System.out来输出呢?

其实这些工作都可以交给 JMH (the Java Microbenchmark Harness) ,它被作为Java9的一部分来发布,但是我们完全不需要等待Java9,而可以方便的使用它来简化我们测试,它能够照看好JVM的预热、代码优化,让你的测试过程变得更加简单。

2、第一个例子

首先,导入JMH需要的jar包:

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

接着,我们来试着写一个例子。就拿最近占小狼发的Calendar和Joda比较的例子来说吧:

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
public class DateBenchMark {

    @Benchmark
    public Calendar runCalendar() {
        return Calendar.getInstance();
    }

    @Benchmark
    public DateTime runJoda() {
        return new DateTime();
    }

    @Benchmark
    public long runSystem() {
        return System.currentTimeMillis();
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(DateBenchMark.class.getSimpleName())
                .build();

        new Runner(opt).run();
    }
}

运行结果里确实可以看到Joda的DateTime比Calendar性能要好:

Benchmark                  Mode  Cnt    Score     Error  Units
DateBenchMark.runCalendar  avgt    3  207.051 ± 358.224  ns/op
DateBenchMark.runJoda      avgt    3   58.860 ±  15.683  ns/op
DateBenchMark.runSystem    avgt    3   33.148 ±   6.270  ns/op

这个例子里的注解都可以换成方法的方式在main方法中指定,比如可以改成这样:

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class DateBenchMark {

    @Benchmark
    public Calendar runCalendar() {
        return Calendar.getInstance();
    }

    @Benchmark
    public DateTime runJoda() {
        return new DateTime();
    }

    @Benchmark
    public long runSystem() {
        return System.currentTimeMillis();
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(DateBenchMark.class.getSimpleName())
                .forks(1)
                .measurementIterations(3)
                .measurementTime(TimeValue.seconds(1))
                .warmupIterations(3)
                .warmupTime(TimeValue.seconds(1))
                .build();

        new Runner(opt).run();
    }
}

具体的方法可以自行参考 ChainedOptionsBuilder 类,因所有的方法几乎都可以用注解来实现,所以我们下面着重讲解注解的使用。

3、生成jar包执行

对于一些我们自己的一些小测试,直接用上面的方式写一个main函数手动执行就好了。但是对于大型的测试,需要测试的时间比较久、线程数比较多,加上测试的服务器需要,一般要放在Linux服务器里去执行。JMH官方提供了生成jar包的方式来执行。

首先我们需要在maven里增加一个plugin:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>2.0</version>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>shade</goal>
            </goals>
            <configuration>
                <finalName>benchmarks</finalName>
                <transformers>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                        <mainClass>org.openjdk.jmh.Main</mainClass>
                    </transformer>
                </transformers>
            </configuration>
        </execution>
    </executions>
</plugin>

接着执行maven的命令生成可执行jar包并执行。

$ mvn clean install
$ java -jar target/benchmarks.jar DateBenchMark

jar的执行命令后面可以加上 -h 来提示可选的命令行参数,用来替换main方法中的方法。

4、IDEA插件

如果你在用Intellij IDEA的话,那么你可以去plugin里搜JMH来安装,github地址:https://github.com/artyushov/idea-jmh-plugin

它的主要功能有两个:

一、帮助你创建@Benchmark方法,可以右键点击 Generate... 来触发,也可以使用快捷键 ctrl+N

jmh1

二、可以让你像Junit一样方便的来进行基准测试,不需要写main方法。点击某个@Benchmark方法名右键run就只会进行光标所在方法的基准测试,而如果光标在类名上,右键run的就是整个类的所有基准测试。

jmh2

jmh3

5、注解分析

下面我把一些常用的注解全部分析一遍,看完之后你就可以得心应手的使用了。

@BenchmarkMode

基准测试类型,对应Mode选项,可用于类或者方法上。 需要注意的是,这个注解的value是一个数组,可以把几种Mode集合在一起执行,如:@BenchmarkMode({Mode.SampleTime, Mode.AverageTime})

  • Throughput:整体吞吐量,每秒执行了多少次调用。
  • AverageTime:用的平均时间,每次操作的平均时间。
  • SampleTime:随机取样,最后输出取样结果的分布,例如“99%的调用在xxx毫秒以内,99.99%的调用在xxx毫秒以内”。
  • SingleShotTime:上模式都是默认一次 iteration 是 1s,唯有 SingleShotTime 是只运行一次。往往同时把 warmup 次数设为0,用于测试冷启动时的性能。
  • All:上面的所有模式都执行一次,适用于内部JMH测试。

@Warmup

预热所需要配置的一些基本测试参数。可用于类或者方法上。一般我们前几次进行程序测试的时候都会比较慢,所以要让程序进行几轮预热,保证测试的准确性。为什么需要预热?因为 JVM 的 JIT 机制的存在,如果某个函数被调用多次之后,JVM 会尝试将其编译成为机器码从而提高执行速度。所以为了让 benchmark 的结果更加接近真实情况就需要进行预热。

  • iterations:预热的次数。
  • time:每次预热的时间。
  • timeUnit:时间的单位,默认秒。
  • batchSize:批处理大小,每次操作调用几次方法。

@Measurement

实际调用方法所需要配置的一些基本测试参数。可用于类或者方法上。参数和@Warmup一样。

@Threads

每个进程中的测试线程,可用于类或者方法上。一般选择为cpu乘以2。如果配置了 Threads.MAX ,代表使用 Runtime.getRuntime().availableProcessors() 个线程。

@Fork

进行 fork 的次数。可用于类或者方法上。如果 fork 数是2的话,则 JMH 会 fork 出两个进程来进行测试。

@Benchmark

方法级注解,表示该方法是需要进行 benchmark 的对象,用法和 JUnit 的 @Test 类似。

@Param

@Param 可以用来指定某项参数的多种情况。只能作用在字段上。特别适合用来测试一个函数在不同的参数输入的情况下的性能。使用该注解必须定义 @State 注解。

@Param(value = {"a", "b", "c"})
private String param;

最后的结果可能是这个样子的:

Benchmark                    (param)  Mode  Cnt    Score   Error  Units
FirstBenchMark.stringConcat        a    ss       330.752          us/op
FirstBenchMark.stringConcat        b    ss       186.050          us/op
FirstBenchMark.stringConcat        c    ss       222.559          us/op

@Setup&@TearDown

@Setup主要实现测试前的初始化工作,只能作用在方法上。用法和Junit一样。使用该注解必须定义 @State 注解。

@TearDown主要实现测试完成后的垃圾回收等工作,只能作用在方法上。用法和Junit一样。使用该注解必须定义 @State 注解。

这两个注解都有一个 Level 的枚举value,它有三个值(默认的是Trial):

  • Trial:在每次Benchmark的之前/之后执行。
  • Iteration:在每次Benchmark的iteration的之前/之后执行。
  • Invocation:每次调用Benchmark标记的方法之前/之后都会执行。

可见,Level的粒度从Trial到Invocation越来越细。

@TearDown(Level.Iteration)
public void check() {
    assert x > Math.PI : "Nothing changed?";
}

@Benchmark
public void measureRight() {
    x++;
}

@Benchmark
public void measureWrong() {
    double x = 0;
    x++;
}

@State

该注解定义了给定类实例的可用范围。JMH可以在多线程同时运行的环境测试,因此需要选择正确的状态。只能作用在上。被该注解定义的类通常作为 @Benchmark 标记的方法的入参,JMH根据scope来进行实例化和共享操作,当然@State可以被继承使用,如果父类定义了该注解,子类则无需定义。

Scope有如下3种值:

  • Benchmark:同一个benchmark在多个线程之间共享实例。
  • Group:同一个线程在同一个group里共享实例。group定义参考注解 @Group
  • Thread:不同线程之间的实例不共享。

首先说一下Benchmark,对于同一个@Benchmark,所有线程共享实例,也就是只会new Person 1次

@State(Scope.Benchmark)
public static class BenchmarkState {
    Person person = new Person(21, "ben", "benchmark");
    volatile double x = Math.PI;
}

@Benchmark
public void measureShared(BenchmarkState state) {
    state.x++;
}

public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
            .include(JMHSample_03_States.class.getSimpleName())
            .threads(8)
            .warmupTime(TimeValue.seconds(1))
            .measurementTime(TimeValue.seconds(1))
            .forks(1)
            .build();

    new Runner(opt).run();
}

再说一下thread,这个比较好理解,不同线程之间的实例不共享。对于上面我们设定的线程数为8个,也就是会new Person 8次。

@State(Scope.Thread)
public static class ThreadState {
    Person person = new Person(21, "ben", "thread");
    volatile double x = Math.PI;
}

@Benchmark
public void measureUnshared(ThreadState state) {
    state.x++;
}

而对于Group来说,同一个group的作为一个执行单元,所以 measureGroupmeasureGroup2 共享8个线程,所以一个方法也就会执行new Person 4次。

@State(Scope.Group)
public static class GroupState {
    Person person = new Person(21, "ben", "group");
    volatile double x = Math.PI;
}

@Benchmark
@Group("ben")
public void measureGroup(GroupState state) {
    state.x++;
}

@Benchmark
@Group("ben")
public void measureGroup2(GroupState state) {
    state.x++;
}

@Group

结合@Benchmark一起使用,把多个基准方法归为一类,只能作用在方法上。同一个组中的所有测试设置相同的名称(否则这些测试将独立运行——没有任何警告提示!)

@GroupThreads

定义了多少个线程参与在组中运行基准方法。只能作用在方法上。

@OutputTimeUnit

这个比较简单了,基准测试结果的时间类型。可用于类或者方法上。一般选择秒、毫秒、微秒。

@CompilerControl

该注解可以控制方法编译的行为,可用于类或者方法或者构造函数上。它内部有6种模式,这里我们只关心三种重要的模式:

  • CompilerControl.Mode.INLINE:强制使用内联。
  • CompilerControl.Mode.DONT_INLINE:禁止使用内联。
  • CompilerControl.Mode.EXCLUDE:禁止编译方法。
public void target_blank() {
}

@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public void target_dontInline() {
}

@CompilerControl(CompilerControl.Mode.INLINE)
public void target_inline() {
}

@CompilerControl(CompilerControl.Mode.EXCLUDE)
public void target_exclude() {
}

@Benchmark
public void baseline() {
}

@Benchmark
public void blank() {
    target_blank();
}

@Benchmark
public void dontinline() {
    target_dontInline();
}

@Benchmark
public void inline() {
    target_inline();
}

@Benchmark
public void exclude() {
    target_exclude();
}

最后得出的结果也表名,使用内联优化会影响实际的结果:

Benchmark                                Mode  Cnt   Score   Error  Units
JMHSample_16_CompilerControl.baseline    avgt    3   0.338 ± 0.475  ns/op
JMHSample_16_CompilerControl.blank       avgt    3   0.343 ± 0.213  ns/op
JMHSample_16_CompilerControl.dontinline  avgt    3   2.247 ± 0.421  ns/op
JMHSample_16_CompilerControl.exclude     avgt    3  82.814 ± 7.333  ns/op
JMHSample_16_CompilerControl.inline      avgt    3   0.322 ± 0.023  ns/op

6、避免JIT优化

我们在测试的时候,一定要避免JIT优化。对于有一些代码,编译器可以推导出一些计算是多余的,并且完全消除它们。 如果我们的基准测试里有部分代码被清除了,那测试的结果就不准确了。比如下面这一段代码:

private double x = Math.PI;

@Benchmark
public void baseline() {
    // do nothing, this is a baseline
}

@Benchmark
public void measureWrong() {
    // This is wrong: result is not used and the entire computation is optimized away.
    Math.log(x);
}

@Benchmark
public double measureRight() {
    // This is correct: the result is being used.
    return Math.log(x);
}

由于 measureWrong 方法被编译器优化了,导致效果和 baseline 方法一样变成了空方法,结果也证实了这一点:

Benchmark                           Mode  Cnt   Score   Error  Units
JMHSample_08_DeadCode.baseline      avgt    5   0.311 ± 0.018  ns/op
JMHSample_08_DeadCode.measureRight  avgt    5  23.702 ± 0.320  ns/op
JMHSample_08_DeadCode.measureWrong  avgt    5   0.306 ± 0.003  ns/op

如果我们想方法返回值还是void,但是需要让Math.log(x)的耗时加入到基准运算中,我们可以使用JMH提供给我们的类 Blackhole ,使用它的 consume 来避免JIT的优化消除。

@Benchmark
public void measureRight_2(Blackhole bh) {
    bh.consume(Math.log(x));
}

但是有返回值的方法就不会被优化了吗?你想的太多了。。。重新改改刚才的代码,让字段 x 变成final的。

private final double x = Math.PI;

运行后的结果发现 measureRight 被JIT进行了优化,从 23.7ns/op 降到了 2.5ns/op

JMHSample_08_DeadCode.measureRight    avgt    5  2.587 ± 0.081  ns/op

当然 Math.log(Math.PI ); 这种返回写法和字段定义成final一样,都会被进行优化。

优化的原因是因为JVM认为每次计算的结果都是相同的,于是就会把相同代码移到了JMH的循环之外。

结论:

  1. 基准测试方法一定不要返回void。
  2. 如果要使用void返回,可以使用 Blackholeconsume 来避免JIT的优化消除。
  3. 计算不要引用常量,否则会被优化到JMH的循环之外。

7、实战

最后我们来使用JMH测试不同框架的序列化性能,代码地址:https://github.com/benjaminwhx/p_rpc/blob/master/serialize/src/main/java/com/github/BenchmarkTest.java

测试结果图:

serialize1

测试的主要参数如下:

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Fork(3)
@Threads(8)

3个进程,8个线程,每次预热3次,每次5秒。执行5次,每次5秒,最后算出的值为平均时间,单位纳秒。



坚持原创技术分享,您的支持将鼓励我继续创作!



分享

评论