性能調(diào)優(yōu)必備利器之JMH
if 快還是 switch 快?HashMap 的初始化 size 要不要指定,指定之后性能可以提高多少?各種序列化方法哪個(gè)耗時(shí)更短?
無(wú)論出自何種原因需要進(jìn)行性能評(píng)估,量化指標(biāo)總是必要的。
在大部分場(chǎng)合,簡(jiǎn)單地回答誰(shuí)快誰(shuí)慢是遠(yuǎn)遠(yuǎn)不夠的,如何將程序性能量化呢?
這就需要我們的主角 JMH 登場(chǎng)了!
JMH 簡(jiǎn)介
JMH(Java Microbenchmark Harness)是用于代碼微基準(zhǔn)測(cè)試的工具套件,主要是基于方法層面的基準(zhǔn)測(cè)試,精度可以達(dá)到納秒級(jí)。該工具是由 Oracle 內(nèi)部實(shí)現(xiàn) JIT 的大牛們編寫(xiě)的,他們應(yīng)該比任何人都了解 JIT 以及 JVM 對(duì)于基準(zhǔn)測(cè)試的影響。
當(dāng)你定位到熱點(diǎn)方法,希望進(jìn)一步優(yōu)化方法性能的時(shí)候,就可以使用 JMH 對(duì)優(yōu)化的結(jié)果進(jìn)行量化的分析。
JMH 比較典型的應(yīng)用場(chǎng)景如下:
- 想準(zhǔn)確地知道某個(gè)方法需要執(zhí)行多長(zhǎng)時(shí)間,以及執(zhí)行時(shí)間和輸入之間的相關(guān)性
- 對(duì)比接口不同實(shí)現(xiàn)在給定條件下的吞吐量
- 查看多少百分比的請(qǐng)求在多長(zhǎng)時(shí)間內(nèi)完成
下面我們以字符串拼接的兩種方法為例子使用 JMH 做基準(zhǔn)測(cè)試。
加入依賴
因?yàn)?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>
編寫(xiě)基準(zhǔn)測(cè)試
接下來(lái),創(chuàng)建一個(gè) JMH 測(cè)試類,用來(lái)判斷 + 和 StringBuilder.append() 兩種字符串拼接哪個(gè)耗時(shí)更短,具體代碼如下所示:
- @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();
- }
- }
其中需要測(cè)試的方法用 @Benchmark 注解標(biāo)識(shí),這些注解的具體含義將在下面介紹。
在 main() 函數(shù)中,首先對(duì)測(cè)試用例進(jìn)行配置,使用 Builder 模式配置測(cè)試,將配置參數(shù)存入 Options 對(duì)象,并使用 Options 對(duì)象構(gòu)造 Runner 啟動(dòng)測(cè)試。
執(zhí)行基準(zhǔn)測(cè)試
準(zhǔn)備工作做好了,接下來(lái),運(yùn)行代碼,等待片刻,測(cè)試結(jié)果就出來(lái)了,下面對(duì)結(jié)果做下簡(jiǎn)單說(shuō)明:
- # 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)
該部分為測(cè)試的基本信息,比如使用的 Java 路徑,預(yù)熱代碼的迭代次數(shù),測(cè)量代碼的迭代次數(shù),使用的線程數(shù)量,測(cè)試的統(tǒng)計(jì)單位等。
- # 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
該部分為每一次熱身中的性能指標(biāo),預(yù)熱測(cè)試不會(huì)作為最終的統(tǒng)計(jì)結(jié)果。預(yù)熱的目的是讓 JVM 對(duì)被測(cè)代碼進(jìn)行足夠多的優(yōu)化,比如,在預(yù)熱后,被測(cè)代碼應(yīng)該得到了充分的 JIT 編譯和優(yōu)化。
- 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
該部分顯示測(cè)量迭代的情況,每一次迭代都顯示了當(dāng)前的執(zhí)行速率,即一個(gè)操作所花費(fèi)的時(shí)間。在進(jìn)行 5 次迭代后,進(jìn)行統(tǒng)計(jì),在本例中,length 為 100 的情況下 testStringBuilderAdd 方法的平均執(zhí)行花費(fèi)時(shí)間為 819.329 ns,誤差為 72.698 ns。
最后的測(cè)試結(jié)果如下所示:
- 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
結(jié)果表明,在拼接字符次數(shù)越多的情況下,StringBuilder.append() 的性能就更好。
生成 jar 包執(zhí)行
對(duì)于一些小測(cè)試,直接用上面的方式寫(xiě)一個(gè) main 函數(shù)手動(dòng)執(zhí)行就好了。
對(duì)于大型的測(cè)試,需要測(cè)試的時(shí)間比較久、線程數(shù)比較多,加上測(cè)試的服務(wù)器需要,一般要放在 Linux 服務(wù)器里去執(zhí)行。
JMH 官方提供了生成 jar 包的方式來(lái)執(zhí)行,我們需要在 maven 里增加一個(gè) 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>
接著執(zhí)行 maven 的命令生成可執(zhí)行 jar 包并執(zhí)行:
- mvn clean install
- java -jar target/jmh-demo.jar StringConnectTest
JMH 基礎(chǔ)
為了能夠更好地使用 JMH 的各項(xiàng)功能,下面對(duì) JMH 的基本概念進(jìn)行講解:
@BenchmarkMode
用來(lái)配置 Mode 選項(xiàng),可用于類或者方法上,這個(gè)注解的 value 是一個(gè)數(shù)組,可以把幾種 Mode 集合在一起執(zhí)行,如:@BenchmarkMode({Mode.SampleTime, Mode.AverageTime}),還可以設(shè)置為 Mode.All,即全部執(zhí)行一遍。
- Throughput:整體吞吐量,每秒執(zhí)行了多少次調(diào)用,單位為 ops/time
- AverageTime:用的平均時(shí)間,每次操作的平均時(shí)間,單位為 time/op
- SampleTime:隨機(jī)取樣,最后輸出取樣結(jié)果的分布
- SingleShotTime:只運(yùn)行一次,往往同時(shí)把 Warmup 次數(shù)設(shè)為 0,用于測(cè)試?yán)鋯?dòng)時(shí)的性能
- All:上面的所有模式都執(zhí)行一次
@State
通過(guò) State 可以指定一個(gè)對(duì)象的作用范圍,JMH 根據(jù) scope 來(lái)進(jìn)行實(shí)例化和共享操作。@State 可以被繼承使用,如果父類定義了該注解,子類則無(wú)需定義。由于 JMH 允許多線程同時(shí)執(zhí)行測(cè)試,不同的選項(xiàng)含義如下:
- Scope.Benchmark:所有測(cè)試線程共享一個(gè)實(shí)例,測(cè)試有狀態(tài)實(shí)例在多線程共享下的性能
- Scope.Group:同一個(gè)線程在同一個(gè) group 里共享實(shí)例
- Scope.Thread:默認(rèn)的 State,每個(gè)測(cè)試線程分配一個(gè)實(shí)例
@OutputTimeUnit
為統(tǒng)計(jì)結(jié)果的時(shí)間單位,可用于類或者方法注解
@Warmup
預(yù)熱所需要配置的一些基本測(cè)試參數(shù),可用于類或者方法上。一般前幾次進(jìn)行程序測(cè)試的時(shí)候都會(huì)比較慢,所以要讓程序進(jìn)行幾輪預(yù)熱,保證測(cè)試的準(zhǔn)確性。參數(shù)如下所示:
- iterations:預(yù)熱的次數(shù)
- time:每次預(yù)熱的時(shí)間
- timeUnit:時(shí)間的單位,默認(rèn)秒
- batchSize:批處理大小,每次操作調(diào)用幾次方法
為什么需要預(yù)熱?
因?yàn)?JVM 的 JIT 機(jī)制的存在,如果某個(gè)函數(shù)被調(diào)用多次之后,JVM 會(huì)嘗試將其編譯為機(jī)器碼,從而提高執(zhí)行速度,所以為了讓 benchmark 的結(jié)果更加接近真實(shí)情況就需要進(jìn)行預(yù)熱。
@Measurement
實(shí)際調(diào)用方法所需要配置的一些基本測(cè)試參數(shù),可用于類或者方法上,參數(shù)和 @Warmup 相同。
@Threads
每個(gè)進(jìn)程中的測(cè)試線程,可用于類或者方法上。
@Fork
進(jìn)行 fork 的次數(shù),可用于類或者方法上。如果 fork 數(shù)是 2 的話,則 JMH 會(huì) fork 出兩個(gè)進(jìn)程來(lái)進(jìn)行測(cè)試。
@Param
指定某項(xiàng)參數(shù)的多種情況,特別適合用來(lái)測(cè)試一個(gè)函數(shù)在不同的參數(shù)輸入的情況下的性能,只能作用在字段上,使用該注解必須定義 @State 注解。
在介紹完常用的注解后,讓我們來(lái)看下 JMH 有哪些陷阱。
JMH 陷阱
在使用 JMH 的過(guò)程中,一定要避免一些陷阱。
比如 JIT 優(yōu)化中的死碼消除,比如以下代碼:
- @Benchmark
- public void testStringAdd(Blackhole blackhole) {
- String a = "";
- for (int i = 0; i < length; i++) {
- a += i;
- }
- }
JVM 可能會(huì)認(rèn)為變量 a 從來(lái)沒(méi)有使用過(guò),從而進(jìn)行優(yōu)化把整個(gè)方法內(nèi)部代碼移除掉,這就會(huì)影響測(cè)試結(jié)果。
JMH 提供了兩種方式避免這種問(wèn)題,一種是將這個(gè)變量作為方法返回值 return a,一種是通過(guò) Blackhole 的 consume 來(lái)避免 JIT 的優(yōu)化消除。
其他陷阱還有常量折疊與常量傳播、永遠(yuǎn)不要在測(cè)試中寫(xiě)循環(huán)、使用 Fork 隔離多個(gè)測(cè)試方法、方法內(nèi)聯(lián)、偽共享與緩存行、分支預(yù)測(cè)、多線程測(cè)試等,感興趣的可以閱讀 https://github.com/lexburner/JMH-samples 了解全部的陷阱。
JMH 插件
大家還可以通過(guò) IDEA 安裝 JMH 插件使 JMH 更容易實(shí)現(xiàn)基準(zhǔn)測(cè)試,在 IDEA 中點(diǎn)擊 File->Settings...->Plugins,然后搜索 jmh,選擇安裝 JMH plugin:
JMH plugin
這個(gè)插件可以讓我們能夠以 JUnit 相同的方式使用 JMH,主要功能如下:
- 自動(dòng)生成帶有 @Benchmark 的方法
- 像 JUnit 一樣,運(yùn)行單獨(dú)的 Benchmark 方法
- 運(yùn)行類中所有的 Benchmark 方法
比如可以通過(guò)右鍵點(diǎn)擊 Generate...,選擇操作 Generate JMH benchmark 就可以生成一個(gè)帶有 @Benchmark 的方法。
還有將光標(biāo)移動(dòng)到方法聲明并調(diào)用 Run 操作就運(yùn)行一個(gè)單獨(dú)的 Benchmark 方法。
將光標(biāo)移到類名所在行,右鍵點(diǎn)擊 Run 運(yùn)行,該類下的所有被 @Benchmark 注解的方法都會(huì)被執(zhí)行。
JMH 可視化
除此以外,如果你想將測(cè)試結(jié)果以圖表的形式可視化,可以試下這些網(wǎng)站:
- JMH Visual Chart:http://deepoove.com/jmh-visual-chart/
- JMH Visualizer:https://jmh.morethan.io/
比如將上面測(cè)試?yán)咏Y(jié)果的 json 文件導(dǎo)入,就可以實(shí)現(xiàn)可視化:
總結(jié)
本文主要介紹了性能基準(zhǔn)測(cè)試工具 JMH,它可以通過(guò)一些功能來(lái)規(guī)避由 JVM 中的 JIT 或者其他優(yōu)化對(duì)性能測(cè)試造成的影響。
只需要將待測(cè)的業(yè)務(wù)邏輯用 @Benchmark 注解標(biāo)識(shí),就可以讓 JMH 的注解處理器自動(dòng)生成真正的性能測(cè)試代碼,以及相應(yīng)的性能測(cè)試配置文件。