为什么需要Benchmark工具
如果想要知道一段代码的性能如何,一种常用的做法可能是这样的:
long start = System.currentTimeMillis();
// do something ...
System.out.println(System.currentTimeMillis() - start);
这样做,存在几个问题:
- 结果不够精确
 首先,System.currentTimeMillis() 的注释里明确表示了,根据操作系统的不同,会存在数十毫秒的误差。虽然这个问题比较容易解决,但是造成测试结果不精确的主要原因并不是时间函数的误差,而是JVM和JIT在运行时会对Java应用进行大量的优化,比如某个计算的结果并没有被使用,那么这段代码在执行时就会被忽略,这样的问题比较难察觉。
- 统计结果有限
 如果需要打印多种类型的测试数据,就需要增加很多额外的代码。
- 配置不灵活
 不容易修改测试的类型和条件。
因此,使用一款靠谱的benchmark工具,既可以减少工作量,又可以确保性能优化过程不被错误的测试数据误导。
之前使用Golang开发的时候,SDK自带的benchmark就非常好用。转到Java栈之后,我也想找一款好用的benchmark工具,后来通过《Effective Java》了解到了JMH。
JMH概述
JMH 即Java Microbenchmark Harness,是用于代码微基准测试的工具套件,由JIT的开发人员编写,他们应该比任何人都了解JIT对于测试的影响。
JMH可以精确测量方法在不同输入参数情况下的执行时间和吞吐量。
一个例子
这里使用Gradle来搭建测试环境,首先在build.gradle中添加依赖:
    testCompile 'org.openjdk.jmh:jmh-core:jar:1.21'
    testCompile 'org.openjdk.jmh:jmh-generator-annprocess:1.21'
编写一个简单的类,测试两种字符串连接操作的性能:
package com.dafengge0913.benchmark;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
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, Mode.Throughput})
@Warmup(iterations = 1)
@Measurement(iterations = 2, time = 1)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Fork(value = 2)
@Threads(8)
@State(Scope.Benchmark)
@OperationsPerInvocation
public class StringBenchmark {
    @Param({"1", "10", "100"})
    private int n;
    @Setup
    public void setup() {
    }
    @TearDown
    public void tearDown() {
    }
    @Benchmark
    public void testStringAdd(Blackhole blackhole) {
        String s = "";
        for (int i = 0; i < n; i++) {
            s += i;
        }
        blackhole.consume(s);
    }
    @Benchmark
    public void testStringBuilderAdd(Blackhole blackhole) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) {
            sb.append(i);
        }
        blackhole.consume(sb.toString());
    }
    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(StringBenchmark.class.getSimpleName())
                .build();
        new Runner(opt).run();
    }
}
测试结果,这里截取了部分输出:
testStringBuilderAdd 测试的某组执行结果:
Result "com.dafengge0913.benchmark.StringBenchmark.testStringBuilderAdd":
  1.317 ±(99.9%) 0.306 us/op [Average]
  (min, avg, max) = (1.259, 1.317, 1.374), stdev = 0.047
  CI (99.9%): [1.011, 1.622] (assumes normal distribution)
