不要再用main方法测试代码性能了,用这款JDK自带工具
前言
作為軟件開發人員,我們通常會寫一些測試程序用來對比不同算法、不同工具的性能問題。而最常見的做法是寫一個main方法,構造模擬場景進行并發測試。
如果細心的朋友可能已經發現,每次測試結果誤差很大,有時候測試出的結果甚至與事實相反。當然,這不排除是因為軟硬件環境因素導致,但更多的可能是因為所使用測試方法自身有問題。
比如,不同需要性能比較方法放到一個虛擬機里調用,有可能會互相影響,缺少預熱的過程等。
本文給大家推薦一款JDK9及以后自帶的一款可用于軟件基準測試的工具JMH(Java Microbenchmark Harness)。
JMH簡介
JMH是用于代碼微基準測試的工具套件,主要是基于方法層面的基準測試,精度可以達到納秒級。
何謂Micro Benchmark呢?簡單的來說就是基于方法層面的基準測試,精度可以達到微秒級。當你定位到熱點方法,希望進一步優化方法性能的時候,就可以使用JMH對優化的結果進行量化的分析。
這款工具是由Oracle內部實現JIT的作者所寫。我們知道JIT(Java即時編譯器)是將JVM優化的所有高效手段和技術都使用上的地方。可想而知,開發者比任何人都更加了解JVM和JIT對基準測試的影響。
因此,這款工具是值得我們信賴和在實踐中進行使用的。而且使用起來也非常方便。
使用場景
JMH不僅能幫我們測試一些常見類的性能,比如對比StringBuffer和StringBuilder的性能、對比不同算法的在不同數據量的性能等,還能夠幫助我們對系統中發現的熱點代碼進行量化分析。
JMH通常用于以下應用場景:
測試某個方法在穩定執行的情況下所需時間,以及執行時間和問題規模的相關性;
對比接口不同實現在給定條件下的吞吐量
查看多少百分比的請求在多長時間內完成
使用實例
依賴引入
如果你使用的是JDK9或以上版本,則JDK中已經自帶了該工具,直接使用即可。如果你使用的是其他版本則可以通過maven直接引入以下依賴:
<dependency><groupId>org.openjdk.jmh</groupId><artifactId>jmh-core</artifactId><version>1.27</version> </dependency> <dependency><groupId>org.openjdk.jmh</groupId><artifactId>jmh-generator-annprocess</artifactId><version>1.27</version> </dependency>其中1.27是當前的最新版本,可根據實際需要更新或降低版本。
測試案例
下面以StringBuffer和StringBuilder的性能測試對比為例來進行基準測試。
//使用模式?默認是Mode.Throughput @BenchmarkMode(Mode.AverageTime) //?配置預熱次數,默認是每次運行1秒,運行10次,這里設置為3次 @Warmup(iterations?=?3,?time?=?1) //?本例是一次運行4秒,總共運行3次,在性能對比時候,采用默認1秒即可 @Measurement(iterations?=?3,?time?=?4) //?配置同時起多少個線程執行 @Threads(1) //代表啟動多個單獨的進程分別測試每個方法,這里指定為每個方法啟動一個進程 @Fork(1) //?定義類實例的生命周期,Scope.Benchmark:所有測試線程共享一個實例,用于測試有狀態實例在多線程共享下的性能 @State(value?=?Scope.Benchmark) //?統計結果的時間單元 @OutputTimeUnit(TimeUnit.NANOSECONDS) public?class?JmhTest?{@Param(value?=?{"10",?"50",?"100"})private?int?length;public?static?void?main(String[]?args)?throws?RunnerException?{Options?opt?=?new?OptionsBuilder().include(JmhTest.class.getSimpleName()).result("result.json").resultFormat(ResultFormatType.JSON).build();new?Runner(opt).run();}@Benchmarkpublic?void?testStringBufferAdd(Blackhole?blackhole)?{StringBuffer?sb?=?new?StringBuffer();for?(int?i?=?0;?i?<?length;?i++)?{sb.append(i);}blackhole.consume(sb.toString());}@Benchmarkpublic?void?testStringBuilderAdd(Blackhole?blackhole)?{StringBuilder?sb?=?new?StringBuilder();for?(int?i?=?0;?i?<?length;?i++)?{sb.append(i);}blackhole.consume(sb.toString());} }上面介紹概念時已經提到Benchmark為基準測試,在使用中只需對要測試的方法添加@Benchmark注解即可。而在測試類JmhTest指定測試的預熱、線程、測試維度等信息。
main方法中通過OptionsBuilder構造測試配置對象Options,并傳入Runner,啟動測試。這里指定測試結果為json格式,同時會將結果存儲在result.json文件當中。
執行測試
執行main方法,控制臺首先會打印出如下信息:
#?JMH?version:?1.27 #?VM?version:?JDK?1.8.0_271,?Java?HotSpot(TM)?64-Bit?Server?VM,?25.271-b09 #?VM?invoker:?/Library/Java/JavaVirtualMachines/jdk1.8.0_271.jdk/Contents/Home/jre/bin/java #?VM?options:?-javaagent:/Applications/IntelliJ?IDEA.app/Contents/lib/idea_rt.jar=56800:/Applications/IntelliJ?IDEA.app/Contents/bin?-Dfile.encoding=UTF-8 #?JMH?blackhole?mode:?full?blackhole?+?dont-inline?hint #?Warmup:?3?iterations,?1?s?each #?Measurement:?3?iterations,?4?s?each #?Timeout:?10?min?per?iteration #?Threads:?1?thread,?will?synchronize?iterations #?Benchmark?mode:?Average?time,?time/op #?Benchmark:?com.choupangxia.strings.JmhTest.testStringBufferAdd #?Parameters:?(length?=?10)這些信息主要用來展示測試的基本信息,包括jdk、JVM、預熱配置、執行輪次、執行時間、執行線程、測試的統計單位等。
#?Warmup?Iteration???1:?76.124?ns/op #?Warmup?Iteration???2:?77.703?ns/op #?Warmup?Iteration???3:?249.515?ns/op這是對待測試方法的預熱處理,這部分不會記入測試結果。預熱主要讓JVM對被測代碼進行足夠多的優化,比如JIT編譯器的優化。
Iteration???1:?921.191?ns/op Iteration???2:?897.729?ns/op Iteration???3:?890.245?ns/opResult?"com.choupangxia.strings.JmhTest.testStringBuilderAdd":903.055?±(99.9%)?294.557?ns/op?[Average](min,?avg,?max)?=?(890.245,?903.055,?921.191),?stdev?=?16.146CI?(99.9%):?[608.498,?1197.612]?(assumes?normal?distribution)顯示每次(共3次)迭代執行速率,最后進行統計。這里是對testStringBuilderAdd方法執行length為100的測試,通過 (min, avg, max) 三項可以看出最小時間、平均時間、最大時間的值,單位為ns。stdev顯示的是誤差時間。
通常情況下,我們只用看最后的結果即可:
Benchmark?????????????????????(length)??Mode??Cnt?????Score??????Error??Units JmhTest.testStringBufferAdd???????????????10??avgt????3????92.599?±??105.019??ns/op JmhTest.testStringBufferAdd???????????????50??avgt????3???582.974?±??580.536??ns/op JmhTest.testStringBufferAdd??????????????100??avgt????3??1131.460?±?1109.380??ns/op JmhTest.testStringBuilderAdd????????10??avgt????3????76.072?±????2.824??ns/op JmhTest.testStringBuilderAdd????????50??avgt????3???450.325?±???14.271??ns/op JmhTest.testStringBuilderAdd???????100??avgt????3???903.055?±??294.557??ns/op看到上述結果我們可能會很吃驚,我們知道StringBuffer要比StringBuilder的性能低一些,但結果發現它們的之間的差別并不是很大。這是因為JIT編譯器進行了優化,比如當JVM發現在測試當中StringBuffer并沒有發生逃逸,于是就進行了鎖消除操作。
常用注解
下面對JHM當中常用的注解進行說明,以便大家可以更精確的使用。
@BenchmarkMode
配置Mode選項,作用于類或者方法上,其value屬性為Mode數組,可同時支持多種Mode,如:@BenchmarkMode({Mode.SampleTime, Mode.AverageTime}),也可設為Mode.All,即全部執行一遍。
org.openjdk.jmh.annotations.Mode為枚舉類,對應的源代碼如下:
public?enum?Mode?{Throughput("thrpt",?"Throughput,?ops/time"),AverageTime("avgt",?"Average?time,?time/op"),SampleTime("sample",?"Sampling?time"),SingleShotTime("ss",?"Single?shot?invocation?time"),All("all",?"All?benchmark?modes");//?省略其他內容 }不同模式之間,測量的維度或測量的方式不同。目前JMH共有四種模式:
Throughput:整體吞吐量,例如“1秒內可以執行多少次調用”,單位為ops/time;
AverageTime:調用的平均時間,例如“每次調用平均耗時xxx毫秒”,單位為time/op;
SampleTime:隨機取樣,最后輸出取樣結果的分布,,例如“99%的調用在xxx毫秒以內,99.99%的調用在xxx毫秒以內”;
SingleShotTime:以上模式都是默認一次iteration是1s,只有SingleShotTime是只運行一次。往往同時把warmup次數設為0,用于測試冷啟動時的性能;
All:上面的所有模式都執行一次;
@Warmup
在執行@Benchmark之前進行預熱操作,確保測試的準確性,可用于類或者方法上。默認是每次運行1秒,運行10次。
其中@Warmup有以下屬性:
iterations:預熱的次數;Iteration是JMH進行測試的最小單位,在大部分模式下,一次iteration代表的是一秒,JMH會在這一秒內不斷調用需要benchmark的方法,然后根據模式對其采樣,計算吞吐量,計算平均執行時間等。
time:每次預熱的時間;
timeUnit:時間的單位,默認秒;
batchSize:批處理大小,每次操作調用幾次方法;
JIT在執行的過程中會將熱點代碼編譯為機器碼,并進行各種優化,從而提高執行效率。預熱的主要目的是讓JVM的JIT機制生效,讓結果更接近真實效果。
@State
類注解,JMH測試類必須使用@State注解,不然會提示無法運行。
State定義了一個類實例的生命周期(作用范圍),可以類比Spring Bean的Scope。因為很多benchmark會需要一些表示狀態的類,JMH會根據scope來進行實例化和共享操作。
@State可以被繼承使用,如果父類定義了該注解,子類則無需定義。
由于JMH允許多線程同時執行測試,不同的選項含義如下:
Scope.Thread:默認的State,該狀態為每個線程獨享,每個測試線程分配一個實例;
Scope.Benchmark:該狀態在所有線程間共享,所有測試線程共享一個實例,用于測試有狀態實例在多線程共享下的性能;
Scope.Group:該狀態為同一個組里面所有線程共享。
@OutputTimeUnit
benchmark統計結果所使用的時間單位,可用于類或者方法注解,使用java.util.concurrent.TimeUnit中的標準時間單位。
@Measurement
度量,其實就是實際調用方法所需要配置的一些基本測試參數,可用于類或者方法上。配置屬性項目和作用與@Warmup相同。
一般比較重的程序可以進行大量的測試,放到服務器上運行。在性能對比時,采用默認1秒即可,如果用jvisualvm做性能監控,可以指定一個較長時間運行。
@Threads
每個進程中同時起多少個線程執行,可用于類或者方法上。默認值是Runtime.getRuntime().availableProcessors(),根據具體情況選擇,一般為cpu乘以2。
@Fork
代表啟動多個單獨的進程分別測試每個方法,可用于類或者方法上。如果fork數是2的話,則JMH會fork出兩個進程來進行測試。
JVM因為使用了profile-guided optimization而“臭名昭著”,這對于微基準測試來說十分不友好,因為不同測試方法的profile混雜在一起,“互相傷害”彼此的測試結果。對于每個@Benchmark方法使用一個獨立的進程可以解決這個問題,這也是JMH的默認選項。注意不要設置為0,設置為n則會啟動n個進程執行測試(似乎也沒有太大意義)。fork選項也可以通過方法注解以及啟動參數來設置。
@Param
屬性級注解,指定某項參數的多種情況,特別適合用來測試一個函數在不同的參數輸入的情況下的性能,只能作用在字段上,使用該注解必須定義@State注解。
@Param注解接收一個String數組,在@Setup方法執行前轉化為對應的數據類型。多個@Param注解的成員之間是乘積關系,譬如有兩個用@Param注解的字段,第一個有5個值,第二個字段有2個值,那么每個測試方法會跑5*2=10次。
@Benchmark
方法注解,表示該方法是需要進行benchmark的對象,用法和JUnit的@Test類似。
@Setup
方法注解,這個注解的作用就是我們需要在測試之前進行一些準備工作,比如對一些數據的初始化之類的。
@TearDown
方法注解,與@Setup相對的,會在所有benchmark執行結束以后執行,比如關閉線程池,數據庫連接等的,主要用于資源的回收等。
Threads
每個fork進程使用多少個線程去執行測試方法,默認值是Runtime.getRuntime().availableProcessors()。
@Group
方法注解,可以把多個benchmark定義為同一個group,則它們會被同時執行,譬如用來模擬生產者-消費者讀寫速度不一致情況下的表現。
@Level
用于控制@Setup,@TearDown的調用時機,默認是Level.Trial。
Trial:每個benchmark方法前后;
Iteration:每個benchmark方法每次迭代前后;
Invocation:每個benchmark方法每次調用前后,謹慎使用,需留意javadoc注釋;
JMH注意事項
無用代碼消除(Dead Code Elimination)
現代編譯器是十分聰明的,它們會對代碼進行推導分析,判定哪些代碼是無用的然后進行去除,這種行為對微基準測試是致命的,它會使你無法準確測試出你的方法性能。
JMH本身已經對這種情況做了處理,要記住:1.永遠不要寫void方法;2.在方法結束返回計算結果。有時候如果需要返回多于一個結果,可以考慮自行合并計算結果,或者使用JMH提供的BlackHole對象:
/**?This?demonstrates?Option?A:**?Merge?multiple?results?into?one?and?return?it.*?This?is?OK?when?is?computation?is?relatively?heavyweight,?and?merging*?the?results?does?not?offset?the?results?much.*/ @Benchmark public?double?measureRight_1()?{return?Math.log(x1)?+?Math.log(x2); } /**?This?demonstrates?Option?B:**?Use?explicit?Blackhole?objects,?and?sink?the?values?there.*?(Background:?Blackhole?is?just?another?@State?object,?bundled?with?JMH).*/ @Benchmark public?void?measureRight_2(Blackhole?bh)?{bh.consume(Math.log(x1));bh.consume(Math.log(x2)); }再比如下面代碼:
@Benchmark public?void?testStringAdd(Blackhole?blackhole)?{String?a?=?"";for?(int?i?=?0;?i?<?length;?i++)?{a?+=?i;} }JVM可能會認為變量a從來沒有使用過,從而進行優化把整個方法內部代碼移除掉,這就會影響測試結果。
JMH提供了兩種方式避免這種問題,一種是將這個變量作為方法返回值return a,一種是通過Blackhole的consume來避免JIT 的優化消除。
常量折疊(Constant Folding)
常量折疊是一種現代編譯器優化策略,例如,i = 320 * 200 * 32,多數的現代編譯器不會真的產生兩個乘法的指令再將結果儲存下來,取而代之的,它們會辨識出語句的結構,并在編譯時期將數值計算出來(i = 2,048,000)。
在微基準測試中,如果你的計算輸入是可預測的,也不是一個@State實例變量,那么很可能會被JIT給優化掉。對此,JMH的建議是:1.永遠從@State實例中讀取你的方法輸入;2.返回你的計算結果;3.或者考慮使用BlackHole對象;
見如下官方例子:
@State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS) public?class?JMHSample_10_ConstantFold?{private?double?x?=?Math.PI;private?final?double?wrongX?=?Math.PI;@Benchmarkpublic?double?baseline()?{//?simply?return?the?value,?this?is?a?baselinereturn?Math.PI;}@Benchmarkpublic?double?measureWrong_1()?{//?This?is?wrong:?the?source?is?predictable,?and?computation?is?foldable.return?Math.log(Math.PI);}@Benchmarkpublic?double?measureWrong_2()?{//?This?is?wrong:?the?source?is?predictable,?and?computation?is?foldable.return?Math.log(wrongX);}@Benchmarkpublic?double?measureRight()?{//?This?is?correct:?the?source?is?not?predictable.return?Math.log(x);}public?static?void?main(String[]?args)?throws?RunnerException?{Options?opt?=?new?OptionsBuilder().include(JMHSample_10_ConstantFold.class.getSimpleName()).warmupIterations(5).measurementIterations(5).forks(1).build();new?Runner(opt).run();} }循環展開(Loop Unwinding)
循環展開最常用來降低循環開銷,為具有多個功能單元的處理器提供指令級并行。也有利于指令流水線的調度。例如:
for?(i?=?1;?i?<=?60;?i++)?a[i]?=?a[i]?*?b?+?c;可以展開成:
for?(i?=?1;?i?<=?60;?i+=3){a[i]?=?a[i]?*?b?+?c;a[i+1]?=?a[i+1]?*?b?+?c;a[i+2]?=?a[i+2]?*?b?+?c; }由于編譯器可能會對你的代碼進行循環展開,因此JMH建議不要在你的測試方法中寫任何循環。如果確實需要執行循環計算,可以結合@BenchmarkMode(Mode.SingleShotTime)和@Measurement(batchSize = N)來達到同樣的效果。參考如下例子:
/**?Suppose?we?want?to?measure?how?much?it?takes?to?sum?two?integers:*/ int?x?=?1; int?y?=?2; /**?This?is?what?you?do?with?JMH.*/ @Benchmark @OperationsPerInvocation(100) public?int?measureRight()?{return?(x?+?y); }JMH可視化
在示例的main方法中指定了生成測試結果的輸出文件result.json,其中的內容就是控制臺輸出的相關內容以json格式存儲。
針對json格式的內容,可以在其他網站上以圖表的形式可視化展示。
對應網站,JMH Visual Chart(http://deepoove.com/jmh-visual-chart/)、JMH Visualizer(https://jmh.morethan.io/)。
展示效果如下圖:
img生成jar包執行
對于大型的測試,一般會放在Linux服務器里去執行。JMH官方提供了生成jar包的方式來執行,在maven里增加如下插件:
<plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-shade-plugin</artifactId><version>2.4.1</version><executions><execution><phase>package</phase><goals><goal>shade</goal></goals><configuration><finalName>jmh-demo</finalName><transformers><transformerimplementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"><mainClass>org.openjdk.jmh.Main</mainClass></transformer></transformers></configuration></execution></executions></plugin> </plugins>執行maven的命令生成可執行jar包,并執行:
mvn?clean?package java?-jar?target/jmh-demo.jar?JmhTest總結
一篇文章幾乎涵蓋了JMH各方面的知識點,如果實踐中還沒運用,趕緊用起來吧,你的專業水平將又提升那么一點。當然,也可以收藏起來,以備不時不需。
參考文章:
https://www.zhihu.com/question/276455629/answer/1259967560 https://www.cnblogs.com/silyvin/p/11736696.html https://blog.csdn.net/wangxuelei036/article/details/105240522 https://www.cnblogs.com/xiang--liu/p/9710143.html
往期推薦6種快速統計代碼執行時間的方法,真香!(史上最全)
Oracle官方推薦的性能測試工具!簡單、精準又直觀!
鏈表竟然比數組慢了1000多倍?(動圖+性能評測)
關注我,每天陪你進步一點點!
總結
以上是生活随笔為你收集整理的不要再用main方法测试代码性能了,用这款JDK自带工具的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 驳《阿里「Java开发手册」中的1个bu
- 下一篇: 重磅!阿里推出国产开源JDK!