JMH - Java 代碼性能測試的終極利器、必須掌握
歡迎點(diǎn)擊?“未讀代碼” ,關(guān)注公眾號(hào),文章每周更新
Java 性能測試難題
現(xiàn)在的 JVM 已經(jīng)越來越為智能,它可以在編譯階段、加載階段、運(yùn)行階段對代碼進(jìn)行優(yōu)化。比如你寫了一段不怎么聰明的代碼,到了 JVM 這里,它發(fā)現(xiàn)幾處可以優(yōu)化的地方,就順手幫你優(yōu)化了一把。這對程序的運(yùn)行固然美妙,卻讓開發(fā)者不能準(zhǔn)確了解程序的運(yùn)行情況。在需要進(jìn)行性能測試時(shí),如果不知道 JVM 優(yōu)化細(xì)節(jié),可能會(huì)導(dǎo)致你的測試結(jié)果差之毫厘,失之千里,同樣的,Java 誕生之初就有一次編譯、隨處運(yùn)行的口號(hào),JVM 提供了底層支持,也提供了內(nèi)存管理機(jī)制,這些機(jī)制都會(huì)對我們的性能測試結(jié)果造成不可預(yù)測的影響。
long start = System.currentTimeMillis();// ....long end = System.currentTimeMillis();System.out.println(end - start);
上面可能就是你最常見的性能測試了,這樣的測試結(jié)果真的準(zhǔn)確嗎?答案是否定的,它有下面幾個(gè)問題。
時(shí)間精度問題,本身獲取到的時(shí)間戳就是存在誤差的,它和操作系統(tǒng)有關(guān)。 JVM 在運(yùn)行時(shí)會(huì)進(jìn)行代碼預(yù)熱,說白了就是越跑越快。因?yàn)轭愋枰b載、需要準(zhǔn)備操作。 JVM 會(huì)在各個(gè)階段都有可能對你的代碼進(jìn)行優(yōu)化處理。 資源回收的不確定性,可能運(yùn)行很快,回收很慢。
帶著這些問題,突然發(fā)現(xiàn)進(jìn)行一次嚴(yán)格的基準(zhǔn)測試的難度大大增加。那么如何才能進(jìn)行一次嚴(yán)格的基準(zhǔn)測試呢?
JMH 介紹
那么如何對 Java 程序進(jìn)行一次精準(zhǔn)的性能測試呢?難道需要掌握很多 JVM 優(yōu)化細(xì)節(jié)嗎?難道要研究如何避免,并進(jìn)行正確編碼才能進(jìn)行嚴(yán)格的性能測試嗎?顯然不是,如果是這樣的話,未免過于困難了,好在有一款一款官方的微基準(zhǔn)測試工具 - JMH.
JMH 的全名是 Java Microbenchmark Harness,它是由 Java 虛擬機(jī)團(tuán)隊(duì)開發(fā)的一款用于 Java 微基準(zhǔn)測試工具。用自己開發(fā)的工具測試自己開發(fā)的另一款工具,以子之矛,攻子之盾果真手到擒來,如臂使指。使用 JMH 可以讓你方便快速的進(jìn)行一次嚴(yán)格的代碼基準(zhǔn)測試,并且有多種測試模式,多種測試維度可供選擇;而且使用簡單、增加注解便可啟動(dòng)測試。
JMH 使用
JMH 的使用首先引入 maven 所需依賴,當(dāng)前最新版 為 1.23 版本。
org.openjdk.jmh jmh-core 1.23 org.openjdk.jmh jmh-generator-annprocess 1.23 provided
快速測試
下面使用注解的方式指定測試參數(shù),通過一個(gè)例子展示 JMH 基準(zhǔn)測試的具體用法,先看一次運(yùn)行效果,然后再了解每個(gè)注解的具體含義。
這個(gè)例子是使用 JMH 測試,使用加號(hào)拼接字符串和使用 StringBuilder 的 append 方法拼接字符串時(shí)的速度如何,每次拼接1000個(gè)數(shù)字進(jìn)行平均速度比較。
import java.util.concurrent.TimeUnit;import org.openjdk.jmh.annotations.*;import org.openjdk.jmh.runner.Runner;import org.openjdk.jmh.runner.RunnerException;import org.openjdk.jmh.runner.options.Options;import org.openjdk.jmh.runner.options.OptionsBuilder;/**** JMH 基準(zhǔn)測試入門** @author niujinpeng* @Date 2020/8/21 1:13*/@BenchmarkMode(Mode.AverageTime)@State(Scope.Thread)@Fork(1)@OutputTimeUnit(TimeUnit.MILLISECONDS)@Warmup(iterations = 3)@Measurement(iterations = 5)public class JmhHello {String string = "";StringBuilder stringBuilder = new StringBuilder();@Benchmarkpublic String stringAdd() {for (int i = 0; i < 1000; i++) {string = string + i;}return string;}@Benchmarkpublic String stringBuilderAppend() {for (int i = 0; i < 1000; i++) {stringBuilder.append(i);}return stringBuilder.toString();}public static void main(String[] args) throws RunnerException {Options opt = new OptionsBuilder().include(JmhHello.class.getSimpleName()).build();new Runner(opt).run();}}
代碼很簡單,不做解釋,stringAdd 使用加號(hào)拼接字符串 1000次,stringBuilderAppend 使用 append 拼接字符串 1000次。直接運(yùn)行 main 方法,稍等片刻后可以得到詳細(xì)的運(yùn)行輸出結(jié)果。
// 開始測試 stringAdd 方法# JMH version: 1.23# VM version: JDK 1.8.0_181, Java HotSpot(TM) 64-Bit Server VM, 25.181-b13# VM invoker: D:\develop\Java\jdk8_181\jre\bin\java.exe# VM options: -javaagent:C:\ideaIU-2020.1.3.win\lib\idea_rt.jar=50363:C:\ideaIU-2020.1.3.win\bin -Dfile.encoding=UTF-8# Warmup: 3 iterations, 10 s each // 預(yù)熱運(yùn)行三次# Measurement: 5 iterations, 10 s each // 性能測試5次# Timeout: 10 min per iteration // 超時(shí)時(shí)間10分鐘# Threads: 1 thread, will synchronize iterations // 線程數(shù)量為1# Benchmark mode: Average time, time/op // 統(tǒng)計(jì)方法調(diào)用一次的平均時(shí)間# Benchmark: net.codingme.jmh.JmhHello.stringAdd // 本次執(zhí)行的方法# Run progress: 0.00% complete, ETA 00:02:40# Fork: 1 of 1# Warmup Iteration 1: 95.153 ms/op // 第一次預(yù)熱,耗時(shí)95ms# Warmup Iteration 2: 108.927 ms/op // 第二次預(yù)熱,耗時(shí)108ms# Warmup Iteration 3: 167.760 ms/op // 第三次預(yù)熱,耗時(shí)167msIteration 1: 198.897 ms/op // 執(zhí)行五次耗時(shí)度量Iteration 2: 243.437 ms/opIteration 3: 271.171 ms/opIteration 4: 295.636 ms/opIteration 5: 327.822 ms/opResult "net.codingme.jmh.JmhHello.stringAdd":267.393 ±(99.9%) 189.907 ms/op [Average](min, avg, max) = (198.897, 267.393, 327.822), stdev = 49.318 // 執(zhí)行的最小、平均、最大、誤差值CI (99.9%): [77.486, 457.299] (assumes normal distribution)// 開始測試 stringBuilderAppend 方法# Benchmark: net.codingme.jmh.JmhHello.stringBuilderAppend# Run progress: 50.00% complete, ETA 00:01:21# Fork: 1 of 1# Warmup Iteration 1: 1.872 ms/op# Warmup Iteration 2: 4.491 ms/op# Warmup Iteration 3: 5.866 ms/opIteration 1: 6.936 ms/opIteration 2: 8.465 ms/opIteration 3: 8.925 ms/opIteration 4: 9.766 ms/opIteration 5: 10.143 ms/opResult "net.codingme.jmh.JmhHello.stringBuilderAppend":8.847 ±(99.9%) 4.844 ms/op [Average](min, avg, max) = (6.936, 8.847, 10.143), stdev = 1.258CI (99.9%): [4.003, 13.691] (assumes normal distribution)# Run complete. Total time: 00:02:42REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up onwhy the numbers are the way they are. Use profilers (see -prof, -lprof), design factorialexperiments, perform baseline and negative tests that provide experimental control, make surethe benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.Do not assume the numbers tell you what you want them to tell.// 測試結(jié)果對比Benchmark Mode Cnt Score Error UnitsJmhHello.stringAdd avgt 5 267.393 ± 189.907 ms/opJmhHello.stringBuilderAppend avgt 5 8.847 ± 4.844 ms/opProcess finished with exit code 0
上面日志里的 // 注釋是我手動(dòng)增加上去的,其實(shí)我們只需要看下面的最終結(jié)果就可以了,可以看到 stringAdd 方法平均耗時(shí) 267.393ms,而 ?stringBuilderAppend 方法平均耗時(shí)只有 8.847ms,可見 StringBuilder 的 append 方法進(jìn)行字符串拼接速度快的多,這也是我們推薦使用append 進(jìn)行字符串拼接的原因。
注解說明
經(jīng)過上面的示例,想必你也可以快速的使用 JMH 進(jìn)行基準(zhǔn)測試了,不過上面的諸多注解你可能還有疑惑,下面一一介紹。
類上使用了六個(gè)注解。
@BenchmarkMode(Mode.AverageTime)@State(Scope.Thread)@Fork(1)@OutputTimeUnit(TimeUnit.MILLISECONDS)@Warmup(iterations = 3)@Measurement(iterations = 5)
@BenchmarkMode(Mode.AverageTime) 表示統(tǒng)計(jì)平均響應(yīng)時(shí)間,不僅可以用在類上,也可用在測試方法上。
除此之外還可以取值:
Throughput:統(tǒng)計(jì)單位時(shí)間內(nèi)可以對方法測試多少次。 SampleTime:統(tǒng)計(jì)每個(gè)響應(yīng)時(shí)間范圍內(nèi)的響應(yīng)次數(shù),比如 0-1ms,3次;1-2ms,5次。 SingleShotTime:跳過預(yù)熱階段,直接進(jìn)行一次****微基準(zhǔn)測試。
@State(Scope.Thread):每個(gè)進(jìn)行基準(zhǔn)測試的線程都會(huì)獨(dú)享一個(gè)對象示例。
除此之外還能取值:
Benchmark:多線程共享一個(gè)示例。 Group:線程組共享一個(gè)示例,在測試方法上使用 @Group 設(shè)置線程組。
@Fork(1):表示開啟一個(gè)線程進(jìn)行測試。
**OutputTimeUnit(TimeUnit.MILLISECONDS):輸出的時(shí)間單位,這里寫的是毫秒。
@Warmup(iterations = 3):微基準(zhǔn)測試前進(jìn)行三次預(yù)熱執(zhí)行,也可用在測試方法上。
@Measurement(iterations = 5):進(jìn)行 5 次微基準(zhǔn)測試,也可用在測試方法上。
在兩個(gè)測試方法上只使用了一個(gè)注解 @Benchmark,這個(gè)注解表示這個(gè)方法是要進(jìn)行基準(zhǔn)測試的方法,它類似于 Junit 中的 @Test 注解。上面還提到某些注解還可以用到測試方法上,也就是使用了 @Benchmark 的方法之上,如果類上和測試方法同時(shí)存在注解,會(huì)以方法上的注解為準(zhǔn)。
其實(shí) JMH 也可以把這些參數(shù)直接在 main 方法中指定,這時(shí) main 方法中指定的級(jí)別最高。
public static void main(String[] args) throws RunnerException {Options opt = new OptionsBuilder().include(JmhHello.class.getSimpleName()).forks(1).warmupIterations(5).measurementIterations(10).build();new Runner(opt).run();}
正確的微基準(zhǔn)測試
如果編寫的代碼本身就存在著諸多問題,那么即使使用正確的測試方法,也不可能得到正確的測試結(jié)果。這些測試代碼中的問題應(yīng)該由我們進(jìn)行主動(dòng)避免,那么有哪些常見問題呢?下面介紹兩種最常見的情況。
無用代碼消除 ( Dead Code Elimination )
也有網(wǎng)友形象的翻譯成死代碼,死代碼是指那些 JVM 經(jīng)過檢查發(fā)現(xiàn)的根本不會(huì)使用到的代碼。比如下面這個(gè)代碼片段。
import java.util.concurrent.TimeUnit;import org.openjdk.jmh.annotations.*;import org.openjdk.jmh.runner.Runner;import org.openjdk.jmh.runner.RunnerException;import org.openjdk.jmh.runner.options.Options;import org.openjdk.jmh.runner.options.OptionsBuilder;/**** 測試死代碼消除** @author niujinpeng* @Date 2020/8/21 8:04*/@BenchmarkMode(Mode.AverageTime)@State(Scope.Thread)@Fork(1)@OutputTimeUnit(TimeUnit.MICROSECONDS)@Warmup(iterations = 3, time = 3)@Measurement(iterations = 5, time = 3)public class JmhDCE {@Benchmarkpublic double test1() {return Math.log(Math.PI);}@Benchmarkpublic void test2() {double result = Math.log(Math.PI);result = Math.log(result);}public static void main(String[] args) throws RunnerException {Options opt = new OptionsBuilder().include(JmhDCE.class.getSimpleName()).build();new Runner(opt).run();}}
在這個(gè)代碼片段里里,test1 方法對圓周率進(jìn)行對數(shù)計(jì)算,并返回計(jì)算結(jié)果;而 test2 中不僅對圓周率進(jìn)行對數(shù)計(jì)算,還對計(jì)算的結(jié)果再次對數(shù)計(jì)算,看起來復(fù)雜一些,但是因?yàn)闆]有用到計(jì)算結(jié)果,所以 JVM 會(huì)自動(dòng)消除這段代碼, 因?yàn)樗鼪]有任何意義。
Benchmark Mode Cnt Score Error UnitsJmhDCE.test1 avgt 5 0.002 ± 0.001 us/opJmhDCE.test2 avgt 5 ≈ 10?? us/op
測試結(jié)果里也可以看到 test 平均耗時(shí) 0.0004 微秒,而 test1 平均耗時(shí) 0.002 微秒。
常量折疊 (Constant Folding)
在對 Java 源文件編譯的過程中,編譯器通過語法分析,可以發(fā)現(xiàn)某些能直接得到計(jì)算結(jié)果而不會(huì)再次更改的代碼,然后會(huì)將計(jì)算結(jié)果記錄下來,這樣在執(zhí)行的過程中就不需要再次運(yùn)算了。比如這段代碼。
import java.util.concurrent.TimeUnit;import org.openjdk.jmh.annotations.*;import org.openjdk.jmh.runner.Runner;import org.openjdk.jmh.runner.RunnerException;import org.openjdk.jmh.runner.options.Options;import org.openjdk.jmh.runner.options.OptionsBuilder;/**** 測試常量折疊** @author niujinpeng* @Date 2020/8/21 8:23*/@BenchmarkMode(Mode.AverageTime)@State(Scope.Thread)@Fork(1)@OutputTimeUnit(TimeUnit.MICROSECONDS)@Warmup(iterations = 3, time = 3)@Measurement(iterations = 5, time = 3)public class JmhConstantFolding {final double PI1 = 3.14159265358979323846;double PI2 = 3.14159265358979323846;@Benchmarkpublic double test1() {return Math.log(PI1) * Math.log(PI1);}@Benchmarkpublic double test2() {return Math.log(PI2) * Math.log(PI2);}public static void main(String[] args) throws RunnerException {Options opt = new OptionsBuilder().include(JmhConstantFolding.class.getSimpleName()).build();new Runner(opt).run();}}
test1 中使用 final 修飾的 PI1 進(jìn)行對象計(jì)算,因?yàn)?PI1 不能再次更改,所以 test1 的計(jì)算結(jié)果必定是不會(huì)更改的,所以 JVM 會(huì)進(jìn)行常量折疊優(yōu)化,而 test2 使用的 PI2 可能會(huì)被修改,所以只能每次進(jìn)行計(jì)算。
Benchmark Mode Cnt Score Error UnitsJmhConstantFolding.test1 avgt 5 0.002 ± 0.001 us/opJmhConstantFolding.test2 avgt 5 0.019 ± 0.001 us/op
可以看到 test2 耗時(shí)要多的多,達(dá)到了 0.019 微秒。
其實(shí) JVM 做的優(yōu)化操作遠(yuǎn)不止上面這些,還有比如常量傳播(Constant Propagation)、循環(huán)展開(Loop Unwinding)、循環(huán)表達(dá)式外提(Loop Expression Hoisting)、消除公共子表達(dá)式(Common Subexpression Elimination)、本塊重排序(Basic Block Reordering)、范圍檢查消除(Range Check Elimination)等。
總結(jié)
JMH 進(jìn)行基準(zhǔn)測試的使用過程并不復(fù)雜,同為 Java 虛擬機(jī)團(tuán)隊(duì)開發(fā),準(zhǔn)確性毋容置疑。但是在進(jìn)行基準(zhǔn)測試時(shí)還是要注意自己的代碼問題,如果編寫的要進(jìn)行測試的代碼本身存在問題,那么測試的結(jié)果必定是不準(zhǔn)的。掌握了 JMH 基準(zhǔn)測試之后,可以嘗試測試一些常用的工具或者框架的性能如何,看看哪個(gè)工具的性能最好,比如 FastJSON 真的比 GSON 在進(jìn)行 JSON 轉(zhuǎn)換時(shí)更 Fast 嗎?Spring 的 BeanUtils 和 Apache 的 BeanUtils 哪個(gè)速度更快?
參考:
https://www.ibm.com/developerworks/cn/java/j-benchmark1.html
http://hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples/
深入理解Java虛擬機(jī):JVM高級(jí)特性與最佳實(shí)踐(第3版)第11章 后端編譯與優(yōu)化
最后的話
文章有幫助可以點(diǎn)個(gè)「在看」或「分享」,都是支持,我都喜歡!
文章每周持續(xù)更新,要實(shí)時(shí)關(guān)注我更新的文章以及分享的干貨,可以關(guān)注「?未讀代碼?」公眾號(hào)。
---- END ----
"未讀代碼,一線技術(shù)工具人的學(xué)習(xí)、生活與見聞"
一個(gè)「在看」,一段時(shí)光?
