Java Microbenchmark Harness-Java快速入门教程
1. 简介
这篇快速文章重点介绍 JMH(Java Microbenchmark Harness)。首先,我们熟悉 API 并了解其基础知识。然后,我们将看到在编写微基准测试时应该考虑的一些最佳实践。
简而言之,JMH 负责 JVM 预热和代码优化路径等工作,使基准测试尽可能简单。
2. 入门
首先,我们实际上可以继续使用 Java 8 并简单地定义依赖项:
<dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-core</artifactId> <version>1.35</version> </dependency> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-generator-annprocess</artifactId> <version>1.35</version> </dependency>
最新版本的JMH Core和JMH Annotation Processor可以在Maven Central中找到。
接下来,通过使用@Benchmark注释(在任何公共类中)创建一个简单的基准:
@Benchmark public void init() { // Do nothing }
然后我们添加启动基准测试过程的主类:
public class BenchmarkRunner { public static void main(String[] args) throws Exception { org.openjdk.jmh.Main.main(args); } }
现在运行 BenchmarkRunner 将执行我们可能有点无用的基准测试。运行完成后,将显示一个汇总表:
# Run complete. Total time: 00:06:45
Benchmark Mode Cnt Score Error Units
BenchMark.init thrpt 200 3099210741.962 ± 17510507.589 ops/s
3. 基准的类型
JMH 支持一些可能的基准测试:吞吐量、平均时间、采样时间和单次拍摄时间。这些可以通过@BenchmarkMode注释进行配置:
@Benchmark @BenchmarkMode(Mode.AverageTime) public void init() { // Do nothing }
生成的表将具有平均时间指标(而不是吞吐量):
# Run complete. Total time: 00:00:40
Benchmark Mode Cnt Score Error Units
BenchMark.init avgt 20 ≈ 10⁻⁹ s/op
4. 配置预热和执行
通过使用 @Fork 注释,我们可以设置基准测试执行的方式:value 参数控制基准测试将执行多少次,预热参数控制基准测试在收集结果之前将试运行多少次,例如:
@Benchmark @Fork(value = 1, warmups = 2) @BenchmarkMode(Mode.Throughput) public void init() { // Do nothing }
这指示JMH运行两个预热叉并丢弃结果,然后再进行实时定时基准测试。
此外,@Warmup注释可用于控制预热迭代次数。例如,@Warmup(迭代 = 5) 告诉 JMH 五次预热迭代就足够了,而不是默认的 20 次。
5. 状态
现在让我们研究一下如何利用 State 来执行对哈希算法进行基准测试的不那么琐碎且更具指示性的任务。假设我们决定通过对密码进行几百次哈希处理来添加额外的保护,以防止字典对密码数据库的攻击。
我们可以通过使用 State 对象来探索性能影响:
@State(Scope.Benchmark) public class ExecutionPlan { @Param({ "100", "200", "300", "500", "1000" }) public int iterations; public Hasher murmur3; public String password = "4v3rys3kur3p455w0rd"; @Setup(Level.Invocation) public void setUp() { murmur3 = Hashing.murmur3_128().newHasher(); } }
然后,我们的基准测试方法将如下所示:
@Fork(value = 1, warmups = 1) @Benchmark @BenchmarkMode(Mode.Throughput) public void benchMurmur3_128(ExecutionPlan plan) { for (int i = plan.iterations; i > 0; i--) { plan.murmur3.putString(plan.password, Charset.defaultCharset()); } plan.murmur3.hash(); }
在这里,当 JMH 将字段注释传递给基准方法时,将使用 JMH 的@Param注释中的适当值填充字段迭代。@Setup注释方法在每次调用基准测试之前被调用,并创建一个新的哈希器来确保隔离。
执行完成后,我们将得到类似于下面的结果:
# Run complete. Total time: 00:06:47 Benchmark (iterations) Mode Cnt Score Error Units BenchMark.benchMurmur3_128 100 thrpt 20 92463.622 ± 1672.227 ops/s BenchMark.benchMurmur3_128 200 thrpt 20 39737.532 ± 5294.200 ops/s BenchMark.benchMurmur3_128 300 thrpt 20 30381.144 ± 614.500 ops/s BenchMark.benchMurmur3_128 500 thrpt 20 18315.211 ± 222.534 ops/s BenchMark.benchMurmur3_128 1000 thrpt 20 8960.008 ± 658.524 ops/s
6. 死代码消除
运行微基准测试时,了解优化非常重要。否则,它们可能会以非常误导的方式影响基准测试结果。
为了使事情更具体一些,让我们考虑一个例子:
@Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public void doNothing() { } @Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public void objectCreation() { new Object(); }
我们期望对象分配的成本高于什么都不做。但是,如果我们运行基准测试:
Benchmark Mode Cnt Score Error Units BenchMark.doNothing avgt 40 0.609 ± 0.006 ns/op BenchMark.objectCreation avgt 40 0.613 ± 0.007 ns/op
显然,在 TLAB 中找到一个位置,创建和初始化一个对象几乎是免费的!仅通过查看这些数字,我们应该知道这里有些东西并没有完全加起来。
在这里,我们是死代码消除的受害者。编译器非常擅长优化冗余代码。事实上,这正是 JIT 编译器在这里所做的。
为了防止这种优化,我们应该以某种方式欺骗编译器并使其认为代码被其他组件使用。 实现此目的的一种方法是返回创建的对象:
@Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public Object pillarsOfCreation() { return new Object(); }
此外,我们可以让黑洞消耗它:
@Benchmark @OutputTimeUnit(TimeUnit.NANOSECONDS) @BenchmarkMode(Mode.AverageTime) public void blackHole(Blackhole blackhole) { blackhole.consume(new Object()); }
让黑洞使用对象是说服 JIT 编译器不应用死代码消除优化的一种方法。无论如何,如果我们再次运行这些基准测试,这些数字将更有意义:
Benchmark Mode Cnt Score Error Units BenchMark.blackHole avgt 20 4.126 ± 0.173 ns/op BenchMark.doNothing avgt 20 0.639 ± 0.012 ns/op BenchMark.objectCreation avgt 20 0.635 ± 0.011 ns/op BenchMark.pillarsOfCreation avgt 20 4.061 ± 0.037 ns/op
7. 恒定折叠
让我们考虑另一个例子:
@Benchmark public double foldedLog() { int x = 8; return Math.log(x); }
基于常量的计算可能会返回完全相同的输出,而不管执行次数如何。 因此,JIT 编译器很有可能将对其结果替换对数函数调用:
@Benchmark public double foldedLog() { return 2.0794415416798357; }
这种形式的部分评估称为恒定折叠。在这种情况下,不断折叠完全避免了 Math.log 调用,这是基准测试的重点。
为了防止常量折叠,我们可以将常量状态封装在一个状态对象中:
@State(Scope.Benchmark) public static class Log { public int x = 8; } @Benchmark public double log(Log input) { return Math.log(input.x); }
如果我们相互运行这些基准:
Benchmark Mode Cnt Score Error Units BenchMark.foldedLog thrpt 20 449313097.433 ± 11850214.900 ops/s BenchMark.log thrpt 20 35317997.064 ± 604370.461 ops/s
显然,与折叠日志相比,日志基准测试正在做一些严肃的工作,这是明智的。