为什么需要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 > 方法注解 > 类注解