每次操作平均耗时1.317 ± 0.306微秒
最小值:1.259
平均值:1.317
最大值:1.374
标准差:0.047
平均值的信赖区间:[1.011, 1.622]
最后的统计结果:
Benchmark                             (n)   Mode  Cnt    Score    Error   Units
StringBenchmark.testStringAdd           1  thrpt    4  260.743 ± 18.891  ops/us
StringBenchmark.testStringAdd          10  thrpt    4   23.293 ±  0.865  ops/us
StringBenchmark.testStringAdd         100  thrpt    4    0.565 ±  0.008  ops/us
StringBenchmark.testStringBuilderAdd    1  thrpt    4  130.606 ±  6.957  ops/us
StringBenchmark.testStringBuilderAdd   10  thrpt    4   74.840 ± 17.819  ops/us
StringBenchmark.testStringBuilderAdd  100  thrpt    4    6.012 ±  0.520  ops/us
StringBenchmark.testStringAdd           1   avgt    4    0.032 ±  0.004   us/op
StringBenchmark.testStringAdd          10   avgt    4    0.355 ±  0.025   us/op
StringBenchmark.testStringAdd         100   avgt    4   14.478 ±  1.119   us/op
StringBenchmark.testStringBuilderAdd    1   avgt    4    0.062 ±  0.005   us/op
StringBenchmark.testStringBuilderAdd   10   avgt    4    0.109 ±  0.038   us/op
StringBenchmark.testStringBuilderAdd  100   avgt    4    1.317 ±  0.306   us/op
(n):参数n的取值
Mode: thrpt代表吞吐量,avgt代表平均运行时间。 对应org.openjdk.jmh.annotations.Mode中的shortLabel
Cnt:iteration组数
Score:对应Units的值
Units:统计的单位
Error:误差
结果表明,在字符较多的情况下,StringBuilder的性能更好,完全符合预期。
概念
Iteration
iteration是JMH进行测试的最小单位,包含一组invocations。
Invocation
一次benchmark方法调用。
Operation
benchmark方法中,被测量操作的执行。如果被测试的操作在benchmark方法中循环执行,可以使用@OperationsPerInvocation表明循环次数,使测试结果为单次operation的性能。
Warmup
在实际进行benchmark前先进行预热。因为某个函数被调用多次之后,JIT会对其进行编译,通过预热可以使测量结果更加接近真实情况。
注解
@Benchmark
表示该方法需要被测量。
@BenchmarkMode
JMH进行benchmark时使用的模式。目前1.21版本共包含四种模式:
- Throughput: 每个单位时间执行的操作数
- AverageTime: 每次执行消耗的平均时间
- SampleTime: 每次执行时间,随机采样
- SingleShotTime: 仅运行一次,用于测试冷启动时的性能
几种模式可以相互组合,也可以设置为Mode.All来执行所有的模式。
@Warmup
实际进行benchmark前,预热的iteration配置。
@Measurement
实践测量的iteration配置。
@Warmup和@Measurement的配置项相同:
- iterations:iteration轮数
- time:每次iteration的时间
- timeUnit:iteration时间的单位,默认为秒
- batchSize:每个operation中调用方法的次数
@OutputTimeUnit
显示结果时使用的时间单位。
@Fork
用于配置JMH运行时fork的Java进程。使用单独的进程可以避免测试结果之间互相影响。
- value: fork的进程数量
- warmups: 每个进程执行Warmup的轮数
- jvm:进程使用的JVM
- jvm参数通过以下三个属性,按照从上到下的顺序拼接:
- jvmArgsPrepend
- jvmArgs
- jvmArgsAppend
 
默认fork的进程数配置在org.openjdk.jmh.runner.Defaults类中:
    /**
     * Number of forks in which we measure the workload.
     */
    public static final int MEASUREMENT_FORKS = 5;
@Threads
测试使用的线程数,默认为 Runtime.getRuntime().availableProcessors()
@State
类实例的生命周期。
- Benchmark: 所有测试线程共享一个实例。
- Group: 每个线程组内部使用一个实例。
- Thread: 每个线程独占一个实例。
@Param
在不同参数的情况下,分别测试。
@Setup
在执行benchmark之前执行,用于初始化。
@TearDown
所有benchmark结束后执行,用于资源关闭。
Setup和TearDown的调用时机
根据Level配置
- Level.Trial: 每组iteration执行
- Level.Iteration: 每组invocation执行
- Level.Invocation: 每次invocation 即benchmark方法被调用时
Blackhole
为了避免JIT忽略未被使用的结果计算,可以使用Blackhole.consume()来保证方法被正确执行。
将结果输出到日志文件中
可以通过在OptionsBuilder中指定output属性,把测试结果输出到日志文件中。
public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
            .include(StringBenchmark.class.getSimpleName())
            .output("D:/string_benchmark.log")
            .build();
    new Runner(opt).run();
}
配置的优先级
OptionsBuilder > 方法注解 > 类注解