JMH 使用指南
簡介
JMH(Java Microbenchmark Harness)是用於程式碼微基準測試的工具套件,主要是基於方法層面的基準測試,精度可以達到納秒級。該工具是由 Oracle 內部實現 JIT 的大牛們編寫的,他們應該比任何人都了解 JIT 以及 JVM 對於基準測試的影響。JMH不止能對Java語言做基準測試,還能對運行在JVM上的其他語言做基準測試。
當你定位到熱點方法,希望進一步優化方法性能的時候,就可以使用 JMH 對優化的結果進行量化的分析。
JMH 比較典型的應用場景如下:
- 想準確地知道某個方法需要執行多長時間,以及執行時間和輸入之間的相關性
- 對比介面不同實現在給定條件下的吞吐量
- 查看多少百分比的請求在多長時間內完成
下面我們以字元串拼接的兩種方法為例子使用 JMH 做基準測試。
快速入門
添加依賴
JMH 是 JDK9 自帶的,如果是 JDK9 之前的版本需要加入如下依賴(目前 JMH 的最新版本為 1.23
):
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.23</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.23</version>
</dependency>
編寫測試程式碼
接下來,創建一個 JMH 測試類,用來判斷 +
和 StringBuilder.append()
兩種字元串拼接哪個耗時更短,具體程式碼如下所示:
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 5)
@Threads(4)
@Fork(1)
@State(value = Scope.Benchmark)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class StringConnectTest {
@Param(value = {"10", "50", "100"})
private int length;
@Benchmark
public void testStringAdd(Blackhole blackhole) {
String a = "";
for (int i = 0; i < length; i++) {
a += i;
}
blackhole.consume(a);
}
@Benchmark
public void testStringBuilderAdd(Blackhole blackhole) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(i);
}
blackhole.consume(sb.toString());
}
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(StringConnectTest.class.getSimpleName())
.result("result.json")
.resultFormat(ResultFormatType.JSON).build();
new Runner(opt).run();
}
}
其中需要測試的方法用 @Benchmark
註解標識,這些註解的具體含義將在下面介紹。
在 main() 函數中,首先對測試用例進行配置,使用 Builder 模式配置測試,將配置參數存入 Options 對象,並使用 Options 對象構造 Runner 啟動測試。
另外大家可以看下官方提供的 jmh 示例 demo://hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples/
使用Main方法執行測試
準備工作做好了,接下來,運行程式碼,等待片刻,測試結果就出來了,下面對結果做下簡單說明:
# JMH version: 1.23
# VM version: JDK 1.8.0_201, Java HotSpot(TM) 64-Bit Server VM, 25.201-b09
# VM invoker: D:\Software\Java\jdk1.8.0_201\jre\bin\java.exe
# VM options: -javaagent:D:\Software\JetBrains\IntelliJ IDEA 2019.1.3\lib\idea_rt.jar=61018:D:\Software\JetBrains\IntelliJ IDEA 2019.1.3\bin -Dfile.encoding=UTF-8
# Warmup: 3 iterations, 1 s each
# Measurement: 5 iterations, 5 s each
# Timeout: 10 min per iteration
# Threads: 4 threads, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: com.wupx.jmh.StringConnectTest.testStringBuilderAdd
# Parameters: (length = 100)
該部分為測試的基本資訊,比如使用的 Java 路徑,預熱程式碼的迭代次數,測量程式碼的迭代次數,使用的執行緒數量,測試的統計單位等。
# Warmup Iteration 1: 1083.569 ±(99.9%) 393.884 ns/op
# Warmup Iteration 2: 864.685 ±(99.9%) 174.120 ns/op
# Warmup Iteration 3: 798.310 ±(99.9%) 121.161 ns/op
該部分為每一次熱身中的性能指標,預熱測試不會作為最終的統計結果。預熱的目的是讓 JVM 對被測程式碼進行足夠多的優化,比如,在預熱後,被測程式碼應該得到了充分的 JIT 編譯和優化。
Iteration 1: 810.667 ±(99.9%) 51.505 ns/op
Iteration 2: 807.861 ±(99.9%) 13.163 ns/op
Iteration 3: 851.421 ±(99.9%) 33.564 ns/op
Iteration 4: 805.675 ±(99.9%) 33.038 ns/op
Iteration 5: 821.020 ±(99.9%) 66.943 ns/op
Result "com.wupx.jmh.StringConnectTest.testStringBuilderAdd":
819.329 ±(99.9%) 72.698 ns/op [Average]
(min, avg, max) = (805.675, 819.329, 851.421), stdev = 18.879
CI (99.9%): [746.631, 892.027] (assumes normal distribution)
Benchmark (length) Mode Cnt Score Error Units
StringConnectTest.testStringBuilderAdd 100 avgt 5 819.329 ± 72.698 ns/op
該部分顯示測量迭代的情況,每一次迭代都顯示了當前的執行速率,即一個操作所花費的時間。在進行 5 次迭代後,進行統計,在本例中,length 為 100 的情況下 testStringBuilderAdd
方法的平均執行花費時間為 819.329 ns
,誤差為 72.698 ns
。
最後的測試結果如下所示:
Benchmark (length) Mode Cnt Score Error Units
StringConnectTest.testStringAdd 10 avgt 5 161.496 ± 17.097 ns/op
StringConnectTest.testStringAdd 50 avgt 5 1854.657 ± 227.902 ns/op
StringConnectTest.testStringAdd 100 avgt 5 6490.062 ± 327.626 ns/op
StringConnectTest.testStringBuilderAdd 10 avgt 5 68.769 ± 4.460 ns/op
StringConnectTest.testStringBuilderAdd 50 avgt 5 413.021 ± 30.950 ns/op
StringConnectTest.testStringBuilderAdd 100 avgt 5 819.329 ± 72.698 ns/op
結果表明,在拼接字元次數越多的情況下,StringBuilder.append()
的性能就更好。
使用IDEA插件執行測試
大家還可以通過 IDEA 安裝 JMH 插件使 JMH 更容易實現基準測試,在 IDEA 中點擊 File->Settings...->Plugins
,然後搜索 jmh,選擇安裝 JMH plugin:
這個插件可以讓我們能夠以 JUnit 相同的方式使用 JMH,主要功能如下:
- 自動生成帶有
@Benchmark
的方法 - 像 JUnit 一樣,運行單獨的 Benchmark 方法
- 運行類中所有的 Benchmark 方法
比如可以通過右鍵點擊 Generate...
,選擇操作 Generate JMH benchmark
就可以生成一個帶有 @Benchmark
的方法。
還有將游標移動到方法聲明並調用 Run 操作就運行一個單獨的 Benchmark 方法。
將游標移到類名所在行,右鍵點擊 Run 運行,該類下的所有被 @Benchmark
註解的方法都會被執行。
打成Jar包進行測試
對於一些小測試,直接用上面的方式寫一個 main 函數手動執行就好了。
對於大型的測試,需要測試的時間比較久、執行緒數比較多,加上測試的伺服器需要,一般要放在 Linux 伺服器里去執行。
JMH 官方提供了生成 jar 包的方式來執行,我們需要在 maven 里增加一個 plugin,具體配置如下:
<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 install
java -jar target/jmh-demo.jar StringConnectTest
JMH 基礎
為了能夠更好地使用 JMH 的各項功能,下面對 JMH 的基本概念進行講解:
@BenchmarkMode
用來配置 Mode 選項,可用於類或者方法上,這個註解的 value 是一個數組,可以把幾種 Mode 集合在一起執行,如:@BenchmarkMode({Mode.SampleTime, Mode.AverageTime})
,還可以設置為 Mode.All
,即全部執行一遍。
- Throughput:整體吞吐量,每秒執行了多少次調用,單位為
ops/time
- AverageTime:用的平均時間,每次操作的平均時間,單位為
time/op
- SampleTime:隨機取樣,最後輸出取樣結果的分布
- SingleShotTime:只運行一次,往往同時把 Warmup 次數設為 0,用於測試冷啟動時的性能
- All:上面的所有模式都執行一次
@State
通過 State 可以指定一個對象的作用範圍,JMH 根據 scope 來進行實例化和共享操作。@State 可以被繼承使用,如果父類定義了該註解,子類則無需定義。由於 JMH 允許多執行緒同時執行測試,不同的選項含義如下:
- Scope.Benchmark:所有測試執行緒共享一個實例,測試有狀態實例在多執行緒共享下的性能
- Scope.Group:同一個執行緒在同一個 group 里共享實例
- Scope.Thread:默認的 State,每個測試執行緒分配一個實例
@OutputTimeUnit
為統計結果的時間單位,可用於類或者方法註解
@Warmup
預熱所需要配置的一些基本測試參數,可用於類或者方法上。一般前幾次進行程式測試的時候都會比較慢,所以要讓程式進行幾輪預熱,保證測試的準確性。參數如下所示:
- iterations:預熱的次數
- time:每次預熱的時間
- timeUnit:時間的單位,默認秒
- batchSize:批處理大小,每次操作調用幾次方法
為什麼需要預熱?
因為 JVM 的 JIT 機制的存在,如果某個函數被調用多次之後,JVM 會嘗試將其編譯為機器碼,從而提高執行速度,所以為了讓 benchmark 的結果更加接近真實情況就需要進行預熱。
@Measurement
實際調用方法所需要配置的一些基本測試參數,可用於類或者方法上,參數和 @Warmup
相同。
@Threads
每個進程中的測試執行緒,可用於類或者方法上。
@Fork
進行 fork 的次數,可用於類或者方法上。如果 fork 數是 2 的話,則 JMH 會 fork 出兩個進程來進行測試。
@Param
指定某項參數的多種情況,特別適合用來測試一個函數在不同的參數輸入的情況下的性能,只能作用在欄位上,使用該註解必須定義 @State 註解。
在介紹完常用的註解後,讓我們來看下 JMH 有哪些陷阱。
JMH 陷阱
在使用 JMH 的過程中,一定要避免一些陷阱。
比如 JIT 優化中的死碼消除,比如以下程式碼:
@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 的優化消除。
其他陷阱還有常量摺疊與常量傳播、永遠不要在測試中寫循環、使用 Fork 隔離多個測試方法、方法內聯、偽共享與快取行、分支預測、多執行緒測試等,感興趣的可以閱讀 //github.com/lexburner/JMH-samples
了解全部的陷阱。
JMH 可視化
除此以外,如果你想將測試結果以圖表的形式可視化,可以試下這些網站:
JMH Visual Chart
://deepoove.com/jmh-visual-chart/JMH Visualizer
://jmh.morethan.io/
比如將上面測試例子結果的 json 文件導入,就可以實現可視化:
總結
本文主要介紹了性能基準測試工具 JMH,它可以通過一些功能來規避由 JVM 中的 JIT 或者其他優化對性能測試造成的影響。
只需要將待測的業務邏輯用 @Benchmark
註解標識,就可以讓 JMH 的註解處理器自動生成真正的性能測試程式碼,以及相應的性能測試配置文件。