作者:閑大賦 鏈接:https://my.oschina.net/xiandafu/blog/3067186
關于JMH,可以直接查看官網地址http://openjdk.JAVA.net/projects/code-tools/jmh/
1.3 JMH
1.3.1 使用JMH
通過手工編寫一個性能壓測程序有較多的問題
- 不同需要性能比較方法放到一個虛擬機里調用,有可能會互相影響。最好的辦法是分成倆個獨立的進程運行,確保倆個對比方法不相互影響。
- PerformaceAreaTest啟動后直接運行, 缺少預熱代過程。虛擬機在執行代碼過程中,會加載類,解釋執行,以及有可能的優化編譯。需要確保虛擬機進行了一定預熱運行,以保證測試的公平性,我們在運行PerformaceAreaTest2的時候,能看到第一次循環執行時間總是較長。可以參考第8章了解JIT
- 為了避免環境影響造成的對結果統計不準,我們需要運行多次,取出平均成績
- 需要從多個緯度統計方法的性能,統計冷啟動需要消耗的時間,統計OPS,TP99的功能。
JMH使用OPS來表示吞吐量,OPS,Opeartion Per Second,是衡量性能的重要指標,指得是每秒操作量。數值越大,性能越好。類似的概念還有TPS,表示每秒的事務完成量,QPS,每秒的查詢量。 如果對每次執行時間進行升序排序,取出總數的99%的最大執行時間作為TP99的值,TP99通常是衡量系統性能重要指標,他表示99%的請求的響應時間不超過某個值。比TP99更嚴格的事TP999,要求99.9%的請求不超過某個值
有什么工具能幫助我們統計性能優化后的效果,比如更方便的統計OPS,TP99等。同時,我們為了做調優,不必每次都自己寫一個測試程序
JMH,即Java Microbenchmark Harness,是專門用于代碼微基準測試的工具套件。主要是基于方法層面的基準測試,精度可以達到納秒級。當你定位到熱點方法,希望進一步優化方法性能的時候,就可以使用JMH對優化的結果進行量化的分析。
JMH 實現了JSR269規范,即注解處理器,能在編譯Java源碼的時候,識別的到需要處理的注解,如@Beanmark,JMH能根據@Beanmark的配置生成一系列測試輔助類。關于JSR269,本書11章詳細介紹. 流行開源Lombok 基于JSR269規范
開始是使用JMH,可以在工程里添加對JMH的依賴,添加如下
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</dependency>
${jmh.version} 為jmh最新版本,為1.0
我們編寫一個JMH測試類
@BenchmarkMode(Mode.Throughput) @Warmup(iterations = 3) @Measurement(iterations = 3, time = 5, timeUnit = TimeUnit.SECONDS) @Threads(1) @Fork(1) @OutputTimeUnit(TimeUnit.SECONDS) public class MyBenchmark { @Benchmark public static void testStringKey(){ //優化前的代碼 } @Benchmark public static void testObjectKey(){ //要測試的優化后代碼 } public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(MyBenchmark.class.getSimpleName()) .build(); new Runner(opt).run(); } }
MyBenchmark 有倆個需要比較的方法,都用 @Benchmark注解標識,MyBenchmark用了一系列注解,解釋如下
- BenchmarkMode,使用模式,默認是Mode.Throughput,表示吞吐量,其他參數還有AverageTime,表示每次執行時間,SampleTime表示采樣時間,SingleShotTime表示只運行一次,用于測試冷啟動消耗時間,All表示統計前面的所有指標
- Warmup 配置預熱次數,默認是每次運行1秒,運行10次,我們的例子是運行3次
- Measurement 配置執行次數,本例是一次運行5秒,總共運行3次。在性能對比時候,采用默認1秒即可,如果我們用jvisualvm做性能監控,我們可以指定一個較長時間運行。
- Threads 配置同時起多少個線程執行,默認值世 Runtime.getRuntime().availableProcessors(),本例啟動1個線程同時執行
- Fork,代表啟動多個單獨的進程分別測試每個方法,我們這里指定為每個方法啟動一個進程。
- OutputTimeUnit 統計結果的時間單元,這個例子TimeUnit.SECONDS,我們在運行后會看到輸出結果是統計每秒的吞吐量
我們在MyBenchmark添加需要的測試方法,如下
static AreaService areaService = new AreaService(); static PreferAreaService perferAreaService = new PreferAreaService(); static List<Area> data = buildData(20); @Benchmark public static void testStringKey(){ areaService.buildArea(data); } @Benchmark public static void testObjectKey(){ perferAreaService.buildArea(data); } private static List<Area> buildData(int count){ List<Area> list = new ArrayList<>(count); for(int i=0;i<count;i++){ Area area = new Area(i,i*10); list.add(area); } return list; }
因為MyBenchmark包含了一個main方法,我們可以直接在IDE里直接運行這個方法,有如下輸出
# Warmup: 3 iterations, 1 s each # Measurement: 3 iterations, 5 s each # Threads: 1 threads, will synchronize iterations # Benchmark mode: Throughput, ops/time
以上輸出來自于我們的配置,第一行表示預熱3次,每次執行1秒,第二行表示運行3次,每次運行5秒,這部分的運行結果計入統計。第三行表示1個線程執行,第四行統計性能數據緯度是Throughput,吞吐量
緊接著會運行testObjectKey方法,有如下輸出
# Benchmark: com.ibeetl.code.ch01.test.MyBenchmark.testObjectKey # Run progress: 0.00% complete, ETA 00:00:36 # Fork: 1 of 1 objc[68658]: Class JavaLaunchHelper is implemented in both /Library/Java/JavaVirtualMachines/jdk1.8.0_45.jdk/Contents/Home/jre/bin/java and /Library/Java/JavaVirtualMachines/jdk1.8.0_45.jdk/Contents/Home/jre/lib/libinstrument.dylib. One of the two will be used. Which one is undefined. # Warmup Iteration 1: 1288302.671 ops/s # Warmup Iteration 2: 3061587.202 ops/s # Warmup Iteration 3: 1094970.828 ops/s Iteration 1: 2491836.097 ops/s Iteration 2: 2780362.118 ops/s Iteration 3: 3621313.883 ops/s
這里的Fork表示子進程,我們只配置里一個,因此只有一個進程的執行結果,該進程包含預熱3次,每次1秒,以及運行3次,每次運行5秒,執行完testObjectKey方法后,會自動打印一個匯總信息
Result: 939996.216 ±(99.9%) 2012646.237 ops/s [Average] Statistics: (min, avg, max) = (813154.364, 939996.216, 1013607.616), stdev = 110319.932 Confidence interval (99.9%): [-1072650.021, 2952642.453]
統計結果給出了多次測試后的最小值,最大值和均值,以及標準差 (stdev),置信區間(Confidence interval)
標準差(stdev)反映了數值相對于平均值得離散程度,置信區間是指由樣本統計量所構造的總體參數的估計區間。在統計學中,一個概率樣本的置信區間(Confidence interval)是對這個樣本的某個總體參數的區間估計
testStringKey的輸出與上面類似,這倆個比較方法執行完畢,會自動打印出一個性能對比數據表格
Benchmark Mode Samples Score Score error Units c.i.c.c.t.MyBenchmark.testObjectKey thrpt 3 1976766.072 408421.217 ops/s c.i.c.c.t.MyBenchmark.testStringKey thrpt 3 423788.869 222139.136 ops/s
Benchmark列表示這次測試對比的方法,Mode列表上結果的統計緯度,Samples列表示采樣次數,Samples=Fork*Iteration。Score是對這次評測的打分,對于testObjectKey,意味著他的OPS為每秒1976766,大約4倍testStringKey方法
Score Error 這里表示性能統計上的誤差,我們不需要關心這個數據,主要查看Score
可以修改統計緯度,比如修改為Mode.SampleTime,時間按照納秒統計
@BenchmarkMode(Mode.SampleTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) ...... public class MyBenchmark {}
可以看到有一組如下統計
p( 0.0000) = 1992.000 ns/op p(50.0000) = 2084.000 ns/op p(90.0000) = 2464.000 ns/op p(95.0000) = 3472.000 ns/op p(99.0000) = 4272.000 ns/op p(99.9000) = 17481.920 ns/op p(99.9900) = 80659.840 ns/op p(99.9990) = 562593.690 ns/op p(99.9999) = 745472.000 ns/op
可以看到90%的調用,是在2464納秒內完成,99%的調用都是在4272納秒完成的.
1.3.2 JMH常用設置
在這個例子,我們性能測試所依賴的對象areaService,perferAreaService 恰好是線程安全的,大多數時候性能測試方法都會引用一些外部實例對象,考慮到多線程測試訪問這些實例對象,JMH要求必須為這些變量申明是Thread 內生效,還是整個BeanMark使用。如果是前者,JMH會為每個線程構建一個新的實例,后者則所有測試都共享這個變量,JMH用@State注解來說明對象的生命周期,@State注解作用在類上,比如,在MyBenchmark例子里,我們可以改成如下例子
@State(Scope.Benchmark) public static class SharedPara{ AreaService areaService = new AreaService(); PreferAreaService perferAreaService = new PreferAreaService(); List<Area> data = buildData(20); private List<Area> buildData(int count){ //忽略其他代碼 } } @Benchmark public void testStringKey(SharedPara para){ para.areaService.buildArea(para.data); } @Benchmark public void testObjectKey(SharedPara para){ para.perferAreaService.buildArea(para.data); }
必須申明一公共靜態內部類,該類包含了我們需要使用的實例對象,并在該類用@State注解表明這個對象是Thread的還是BeanchMark范圍內使用。在這個例子里,因為配置為Scope.Benchmark,JMH在整個性能測試過程中,只構造一個SharedPara實例,SharedPara 作為參數傳入每個待測試的方法。
也可以不使用內部類,直接使用申明性能測試的類,在類上使用@State注解
@State(Scope.Benchmark) public class MyBenchmarkStateSimple { AreaService areaService = new AreaService(); PreferAreaService perferAreaService = new PreferAreaService(); List<Area> data = buildData(20); //忽略其他代碼 }
@Setup 和 @TearDown 是一對注解,作用于方法上,前者用于測試前的初始化工作,后者用于回收某些資源,比如壓測前需要準備一些數據
@State(Scope.Benchmark) public class ScriptEngineBeanchmrk { String script = null; @Benchmark public void nashornTest(){ // ... 測試方法 } @Setup public void loadScriptFromFile(){ //加載一個測試腳本 } }
@Level 用于控制 @Setup,@TearDown 的調用時機,有如下含義
- Level.Tiral: 運行每個性能測試的時候執行,推薦的方式。
- Level.Iteration, 每次迭代的時候執行
- Level.Invocation,每次調用方法的時候執行,這個選項需要謹慎使用。
JMH提供了Runner類能運行Benchmark類
public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(MyBenchmark.class.getSimpleName()) .build(); new Runner(opt).run(); }
include接受一個字符串表達式,表示需要測試的類和方法,如上例子測試所有方法MyBenchmark。如下例子則只測試方法名字包含“testObjectKey“的方法
include(MyBenchmark.class.getSimpleName()+".*testObjectKey*")
OptionsBuilder包含了多個方法用于配置性能測試,可以指定循環次數,預熱次數等,如下例子會用4個子進程做性能測試,每個進程預熱一次,執行5次迭代
public static void main(String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(MyBenchmark.class.getSimpleName()) .forks(4) .warmupIterations(1) .measurementIterations(5) .build(); new Runner(opt).run(); }
截至到目前為止,JMH都是通過一個main方法在IDE里執行,更為通常情況,JMH推薦使用單獨的一個Maven工程來執行性能測試而不要放到業務工程里。可以通過maven archetype:generate 命令來生成一個心得JMH Maven工程。
mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jmh -DarchetypeArtifactId=jmh-java-benchmark-archetype -DgroupId=code.ibeetl.com -DartifactId=first-benchmark -Dversion=1.0
為了閱讀方便,分成幾行,如上命令行應該放到一行執行,執行完畢后,生成了一個maven工程,maven工程僅僅包含了一個 MyBenchmark 例子。
package org.sample; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public void testMethod() { // place your benchmarked code here } }
我們可以修改MyBenchmark,添加我們需要測試的代碼, 現在,可以創建一個性能測試的jar文件,通過運行如下maven命令
mvn clean install
命令會在target目錄下生成一個benchmarks.jar,包含了運行性能測試所需的任何東西,在命令行運行如下命令
java -jar target/benchmarks.jar MyBenchmark
JMH將會被啟動,默認情況下運行MyBenchmark類里的所有被@Benchmark標注方法
有些性能測試需要了解不同輸入參數的性能,比如對于模板引擎的性能測試中,考慮到字節流輸出和字符流輸出
@Param({"1","2","3"}) int outputType; @Benchmark public String benchmark() throws TemplateException, IOException { if(outputType==3){ return doStream(); }else if(outputType==2) { return doCharStream() }else{ return doString(); } }
JMH會分別賦值outpuType為1,2,3后,在各自測試一次,會輸出如下
Benchmark (outputType) Score Units Beetl.benchmark 1 44977.421 ops/s Beetl.benchmark 2 34931.724 ops/s Beetl.benchmark 3 59175.106 ops/s
1.3.3 注意事項
編寫JHM代碼,需要考慮到虛擬機的優化,而使得測試失真,如下measureWrong代碼就是所謂的Dead-Code代碼
@State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public class JMHSample_08_DeadCode { private double x = Math.PI; @Benchmark public void baseline() { //基準 } @Benchmark public void measureWrong() { //虛擬機會優化掉這部分,性能同baseline Math.log(x); } @Benchmark public double measureRight() { // 真正的性能測試 return Math.log(x); } }
測試結果如下
Benchmark Mode Score Units
c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.baseline avgt 0.358 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.measureRight avgt 24.605 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_08_DeadCode.measureWrong avgt 0.366 ns/op
在測試measureWrong方法,JIT能推測出方法體可以被優化調而不影響系統,measureRight因為定義了返回值,JIT不會優化。
下一個是關于常量折疊,JIT認為方法計算結果為常量,從而優化直接返回常量給調用者
private double x = Math.PI; private final double wrongX = Math.PI; @Benchmark public double baseline() { // 基準測試 return Math.PI; } @Benchmark public double measureWrong_1() { // JIT認為是個常量 return Math.log(Math.PI); } @Benchmark public double measureWrong_2() { // JIT認為方法調用結果是個常量. return Math.log(wrongX); } @Benchmark public double measureRight() { // 正確的測試 return Math.log(x); }
如下是測試結果
Benchmark Mode Score Units
c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.baseline avgt 1.175 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureRight avgt 25.805 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureWrong_1 avgt 1.116 ns/op
c.i.c.c.c.i.c.c.j.JMHSample_10_ConstantFold.measureWrong_2 avgt 1.031 ns/op
考慮到inline對性能影響很大,JMH支持 @CompilerControl來控制是否允許內聯
public class Inline { int x=0,y=0; @Benchmark @CompilerControl(CompilerControl.Mode.DONT_INLINE) public int add(){ return dataAdd(x,y); } @Benchmark public int addInline(){ return dataAdd(x,y); } private int dataAdd(int x,int y){ return x+y; } @Setup public void init() { x = 1; y = 2; } }
add和addInline方法都會調用dataAdd方法,前者使用CompilerControl類,可以用在方法或者類上,來提供編譯選項
- DONT_INLINE,調用方法不內聯
- INLINE,調用方法內聯
- BREAK,插入一個調試斷點(TODO,如何調試,參考11章)
- PRINT,打印方法被JIT編譯后的機器碼信息
開發人員可能覺得上面的測試,add方法太簡單,會習慣性的在add方法里方一個循環,以減少JMH調用add方法的成本。JMH不建議這么做,因為JIT會實際上對這種循環會做優化,以消除循環調用成本。如下是個例子可以看到循環測試結果不準確
int x = 1; int y = 2; /** 正確測試 */ @Benchmark public int measureRight() { return (x + y); } private int reps(int reps) { int s = 0; for (int i = 0; i < reps; i++) { s += (x + y); } return s; } @Benchmark @OperationsPerInvocation(1) public int measureWrong_1() { return reps(1); } @Benchmark @OperationsPerInvocation(10) public int measureWrong_10() { return reps(10); } @Benchmark @OperationsPerInvocation(100) public int measureWrong_100() { return reps(100); } @Benchmark @OperationsPerInvocation(1000) public int measureWrong_1000() { return reps(1000); }
注解OperationsPerInvocation 告訴JMH統計性能的時候需要做修正,比如@OperationsPerInvocation(10)調用了10次。
性能測試結果如下
編寫性能測試的一個好習慣是先編寫一個單元測試用例,以確保性能測試準確性,x Benchmark Mode Score Units c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureRight avgt 1.114 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_oops.measureWrong_1 avgt 1.057 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_10 avgt 0.139 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_100 avgt 0.018 ns/op c.i.c.c.c.i.c.c.j.JMHSample_11_Loops.measureWrong_1000 avgt 0.035 ns/op java
可以看到,測試方法里使用循環,會促使JIT進行優化,做循環消除(參考第8章JIT TODO)
1.3.4 單元測試
無論是編寫JMH,或者其他性能測試程序,好習慣是先編寫一個單元測試用例,以確保性能測試方法的準確性,對于1.3.4的Inline類,可以先編寫一個單元測試用例,確保add和addInline返回正確結果
public class InLineTestJunit { @Test public void test(){ Inline inline = new Inline(); inline.init(); //期望結果 int expectd = inline.x+inline.y; int ret = inline.add(); int ret2 = inline.addInline(); Assert.assertEquals(expectd,ret); Assert.assertEquals(expectd,ret2); } }
在JMH工程調用maven install 生成測試代碼的時候,會進行單元測試,從而保證測試結果的準確