1. 程式人生 > >函式效能對比工具之JMH

函式效能對比工具之JMH

概述

JMH 是一個由 OpenJDK/Oracle 裡面那群開發了 Java 編譯器的大牛們所開發的 Micro Benchmark Framework 。何謂 Micro Benchmark 呢?簡單地說就是在 method 層面上的 benchmark,精度可以精確到微秒級。可以看出 JMH 主要使用在當你已經找出了熱點函式,而需要對熱點函式進行進一步的優化時,就可以使用 JMH 對優化的效果進行定量的分析。

比較典型的使用場景還有:

  • 想定量地知道某個函式需要執行多長時間,以及執行時間和輸入 n 的相關性
  • 一個函式有兩種不同實現(例如實現 A 使用了 FixedThreadPool,實現 B 使用了 ForkJoinPool),不知道哪種實現效能更好

儘管 JMH 是一個相當不錯的 Micro Benchmark Framework,但很無奈的是網上能夠找到的文件比較少,而官方也沒有提供比較詳細的文件,對使用造成了一定的障礙。但是有個好訊息是官方的 Code Sample 寫得非常淺顯易懂,推薦在需要詳細瞭解 JMH 的用法時可以通讀一遍——本文則會介紹 JMH 最典型的用法和部分常用選項。

 

第一個例子

如果你使用 maven 來管理你的 Java 專案的話,引入 JMH 是一件很簡單的事情——只需要在 pom.xml裡增加 JMH 的依賴即可


  
  1. <properties
    >
  2. <jmh.version>1.14.1 </jmh.version>
  3. </properties>
  4. <dependencies>
  5. <dependency
    >
  6. <groupId>org.openjdk.jmh </groupId>
  7. <artifactId>jmh-core </artifactId>
  8. <version>${jmh.version} </version>
  9. </dependency>
  10. <dependency>
  11. <groupId>org.openjdk.jmh </groupId>
  12. <artifactId>jmh-generator-annprocess </artifactId>
  13. <version>${jmh.version} </version>
  14. <scope>provided </scope>
  15. </dependency>
  16. </dependencies>

  
  1. @BenchmarkMode(Mode.AverageTime)
  2. @OutputTimeUnit(TimeUnit.MICROSECONDS)
  3. @State(Scope.Thread)
  4. public class FirstBenchmark {
  5. @Benchmark
  6. public int sleepAWhile() {
  7. try {
  8. Thread.sleep( 500);
  9. } catch (InterruptedException e) {
  10. // ignore
  11. }
  12. return 0;
  13. }
  14. public static void main(String[] args) throws RunnerException {
  15. Options opt = new OptionsBuilder()
  16. .include(FirstBenchmark.class.getSimpleName())
  17. .forks( 1)
  18. .warmupIterations( 5)
  19. .measurementIterations( 5)
  20. .build();
  21. new Runner(opt).run();
  22. }
  23. }

結果:


  
  1. # JMH 1.14.1 (released 39 days ago)
  2. # VM version: JDK 1.8.0_11, VM 25.11-b03
  3. # VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_11.jdk/Contents/Home/jre/bin/java
  4. # VM options: -Didea.launcher.port=7535 -Didea.launcher.bin.path=/Applications/IntelliJ IDEA 15 CE.app/Contents/bin -Dfile.encoding=UTF-8
  5. # Warmup: 5 iterations, 1 s each
  6. # Measurement: 5 iterations, 1 s each
  7. # Timeout: 10 min per iteration
  8. # Threads: 1 thread, will synchronize iterations
  9. # Benchmark mode: Average time, time/op
  10. # Benchmark: com.dyng.FirstBenchmark.sleepAWhile
  11. # Run progress: 0.00% complete, ETA 00:00:10
  12. # Fork: 1 of 1
  13. # Warmup Iteration 1: 503.440 ms/op
  14. # Warmup Iteration 2: 503.885 ms/op
  15. # Warmup Iteration 3: 503.714 ms/op
  16. # Warmup Iteration 4: 504.333 ms/op
  17. # Warmup Iteration 5: 502.596 ms/op
  18. Iteration 1: 504.352 ms/op
  19. Iteration 2: 502.583 ms/op
  20. Iteration 3: 501.256 ms/op
  21. Iteration 4: 501.655 ms/op
  22. Iteration 5: 504.212 ms/op
  23. Result "sleepAWhile":
  24. 502.811 ±( 99.9%) 5.495 ms/op [Average]
  25. (min, avg, max) = ( 501.256, 502.811, 504.352), stdev = 1.427
  26. CI ( 99.9%): [ 497.316, 508.306] (assumes normal distribution)
  27. # Run complete. Total time: 00:00:12
  28. Benchmark Mode Cnt Score Error Units
  29. FirstBenchmark.sleepAWhile avgt 5 502.811 ± 5.495 ms/op

