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