JMH-Java Microbenchmark线束
JMH是Java Microbenchmark Harness的缩写。 JMH是一个工具包,可以正确地实现Java微基准测试。 JMH由实现Java虚拟机的同一个人开发,因此这些人知道他们在做什么。该JMH教程将教我们如何使用JMH实现和运行Java微基准。
为什么Java Microbenchmarks很难?
编写基准测试以正确衡量大型应用程序中一小部分的性能非常困难。当基准测试独立执行该组件时,JVM或者基础硬件可能会应用许多优化。当组件作为大型应用程序的一部分运行时,可能无法应用这些优化。因此,实施不当的微基准测试可能会使我们相信组件的性能要比实际情况要好。
编写正确的Java微基准通常会阻止在微基准执行期间可能无法应用的JVM和硬件优化,而这些优化在实际生产系统中可能无法应用。这就是JMH Java Microbenchmark Harness完成的工作。
JMH入门
开始使用JMH的最简单方法是使用JMH Maven原型生成一个新的JMH项目。 JMH Maven原型将使用一个示例基准Java类和一个Mavenpom.xml
文件生成一个新的Java项目。 Mavenpom.xml
文件包含正确的依赖关系,用于编译和构建JMH microbenchmark套件。
这是生成JMH项目模板所需的Maven命令行:
mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jmh -DarchetypeArtifactId=jmh-java-benchmark-archetype -DgroupId=com.Hyman -DartifactId=first-benchmark -Dversion=1.0
这是一个很长的命令。命令中不应有换行符。我只是添加了它们,以使命令更易于阅读。
该命令行将创建一个名为" first-benchmark"(在Maven命令中指定的" artifactId")的新目录。在该目录内将生成一个新的Maven源目录结构(src / main / java
)。在java
源根目录中将生成一个名为com.Hyman
的单个Java包(实际上是一个名为com
的包,以及一个名为Hyman
的子包)。在com.Hyman包中将包含一个名为MyBenchmark的JMH基准Java类。
第一个JMH基准
现在是时候编写第一个JMH基准测试类,或者至少看看它是如何完成的。
生成的" MyBenchmark"类是一个JMH类模板,可用于实现JMH基准测试。我们可以直接在生成的" MyBenchmark"类中实现基准,也可以在同一Java包中创建一个新类。为了使我们轻松编写第一个JMH基准测试,在本示例中,我将仅使用生成的类。
首先是生成的MyBenchmark
类的外观:
package com.Hyman; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public void testMethod() { // This is a demo/sample template for building your JMH benchmarks. Edit as needed. // Put your benchmark code here. } }
我们可以将要测量的代码放入testMethod()方法主体中。这是一个例子:
package com.Hyman; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public void testMethod() { // This is a demo/sample template for building your JMH benchmarks. Edit as needed. // Put your benchmark code here. int a = 1; int b = 2; int sum = a + b; } }
注意:这个特定示例是一个糟糕的基准实现,因为JVM可以看到从不使用sum
,因此可以省去sum计算。实际上,可以通过消除JVM死代码来删除整个方法主体。现在,仅想象一下testMethod()主体实际上包含一个良好的基准实现。在本教程的稍后部分,我将返回如何使用JMH实施更好的基准测试。
建立JMH基准
现在,我们可以使用以下Maven命令从JMH基准测试项目编译并生成基准JAR文件:
mvn clean install
这个Maven命令必须从生成的基准项目目录(在本例中为" first-benchmark"目录)内部执行。
执行此命令后,将在" first-benchmark / target"目录中创建一个JAR文件。该JAR文件将被命名为benchmarks.jar
。
Benchmarks.jar文件
当我们建立JMH基准测试时,Maven总是会在目标目录(Maven的标准输出目录)中生成一个名为" benchmarks.jar"的JAR文件。
benchmarks.jar
文件包含运行基准测试所需的一切。它包含已编译的基准测试类以及运行基准测试所需的所有JMH类。
如果基准测试有任何外部依赖性(运行基准测试所需的其他项目的JAR文件),请在Mavenpom.xml
中声明这些依赖性,它们也将包含在benchmarks.jar
中。
由于benchmarks.jar
是完全独立的,因此我们可以将该JAR文件复制到另一台计算机上以在该计算机上运行JMH基准测试。
运行JMH基准
构建完JMH基准代码后,我们可以使用以下Java命令运行基准:
java -jar target/benchmarks.jar
这将在基准课程上启动JMH。 JMH将扫描代码并找到所有基准并运行它们。 JMH将结果打印到命令行。
运行基准测试将需要一些时间。 JMH进行了几次热身,迭代等操作,以确保结果不是完全随机的。运行次数越多,平均性能越好,并且获得的性能高/低性能信息也越多。
在运行基准测试时,我们应该让计算机不动,并关闭所有其他应用程序(如果可能)。如果计算机运行的是其他应用程序,则这些应用程序可能会花费CPU时间,并且会给出不正确的(较低的)性能数字。
JMH基准测试模式
JMH可以以不同的模式运行基准测试。基准测试模式告诉JMH我们要测量的内容。 JMH提供以下基准测试模式:
吞吐量 | 测量每秒的操作数,这意味着每秒可以执行基准测试方法的次数。 |
平均时间 | 测量基准方法执行(一次执行)所需的平均时间。 |
Sample Time | 测量执行基准测试方法所花费的时间,包括最长,最短时间等。 |
“单次射击时间” | 测量单个基准方法执行执行所花费的时间。很好地测试它在冷启动(不预热JVM)下的性能。 |
全部 | 测量以上所有内容。 |
默认的基准测试模式是吞吐量。
我们可以通过JMH注释BenchmarkMode
指定基准测试应使用的基准测试模式。我们可以在基准测试方法的顶部放置" BenchmarkMode"注释。这是一个JMHBenchmarkMode
示例:
package com.Hyman; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Mode; public class MyBenchmark { @Benchmark @BenchmarkMode(Mode.Throughput) public void testMethod() { // This is a demo/sample template for building your JMH benchmarks. Edit as needed. // Put your benchmark code here. int a = 1; int b = 2; int sum = a + b; } }
注意在testMethod()方法上方的@BenchmarkMode(Mode.Throughput)批注。该注释指定基准模式。 Mode类包含每种可能的基准测试模式的常量。
基准时间单位
JMH使我们可以指定要打印基准测试结果的时间单位。该时间单位将用于执行基准测试的所有基准测试模式。
我们可以使用JMH批注@ OutputTimeUnit
指定基准时间单位。 @OutputTimeUnit注解采用java.util.concurrent.TimeUnit作为参数来指定要使用的实际时间单位。这是一个JMH@ OutputTimeUnit
注释示例:
package com.Hyman; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Mode; import org.openjdk.jmh.annotations.OutputTimeUnit; import java.util.concurrent.TimeUnit; public class MyBenchmark { @Benchmark @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.MINUTES) public void testMethod() { // This is a demo/sample template for building your JMH benchmarks. Edit as needed. // Put your benchmark code here. int a = 1; int b = 2; int sum = a + b; } }
在此示例中,指定的时间单位为分钟。这意味着我们希望以时间单位分钟(例如每分钟的操作数)显示输出。
TimeUnit类包含以下时间单位常量:
- NANOSECONDS
- MICROSECONDS
- MILLISECONDS
- SECONDS
- MINUTES
- HOURS
- DAYS
基准状态
有时,我们可能希望初始化一些基准代码需要的变量,但又不想成为基准测量的代码的一部分。这样的变量称为"状态"变量。状态变量在特殊状态类中声明,然后可以将该状态类的实例作为基准方法的参数提供。这听起来可能有些复杂,所以这里是一个JMH基准状态示例:
package com.Hyman; import org.openjdk.jmh.annotations.*; import java.util.concurrent.TimeUnit; public class MyBenchmark { @State(Scope.Thread) public static class MyState { public int a = 1; public int b = 2; public int sum ; } @Benchmark @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.MINUTES) public void testMethod(MyState state) { state.sum = state.a + state.b; } }
在此示例中,我添加了一个名为MyState
的嵌套静态类。 MyState类使用JMH @State注释进行注释。这会向JMH发出信号,表明这是一个状态类。注意,testMethod()基准测试方法现在将MyState的实例作为参数。
还要注意,testMethod()主体现在已更改为在执行总和计算时使用MyState对象。
状态范围
状态对象可以在对基准方法的多次调用中重用。 JMH提供了状态对象可以其中重用的不同"作用域"。状态范围在@State批注的参数中指定。在上面的示例中,选择的范围是Scope.Thread
。
范围类包含以下范围常量:
Thread | 运行基准的每个线程将创建其自己的状态对象实例。 |
Group | 运行基准的每个线程组将创建其自己的状态对象实例。 |
基准 | 运行基准的所有线程共享相同的状态对象。 |
状态类要求
JMH状态类必须遵守以下规则:
- 该类必须声明为" public"
- 如果该类是嵌套类,则必须将其声明为"静态"(例如,"公共静态类...")
- 该类必须具有公共的无参数构造函数(该构造函数没有参数)。
当遵守这些规则时,我们可以使用@State注释对类进行注释,以使JMH将其识别为状态类。
状态对象@Setup和@TearDown
我们可以在状态类中使用@ Setup
和@ TearDown
批注来批注方法。 @Setup批注告诉JMH,在将状态对象传递给基准方法之前,应先调用此方法来设置状态对象。 " @TearDown"注解告诉JMH,在执行基准测试后应调用此方法来清理("删除")状态对象。
基准运行时测量中不包括设置和拆卸执行时间。
这是一个JMH状态对象示例,它显示了@Setup和@TearDown注释的用法:
package com.Hyman; import org.openjdk.jmh.annotations.*; import java.util.concurrent.TimeUnit; public class MyBenchmark { @State(Scope.Thread) public static class MyState { @Setup(Level.Trial) public void doSetup() { sum = 0; System.out.println("Do Setup"); } @TearDown(Level.Trial) public void doTearDown() { System.out.println("Do TearDown"); } public int a = 1; public int b = 2; public int sum ; } @Benchmark @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.MINUTES) public void testMethod(MyState state) { state.sum = state.a + state.b; } }
注意MyState类中名为doSetup()和doTearDown()的两个新方法。这些方法都用@ Setup
和@ TearDown
批注来批注。这个例子只显示了两种方法,但是我们可以使用@Setup和@TearDown注释更多方法。
还要注意,注释带有一个参数。此参数可以采用三个不同的值。我们设置的值指示JMH有关何时应调用该方法的信息。可能的值为:
Level.Trial | 对于基准的每次完整运行,该方法每次都调用一次。完整运行意味着完整的“ fork” ,包括所有的预热和基准测试迭代。 |
Level.Iteration | 对于基准的每次迭代都调用一次该方法。 |
Level.Invocation | 对于基准方法的每次调用都将调用一次该方法。 |
如果我们对何时调用设置或者拆卸方法有任何疑问,请尝试在该方法中插入" System.out.println()"语句。然后我们会看到。然后,我们可以更改@ Setup
和@TearDown()
参数值,直到在正确的时间调用设置和拆卸方法。
编写良好的基准
既然我们已经了解了如何使用JMH编写基准,现在该讨论如何编写良好的基准了。如本JMH教程开始时所提到的,在实现基准测试时,我们容易陷入一些陷阱。我将在以下各节中讨论其中的一些陷阱。
一个常见的陷阱是,当在基准内执行时,JVM可能会对组件进行优化,而如果在实际应用程序中执行了该组件,则可能无法应用优化。这样的优化将使代码看起来比实际的快。稍后我将讨论其中的一些优化。
循环优化
试图将基准代码放入基准方法的循环中,以使每次对基准方法的调用重复多次(以减少基准方法调用的开销)。但是,JVM非常擅长优化循环,因此最终结果可能与我们预期的不同。通常,我们应该避免在基准测试方法中使用循环,除非循环是我们要测量的代码的一部分(而不是我们要测量的代码周围)。
消除死代码
实施性能基准测试时要避免的JVM优化之一是消除死代码。如果JVM检测到从未使用过某些计算的结果,则JVM可能会考虑此计算无效代码并将其消除。看一下这个基准示例:
package com.Hyman; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public void testMethod() { int a = 1; int b = 2; int sum = a + b; } }
JVM可以检测到从未使用分配给sum的a + b的计算。因此,JVM可以完全删除" a + b"的计算。它被认为是无效代码。然后,JVM可以检测到从未使用过sum变量,并且随后也从未使用过a和b。它们也可以被消除。
最后,基准测试中没有任何代码。因此,运行此基准测试的结果极具误导性。基准实际上并没有测量添加两个变量并将值分配给第三个变量的时间。基准测试什么也没有。
避免死代码消除
为了避免消除无效代码,我们必须确保要测量的代码与JVM的无效代码不一样。有两种方法可以做到这一点。
- 从基准测试方法返回代码的结果。
- 将计算的值传递到JMH提供的"黑洞"中。
在以下各节中,我将向我们展示这两种方法的示例。
从基准方法返回值
从JMH基准测试方法返回计算值可能看起来像这样:
package com.Hyman; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public int testMethod() { int a = 1; int b = 2; int sum = a + b; return sum; } }
注意testMethod()方法现在如何返回sum变量。这样,JVM不能仅仅消除添加,因为调用者可能会使用返回值。 JMH将诱使JVM相信实际使用了返回值。
如果基准测试方法正在计算可能最终被作为死代码消除的多个值,则可以将两个值合并为一个值,然后返回该值(例如,同时包含两个值的对象)。
将值传递给黑洞
返回组合值的替代方法是将计算出的值(或者返回/生成的对象或者基准的任何结果)传递到JMH黑洞中。这是将值传递到黑洞中的样子:
package com.Hyman; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.infra.Blackhole; public class MyBenchmark { @Benchmark public void testMethod(Blackhole blackhole) { int a = 1; int b = 2; int sum = a + b; blackhole.consume(sum); } }
注意,testMethod()基准测试方法现在如何将" Blackhole"对象作为参数。调用时,它将由JMH提供给测试方法。
还要注意,现在如何将sum变量中计算出的总和传递给Blackhole实例的consume()方法。这将使JVM误以为实际上正在使用sum
变量。
如果基准测试方法产生多个结果,则可以将这些结果中的每一个传递到一个黑洞,这意味着对每个值在Blackhole实例上调用consume()。
常量叠算
常量叠算是另一种常见的JVM优化。不管执行多少次,基于常数的计算通常会得出完全相同的结果。 JVM可以检测到该情况,然后将计算结果替换为计算结果。
例如,看一下这个基准:
package com.Hyman; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public int testMethod() { int a = 1; int b = 2; int sum = a + b; return sum; } }
JVM可以检测到" sum"的值基于" a"和" b"中的两个常量值1和2. 因此,它可以用以下代码替换上面的代码:
package com.Hyman; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public int testMethod() { int sum = 3; return sum; } }
甚至直接返回3即可。 JVM甚至可以继续执行并且从不调用testMethod()
,因为它知道它总是返回3,并且只要在要调用testMethod()
的位置处内联常数3.
避免常量叠算
为避免常量折叠,我们不得将常量硬编码到基准方法中。相反,计算输入应来自状态对象。这使JVM很难看到计算是基于常量值的。这是一个例子:
package com.Hyman; import org.openjdk.jmh.annotations.*; public class MyBenchmark { @State(Scope.Thread) public static class MyState { public int a = 1; public int b = 2; } @Benchmark public int testMethod(MyState state) { int sum = state.a + state.b; return sum; } }
请记住,如果基准测试方法计算出多个值,则可以将其传递给黑洞而不是返回它们,以避免死代码消除优化。例如:
@Benchmark public void testMethod(MyState state, Blackhole blackhole) { int sum1 = state.a + state.b; int sum2 = state.a + state.a + state.b + state.b; blackhole.consume(sum1); blackhole.consume(sum2); }