作為軟體開發人員,我們通常會寫一些測試程式用來對比不同演算法、不同工具的效能問題。而最常見的做法是寫一個 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(); } @Benchmark public void testStringBufferAdd(Blackhole blackhole) { StringBuffer sb = new StringBuffer(); for (int i = 0; i < length; i++) { sb.append(i); } blackhole.consume(sb.toString()); } @Benchmark public 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/opIteration 2: 897.729 ns/opIteration 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.146 CI (99.9%): [608.498, 1197.612] (assumes normal distribution)複製程式碼
顯示每次(共 3 次)迭代執行速率,最後進行統計。這裡是對 testStringBuilderAdd 方法執行 length 為 100 的測試,透過 (min, avg, max) 三項可以看出最小時間、平均時間、最大時間的值,單位為 ns。stdev 顯示的是誤差時間。
通常情況下,我們只用看最後的結果即可:
Benchmark (length) Mode Cnt Score Error UnitsJmhTest.testStringBufferAdd 10 avgt 3 92.599 ± 105.019 ns/opJmhTest.testStringBufferAdd 50 avgt 3 582.974 ± 580.536 ns/opJmhTest.testStringBufferAdd 100 avgt 3 1131.460 ± 1109.380 ns/opJmhTest.testStringBuilderAdd 10 avgt 3 76.072 ± 2.824 ns/opJmhTest.testStringBuilderAdd 50 avgt 3 450.325 ± 14.271 ns/opJmhTest.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:該狀態為同一個組裡面所有執行緒共享。@OutputTimeUnitbenchmark 統計結果所使用的時間單位,可用於類或者方法註解,使用 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. */@Benchmarkpublic 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). */@Benchmarkpublic void measureRight_2(Blackhole bh) { bh.consume(Math.log(x1)); bh.consume(Math.log(x2));}複製程式碼
再比如下面程式碼:
@Benchmarkpublic 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; @Benchmark public double baseline() { // simply return the value, this is a baseline return Math.PI; } @Benchmark public double measureWrong_1() { // This is wrong: the source is predictable, and computation is foldable. return Math.log(Math.PI); } @Benchmark public double measureWrong_2() { // This is wrong: the source is predictable, and computation is foldable. return Math.log(wrongX); } @Benchmark public 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/)。
展示效果如下圖:
生成 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> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>org.openjdk.jmh.Main</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin></plugins>複製程式碼
執行 maven 的命令生成可執行 jar 包,並執行:
mvn clean packagejava -jar target/jmh-demo.jar JmhTest複製程式碼
總結
一篇文章幾乎涵蓋了 JMH 各方面的知識點,如果實踐中還沒運用,趕緊用起來吧,你的專業水平將又提升那麼一點。當然,也可以收藏起來,以備不時不需。