對 sleepAWhile() 的測試結果顯示執行時間平均約為502毫秒。因為我們的測試物件 sleepAWhile() 正好就是睡眠500毫秒,所以 JMH 顯示的結果可以說很符合我們的預期。

那好,現在我們再來詳細地解釋程式碼的意義。不過在這之前,需要先了解一下 JMH 的幾個基本概念。

基本概念

Mode

Mode 表示 JMH 進行 Benchmark 時所使用的模式。通常是測量的維度不同,或是測量的方式不同。目前 JMH 共有四種模式:

  • Throughput: 整體吞吐量,例如“1秒內可以執行多少次呼叫”。
  • AverageTime: 呼叫的平均時間,例如“每次呼叫平均耗時xxx毫秒”。
  • SampleTime: 隨機取樣,最後輸出取樣結果的分佈,例如“99%的呼叫在xxx毫秒以內,99.99%的呼叫在xxx毫秒以內”
  • SingleShotTime: 以上模式都是預設一次 iteration 是 1s,唯有 SingleShotTime 是只執行一次。往往同時把 warmup 次數設為0,用於測試冷啟動時的效能。

Iteration

Iteration 是 JMH 進行測試的最小單位。在大部分模式下,一次 iteration 代表的是一秒,JMH 會在這一秒內不斷呼叫需要 benchmark 的方法,然後根據模式對其取樣,計算吞吐量,計算平均執行時間等。

Warmup

Warmup 是指在實際進行 benchmark 前先進行預熱的行為。為什麼需要預熱?因為 JVM 的 JIT 機制的存在,如果某個函式被呼叫多次之後,JVM 會嘗試將其編譯成為機器碼從而提高執行速度。所以為了讓 benchmark 的結果更加接近真實情況就需要進行預熱。

註解

現在來解釋一下上面例子中使用到的註解,其實很多註解的意義完全可以望文生義 :)

@Benchmark

表示該方法是需要進行 benchmark 的物件,用法和 JUnit 的 @Test 類似。

@Mode

Mode 如之前所說,表示 JMH 進行 Benchmark 時所使用的模式。

@State

State 用於宣告某個類是一個“狀態”,然後接受一個 Scope 引數用來表示該狀態的共享範圍。因為很多 benchmark 會需要一些表示狀態的類,JMH 允許你把這些類以依賴注入的方式注入到 benchmark 函式裡。Scope 主要分為兩種。

  • Thread: 該狀態為每個執行緒獨享。
  • Benchmark: 該狀態在所有執行緒間共享。

關於State的用法,官方的 code sample 裡有比較好的例子

@OutputTimeUnit

benchmark 結果所使用的時間單位。

啟動選項

解釋完了註解,再來看看 JMH 在啟動前設定的引數。


  
  1. Options opt = new OptionsBuilder()
  2. .include(FirstBenchmark. class.getSimpleName())
  3. .forks( 1)
  4. .warmupIterations( 5)
  5. .measurementIterations( 5)
  6. .build();
  7. new Runner(opt).run();

include

benchmark 所在的類的名字,注意這裡是使用正則表示式對所有類進行匹配的。

fork

進行 fork 的次數。如果 fork 數是2的話,則 JMH 會 fork 出兩個程序來進行測試。

warmupIterations

預熱的迭代次數。

measurementIterations

實際測量的迭代次數。

第二個例子

在看過第一個完全只為示範的例子之後,再來看一個有實際意義的例子。

問題:

計算 1 ~ n 之和,比較序列演算法和並行演算法的效率,看 n 在大約多少時並行演算法開始超越序列演算法

首先定義一個表示這兩種實現的介面


  
  1. public interface Calculator {
  2. /**
  3. * calculate sum of an integer array
  4. * @param numbers
  5. * @return
  6. */
  7. public long sum(int[] numbers);
  8. /**
  9. * shutdown pool or reclaim any related resources
  10. */
  11. public void shutdown();
  12. }

由於這兩種演算法的實現不是這篇文章的重點,而且本身並不困難,所以實際程式碼就不贅述了。如果真的感興趣的話,可以看最後的附錄。以下僅說明一下我所指的序列演算法和並行演算法的含義。

  • 序列演算法:使用 for-loop 來計算 n 個正整數之和。
  • 並行演算法:將所需要計算的 n 個正整數分成 m 份,交給 m 個執行緒分別計算出和以後,再把它們的結果相加。

