1. 程式人生 > >JMH-大廠是如何使用JMH進行Java程式碼效能測試的?必須掌握!

JMH-大廠是如何使用JMH進行Java程式碼效能測試的?必須掌握!

## Java 效能測試難題 現在的 JVM 已經越來越為智慧,它可以在編譯階段、載入階段、執行階段對程式碼進行優化。比如你寫了一段不怎麼聰明的程式碼,到了 JVM 這裡,它發現幾處可以優化的地方,就順手幫你優化了一把。這對程式的執行固然美妙,卻讓開發者不能準確瞭解程式的執行情況。在需要進行效能測試時,如果不知道 JVM 優化細節,可能會導致你的測試結果差之毫釐,失之千里,同樣的,Java 誕生之初就有一次編譯、隨處執行的口號,JVM 提供了底層支援,也提供了記憶體管理機制,這些機制都會對我們的效能測試結果造成不可預測的影響。 ```java long start = System.currentTimeMillis(); // .... long end = System.currentTimeMillis(); System.out.println(end - start); ``` 上面可能就是你最常見的效能測試了,這樣的測試結果真的準確嗎?答案是否定的,它有下面幾個問題。 1. 時間精度問題,本身獲取到的時間戳就是存在**誤差**的,它和作業系統有關。 2. JVM 在執行時會進行**程式碼預熱**,說白了就是**越跑越快**。因為類需要裝載、需要準備操作。 3. JVM 會在各個階段都有可能對你的程式碼進行**優化處理**。 4. **資源回收**的不確定性,可能執行很快,回收很慢。 帶著這些問題,突然發現進行一次嚴格的基準測試的難度大大增加。那麼如何才能進行一次嚴格的基準測試呢? ## JMH 介紹 那麼如何對 Java 程式進行一次精準的效能測試呢?難道需要掌握很多 JVM 優化細節嗎?難道要研究如何避免,並進行正確編碼才能進行嚴格的效能測試嗎?顯然不是,如果是這樣的話,未免過於困難了,好在有一款一款官方的微基準測試工具 - **JMH**. **JMH** 的全名是 Java Microbenchmark Harness,它是由 **Java 虛擬機器團隊**開發的一款用於 Java **微基準測試工具**。用自己開發的工具測試自己開發的另一款工具,以子之矛,攻子之盾果真手到擒來,如臂使指。使用 **JMH** 可以讓你方便快速的進行一次嚴格的程式碼基準測試,並且有多種測試模式,多種測試維度可供選擇;而且使用簡單、增加註解便可啟動測試。 ## JMH 使用 JMH 的使用首先引入 maven 所需依賴,當前最新版 為 1.23 版本。 ``` xml ``` ### 快速測試 下面使用註解的方式指定測試引數,通過一個例子展示 JMH 基準測試的具體用法,先看一次執行效果,然後再瞭解每個註解的具體含義。 這個例子是使用 JMH 測試,使用加號拼接字串和使用 `StringBuilder` 的 `append` 方法拼接字串時的速度如何,每次拼接1000個數字進行平均速度比較。 ```java 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 基準測試入門 * * @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(); @Benchmark public String stringAdd() { for (int i = 0; i < 1000; i++) { string = string + i; } return string; } @Benchmark public 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` 使用加號拼接字串 1000次,`stringBuilderAppend` 使用 `append` 拼接字串 1000次。直接執行 main 方法,稍等片刻後可以得到詳細的執行輸出結果。 ```log // 開始測試 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 // 預熱執行三次 # Measurement: 5 iterations, 10 s each // 效能測試5次 # Timeout: 10 min per iteration // 超時時間10分鐘 # Threads: 1 thread, will synchronize iterations // 執行緒數量為1 # Benchmark mode: Average time, time/op // 統計方法呼叫一次的平均時間 # Benchmark: net.codingme.jmh.JmhHello.stringAdd // 本次執行的方法 # Run progress: 0.00% complete, ETA 00:02:40 # Fork: 1 of 1 # Warmup Iteration 1: 95.153 ms/op // 第一次預熱,耗時95ms # Warmup Iteration 2: 108.927 ms/op // 第二次預熱,耗時108ms # Warmup Iteration 3: 167.760 ms/op // 第三次預熱,耗時167ms Iteration 1: 198.897 ms/op // 執行五次耗時度量 Iteration 2: 243.437 ms/op Iteration 3: 271.171 ms/op Iteration 4: 295.636 ms/op Iteration 5: 327.822 ms/op Result "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 // 執行的最小、平均、最大、誤差值 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/op Iteration 1: 6.936 ms/op Iteration 2: 8.465 ms/op Iteration 3: 8.925 ms/op Iteration 4: 9.766 ms/op Iteration 5: 10.143 ms/op Result "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.258 CI (99.9%): [4.003, 13.691] (assumes normal distribution) # Run complete. Total time: 00:02:42 REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial experiments, perform baseline and negative tests that provide experimental control, make sure the 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. // 測試結果對比 Benchmark Mode Cnt Score Error Units JmhHello.stringAdd avgt 5 267.393 ± 189.907 ms/op JmhHello.stringBuilderAppend avgt 5 8.847 ± 4.844 ms/op Process finished with exit code 0 ``` 上面日誌裡的 `//` 註釋是我手動增加上去的,其實我們只需要看下面的最終結果就可以了,可以看到 `stringAdd` 方法平均耗時 267.393ms,而 `stringBuilderAppend` 方法平均耗時只有 8.847ms,可見 `StringBuilder` 的 `append` 方法進行字串拼接速度快的多,這也是我們推薦使用` append` 進行字串拼接的原因。 ### 註解說明 經過上面的示例,想必你也可以快速的使用 JMH 進行基準測試了,不過上面的諸多註解你可能還有疑惑,下面一一介紹。 **類上**使用了六個註解。 ``` @BenchmarkMode(Mode.AverageTime) @State(Scope.Thread) @Fork(1) @OutputTimeUnit(TimeUnit.MILLISECONDS) @Warmup(iterations = 3) @Measurement(iterations = 5) ``` **@BenchmarkMode(Mode.AverageTime)** 表示統計平均響應時間,不僅可以用在類上,也可用在**測試方法**上。 除此之外還可以取值: - Throughput:統計單位時間內可以對方法測試多少次。 - SampleTime:統計每個響應時間範圍內的響應次數,比如 0-1ms,3次;1-2ms,5次。 - SingleShotTime:跳過預熱階段,直接進行**一次****微基準**測試。 **@State(Scope.Thread)**:每個進行基準測試的執行緒都會獨享一個物件示例。 除此之外還能取值: - Benchmark:多執行緒共享一個示例。 - Group:執行緒組共享一個示例,在測試方法上使用 @Group 設定執行緒組。 **@Fork(1)**:表示開啟一個執行緒進行測試。 **OutputTimeUnit(TimeUnit.MILLISECONDS):輸出的時間單位,這裡寫的是毫秒。 **@Warmup(iterations = 3)**:微基準測試前進行三次預熱執行,也可用在**測試方法**上。 **@Measurement(iterations = 5)**:進行 5 次微基準測試,也可用在**測試方法**上。 在兩個測試方法上只使用了一個註解 **@Benchmark**,這個註解表示這個方法是要進行基準測試的方法,它類似於 Junit 中的 **@Test** 註解。上面還提到某些註解還可以用到測試方法上,也就是使用了 **@Benchmark** 的方法之上,如果類上和測試方法同時存在註解,會以**方法上的註解**為準。 其實 JMH 也可以把這些引數直接在 main 方法中指定,這時 main 方法中指定的級別最高。 ```java 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(); } ``` ## 正確的微基準測試 如果編寫的程式碼本身就存在著諸多問題,那麼即使使用正確的測試方法,也不可能得到正確的測試結果。這些測試程式碼中的問題應該由我們進行主動避免,那麼有哪些常見問題呢?下面介紹兩種最常見的情況。 ### 無用程式碼消除 ( Dead Code Elimination ) 也有網友形象的翻譯成**死程式碼**,死程式碼是指那些 JVM 經過檢查發現的根本不會使用到的程式碼。比如下面這個程式碼片段。 ```java 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 { @Benchmark public double test1() { return Math.log(Math.PI); } @Benchmark public 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(); } } ``` 在這個程式碼片段裡裡,`test1` 方法對圓周率進行對數計算,並返回計算結果;而 `test2` 中不僅對圓周率進行對數計算,還對計算的結果再次對數計算,看起來複雜一些,但是因為沒有用到計算結果,所以 JVM 會自動消除這段程式碼, 因為它沒有任何意義。 ```shell Benchmark Mode Cnt Score Error Units JmhDCE.test1 avgt 5 0.002 ± 0.001 us/op JmhDCE.test2 avgt 5 ≈ 10⁻⁴ us/op ``` 測試結果裡也可以看到 `test` 平均耗時 0.0004 微秒,而 `test1` 平均耗時 0.002 微秒。 ### 常量摺疊 (Constant Folding) 在對 Java 原始檔編譯的過程中,編譯器通過語法分析,可以發現某些能直接得到計算結果而不會再次更改的程式碼,然後會將計算結果記錄下來,這樣在執行的過程中就不需要再次運算了。比如這段程式碼。 ```java 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; @Benchmark public double test1() { return Math.log(PI1) * Math.log(PI1); } @Benchmark public 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(); } } ``` `test`1 中使用 `final` 修飾的 PI1 進行物件計算,因為 PI1 不能再次更改,所以 `test1` 的計算結果必定是不會更改的,所以 JVM 會進行常量摺疊優化,而 `test2` 使用的 `PI2` 可能會被修改,所以只能每次進行計算。 ```shell Benchmark Mode Cnt Score Error Units JmhConstantFolding.test1 avgt 5 0.002 ± 0.001 us/op JmhConstantFolding.test2 avgt 5 0.019 ± 0.001 us/op ``` 可以看到 `test2` 耗時要多的多,達到了 0.019 微秒。 其實 JVM 做的優化操作遠不止上面這些,還有比如常量傳播(Constant Propagation)、迴圈展開(Loop Unwinding)、迴圈表示式外提(Loop Expression Hoisting)、消除公共子表示式(Common Subexpression Elimination)、本塊重排序(Basic Block Reordering)、範圍檢查消除(Range Check Elimination)等。 ## 總結 JMH 進行基準測試的使用過程並不複雜,同為 Java 虛擬機器團隊開發,準確性毋容置疑。但是在進行基準測試時還是要注意自己的程式碼問題,如果編寫的要進行測試的程式碼本身存在問題,那麼測試的結果必定是不準的。掌握了 JMH 基準測試之後,可以嘗試測試一些常用的工具或者框架的效能如何,看看哪個工具的效能最好,比如 FastJSON 真的比 GSON 在進行 JSON 轉換時更 Fast 嗎? **參考:** - 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虛擬機器:JVM高階特性與最佳實踐(第3版)第11章 後端編譯與優化 **最後的話** >文章已經收錄在 [Github.com/niumoo/JavaNotes](https://github.com/niumoo/JavaNotes) ,歡迎Star和指教。更有一線大廠面試點,Java程式設計師需要掌握的核心知識等文章,也整理了很多我的文字,歡迎 **Star** 和完善,希望我們一起變得優秀。 文章有幫助可以點個「**贊**」或「**分享**」,都是支援,我都喜歡! 文章每週持續更新,要實時關注我更新的文章以及分享的乾貨,可以關注「 **未讀程式碼** 」公眾號或者[我的部落格](https://www.wdbyte.com/)。 ![公眾號](https://cdn.jsdelivr.net/gh/niumoo/cdn-assets@439f6a5f6bd130e2aec56f3527656d6edb487b91/webinfo/weixin-public.j