基准测试benchmarking
性能测试的坑 通过System.nanoTime或者System.currentTimeMillis来测量每若干个操作所花费的时间,这种测量方式过于理性化,忽略了JVM、操作系统、硬件系统所带来的影响。
JVM的影响 JVM堆空间的自适配,即时编译等。
简单性能测试代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 static int foo () { int i = 0 ; while (i < 1_000_000_000 ) { i++; } return i; } public class LoopPerformanceTest { static int foo () { ... } public static void main (String[] args) { for (int i = 0 ; i < 20_000 ; i++) { foo(); } long current = System.nanoTime(); for (int i = 1 ; i <= 10_000 ; i++) { foo(); if (i % 1000 == 0 ) { long temp = System.nanoTime(); System.out.println(temp - current); current = System.nanoTime(); } } } }
真正进行测试的代码由于循环次数不多,属于冷循环,不一定能触发OSR(On-Stack Replacement)编译。 在main方法中解释执行,然后调用foo方法即时编译生成的机器码。这种混杂了解释执行以及即时编译生成代码的测量方式,其得到的数据含义不明。(即时编译器循环优化)
操作系统和硬件系统的影响 例电源管理策略。操作系统会动态配置CPU的频率,CPU的频率直接影响到性能测试的数据,因此短时间的性能测试得出的数据未必可靠。
例CPU缓存,如果程序的数据本地性较好,那么它的性能指标便会非常好;如果程序存在false sharing的问题,即几个线程写入内存中属于同一缓存行的不同部分,那么它的性能指标便会非常糟糕。
例超线程技术,将为每个物理核心虚拟出两个虚拟核心,从而尽可能的提高物理核心的利用率。如果性能测试的两个线程被安排在同一物理核心上,那么得到的测试数据显然要比被安排在不同物理核心上的数据糟糕。
性能基准测试存在着许多深坑(pitfall)。性能测试数据很有可能是有偏差的(biased)。
JMH OpenJDK的开源项目JMH(Java Microbenchmark Harness)是一个面向Java语言或者其它JVM语言的性能基准测试框架。它针对的是纳秒级别、微妙级别、毫秒级别、以及秒级别的性能测试。 JMH内置了许多功能来控制即时编译器的优化,对于其它影响性能评测的因素,JMH提供了不少策略来降低影响。使用JMH可以将精力完全几种在所要测试的业务逻辑,并以最小的代价控制除业务逻辑之外的可能影响性能的因素。JMH也不能完美解决性能测试数据的偏差问题。
通常来说,性能基准测试的结果反映的是所测试的业务逻辑在所运行的JVM、操作系统、硬件系统这一组合上的性能指标,而根据这些性能指标得出的通用结论则需要经过严格论证。
生成JMH项目 借助JMH部署在maven上的archetype,生成预设好依赖关系的maven项目模板。
1 2 3 4 5 6 7 8 $ mvn archetype:generate \ -DinteractiveMode=false \ -DarchetypeGroupId=org.openjdk.jmh \ -DarchetypeArtifactId=jmh-java-benchmark-archetype \ -DgroupId=org.sample \ -DartifactId=test \ -Dversion=1.21 $ cd test
该命令将在当前目录下生成一个test文件夹(对应参数-DartifactId=test),其中包含了定义该maven项目依赖的pom.xml文件,以及自动生成的测试文件src/main/org/sample/MyBenchmark.java(对应参数-DgroupId=org.sample)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package org.sample;import org.openjdk.jmh.annotations.Benchmark;public class MyBenchmark { @Benchmark public void testMethod () { new Exception(); } }
@Benchmark注解,标注的方法就是JMH基准测试的测试方法。该方法默认是空的,可以填入需要进行性能测试的业务逻辑。
编译和运行JMH项目 JMH是利用注解处理器来自动生成性能测试的代码。除了@Benchmark之外,JMH的注解处理器还将处理所有位于org.openjdk.jmh.annotations包下的注解。
mvn compile 可以运行mvn compile命令来编译这个maven项目。该命令将生成target文件夹,其中的generated-sources目录存放着由JMH的注解处理器所生成的Java源代码:
1 2 3 $ mvn compile $ ls target/generated-sources/annotations/org/sample/generated/ MyBenchmark_jmhType.java MyBenchmark_jmhType_B1.java MyBenchmark_jmhType_B2.java MyBenchmark_jmhType_B3.java MyBenchmark_testMethod_jmhTest.java
在这些源代码里,所有以MyBenchmark_jmhType为前缀的Java类都继承自MyBenchmark。这是注解处理器的常见用法,通过生成子类来将注解所带来的额外语义扩张成方法。
它们之间的继承关系是MyBenchmark_jmhType->B3->B2->B1->MyBenchmark(A->B代表A继承B)。其中B2存放着JMH用来控制基准测试的各项字段。
为了避免这些控制字段对MyBenchmark类中的字段造成false sharing的影响,JMH生成了B1和B3,分别存放了256个boolean字段,从而避免B2中的字段与MyBenchmark类、MyBenchmark_jmhType类中的字段(或内存里下一个对象中的字段)会出现在同一缓存行中。
因为JVM的字段重排列,所以不能在同一类中安排这些字段。类之间的继承关系,可以避免不同类所包含的字段之间的重排列。
除了jmhType源代码外,generated-source目录还存放着真正的性能测试代码MyBenchmark_testMethod_jmhTest.java。当进行性能测试时,JVM所运行的代码很有可能便是这一个源文件中的热循环经过OSR编译过后的代码。
在通过CompileCommand分析即时编译后的机器码时,需要关注的其实是MyBenchmark_testMethod_jmhTest中的方法。
mvn package 可以运行mvn package命令,将编译号的class文件打包成jar包。生成的jar包同样位于target目录下,名字为benchmarks.jar。jar包中附带了一系列配置文件。
配置文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 $ mvn package $ jar tf target/benchmarks.jar META-INF META-INF/MANIFEST.MF META-INF/ META-INF/BenchmarkList META-INF/CompilerHints META-INF/maven/ META-INF/maven/org.sample/ META-INF/maven/org.sample/test/ META-INF/maven/org.sample/test/pom.xml META-INF/maven/org.sample/test/pom.properties META-INF/maven/org.openjdk.jmh/ META-INF/maven/org.openjdk.jmh/jmh-core/ META-INF/maven/org.openjdk.jmh/jmh-core/pom.xml META-INF/maven/org.openjdk.jmh/jmh-core/pom.properties META-INF/maven/net.sf.jopt-simple/ META-INF/maven/net.sf.jopt-simple/jopt-simple/ META-INF/maven/net.sf.jopt-simple/jopt-simple/pom.xml META-INF/maven/net.sf.jopt-simple/jopt-simple/pom.properties META-INF/LICENSE.txt META-INF/NOTICE.txt META-INF/maven/org.apache.commons/ META-INF/maven/org.apache.commons/commons-math3/ META-INF/maven/org.apache.commons/commons-math3/pom.xml META-INF/maven/org.apache.commons/commons-math3/pom.properties $ unzip -c target/benchmarks.jar META-INF/MANIFEST.MF Archive: target/benchmarks.jar inflating: META-INF/MANIFEST.MF Manifest-Version: 1.0 Archiver-Version: Plexus Archiver Created-By: Apache Maven 3.5 .4 Built-By: zhengy Build-Jdk: 10.0 .2 Main-Class: org.openjdk.jmh.Main $ unzip -c target/benchmarks.jar META-INF/BenchmarkList Archive: target/benchmarks.jar inflating: META-INF/BenchmarkList JMH S 22 org.sample.MyBenchmark S 51 org.sample.generated.MyBenchmark_testMethod_jmhTest S 10 testMethod S 10 Throughput E A 1 1 1 E E E E E E E E E E E E E E E E E $ unzip -c target/benchmarks.jar META-INF/CompilerHints Archive: target/benchmarks.jar inflating: META-INF/CompilerHints dontinline,*.*_all_jmhStub dontinline,*.*_avgt_jmhStub dontinline,*.*_sample_jmhStub dontinline,*.*_ss_jmhStub dontinline,*.*_thrpt_jmhStub inline,org/sample/MyBenchmark.testMethod
其中三个配置文件:
MANIFEST.MF中指定了该jar包的默认入口,即org.openjdk.jmh.Main。
BenchmarkList中存放了测试配置。该配置是根据MyBenchmark.java里的注解自动生成的。
CompilerHints中存放了传递给JVM的-XX:CompileCommandFile参数的内容。它规定了无法内联以及必须内联的几个方法,其中便有存放业务逻辑的测试方法testMethod。
在编译MyBenchmark_testMethod_jmhTest类中的测试方法时,JMH会让即时编译器强制内联对MyBenchmark.testMethod的方法调用,以避免调用开销。
打包生成的jar包可以直接运行。
1 2 3 4 5 6 $ java -jar target/benchmarks.jar WARNING: An illegal reflective access operation has occurred ... Benchmark Mode Cnt Score Error Units MyBenchmark.testMethod thrpt 25 1004801 ,393 ± 4055 ,462 ops/s
输出的最后便是本次基准测试的结果。其中比较重要的两项指标是Score和Error,分别代表本次基准测试的平局吞吐量(每秒运行testMethod方法的次数)以及误差范围。(本次基准测试平均每秒生成10^6个异常实例,误差范围大致在4000个异常实例)
总结 Java程序的性能测试存在着许多深坑,有来自JVM的,有来自操作系统的,甚至有来自硬件系统的。性能测试的结果很有可能是有偏差的。
性能基准测试框架JMH是OpenJDK中的其中一个开源项目。它内置了许多功能,来规避由JVM中的即时编译器或者其它优化对性能测试造成的影响。它还提供了不少策略来降低来自操作系统以及硬件系统的影响。
开发人员仅需要将所要测试的业务逻辑通过@Benchmark注解,便可以让JMH的注解处理器自动生成真正的性能测试代码,以及相应的性能测试配置文件。