進行 benchmark 的程式碼如下


  
  1. @BenchmarkMode(Mode.AverageTime)
  2. @OutputTimeUnit(TimeUnit.MICROSECONDS)
  3. @State(Scope.Benchmark)
  4. public class SecondBenchmark {
  5. @Param({ "10000", "100000", "1000000"})
  6. private int length;
  7. private int[] numbers;
  8. private Calculator singleThreadCalc;
  9. private Calculator multiThreadCalc;
  10. public static void main(String[] args) throws RunnerException {
  11. Options opt = new OptionsBuilder()
  12. .include(SecondBenchmark.class.getSimpleName())
  13. .forks( 2)
  14. .warmupIterations( 5)
  15. .measurementIterations( 5)
  16. .build();
  17. new Runner(opt).run();
  18. }
  19. @Benchmark
  20. public long singleThreadBench() {
  21. return singleThreadCalc.sum(numbers);
  22. }
  23. @Benchmark
  24. public long multiThreadBench() {
  25. return multiThreadCalc.sum(numbers);
  26. }
  27. @Setup
  28. public void prepare() {
  29. numbers = IntStream.rangeClosed( 1, length).toArray();
  30. singleThreadCalc = new SinglethreadCalculator();
  31. multiThreadCalc = new MultithreadCalculator(Runtime.getRuntime().availableProcessors());
  32. }
  33. @TearDown
  34. public void shutdown() {
  35. singleThreadCalc.shutdown();
  36. multiThreadCalc.shutdown();
  37. }
  38. }

注意到這裡用到了3個之前沒有使用的註解。

@Param

@Param 可以用來指定某項引數的多種情況。特別適合用來測試一個函式在不同的引數輸入的情況下的效能。

@Setup

@Setup 會在執行 benchmark 之前被執行,正如其名,主要用於初始化。

@TearDown

@TearDown 和 @Setup 相對的,會在所有 benchmark 執行結束以後執行,主要用於資源的回收等。

最後來猜猜看實際結果如何?並行演算法在哪個問題集下能夠超越序列演算法?

我在自己的 mac 上跑下來的結果,總數在10000時並行演算法不如序列演算法,總數達到100000時並行演算法開始和序列演算法接近,總數達到1000000時並行演算法所耗時間約是序列演算法的一半左右。

常用選項

還有一些 JMH 的常用選項沒有提及的,簡單地在此介紹一下

CompilerControl

控制 compiler 的行為,例如強制 inline,不允許編譯等。

Group

可以把多個 benchmark 定義為同一個 group,則它們會被同時執行,主要用於測試多個相互之間存在影響的方法。

Level

用於控制 @Setup@TearDown 的呼叫時機,預設是 Level.Trial,即benchmark開始前和結束後。

Profiler

JMH 支援一些 profiler,可以顯示等待時間和執行時間比,熱點函式等。

延伸閱讀

IDE外掛

IntelliJ 有 JMH 的外掛,提供 benchmark 方法的自動生成等便利功能。

JMH 教程

Jenkov 的 JMH 教程,相比於這篇文章介紹得更為詳細,非常推薦。順便 Jenkov 的其他 Java 教程也非常值得一看。

附錄

程式碼清單


  
  1. public class SinglethreadCalculator implements Calculator {
  2. public long sum(int[] numbers) {
  3. long total = 0L;
  4. for ( int i : numbers) {
  5. total += i;
  6. }
  7. return total;
  8. }
  9. @Override
  10. public void shutdown() {
  11. // nothing to do
  12. }
  13. }
  14. public class MultithreadCalculator implements Calculator {
  15. private final int nThreads;
  16. private final ExecutorService pool;
  17. public MultithreadCalculator(int nThreads) {
  18. this.nThreads = nThreads;
  19. this.pool = Executors.newFixedThreadPool(nThreads);
  20. }
  21. private class SumTask implements Callable<Long> {
  22. private int[] numbers;
  23. private int from;
  24. private int to;
  25. public SumTask(int[] numbers, int from, int to) {
  26. this.numbers = numbers;
  27. this.from = from;
  28. this.to = to;
  29. }
  30. public Long call() throws Exception {
  31. long total = 0L;
  32. for ( int i = from; i < to; i++) {
  33. total += numbers[i];
  34. }
  35. return total;
  36. }
  37. }
  38. public long sum(int[] numbers) {
  39. int chunk = numbers.length / nThreads;
  40. int from, to;
  41. List<SumTask> tasks = new ArrayList<SumTask>();
  42. for ( int i = 1; i <= nThreads; i++) {
  43. if (i == nThreads) {
  44. from = (i - 1) * chunk;
  45. to = numbers.length;
  46. } else {
  47. from = (i - 1) * chunk;
  48. to = i * chunk;