1. 程式人生 > >Java微基準測試框架JMH

Java微基準測試框架JMH

本文轉自:https://www.xncoding.com/2018/01/07/java/jmh.html 

作者:XiongNeng

JMH,即Java Microbenchmark Harness,這是專門用於進行程式碼的微基準測試的一套工具API。

JMH 由 OpenJDK/Oracle 裡面那群開發了 Java 編譯器的大牛們所開發 。何謂 Micro Benchmark 呢? 簡單地說就是在 method 層面上的 benchmark,精度可以精確到微秒級。

Java的基準測試需要注意的幾個點:

  • 測試前需要預熱。

  • 防止無用程式碼進入測試方法中。

  • 併發測試。

  • 測試結果呈現。

比較典型的使用場景:

  1. 當你已經找出了熱點函式,而需要對熱點函式進行進一步的優化時,就可以使用 JMH 對優化的效果進行定量的分析。

  2. 想定量地知道某個函式需要執行多長時間,以及執行時間和輸入 n 的相關性

  3. 一個函式有兩種不同實現(例如JSON序列化/反序列化有Jackson和Gson實現),不知道哪種實現效能更好

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

第一個例子

新增maven依賴

如果使用maven專案,只需要新增如下依賴:

 <!-- JMH-->
 <dependency>
     <groupId>org.openjdk.jmh</groupId>
     <artifactId>jmh-core</artifactId>
     <version>${jmh.version}</version>
 </dependency>
 <dependency>
     <groupId>org.openjdk.jmh</groupId>
     <artifactId>jmh-generator-annprocess</artifactId>
     <version>${jmh.version}</version>
     <scope>provided</scope>
 </dependency>

編寫效能測試

接下來我寫一個比較字串連線操作的時候,直接使用字串相加和使用StringBuilder的append方式的效能比較測試:

 /**
  * 比較字串直接相加和StringBuilder的效率
  */
 @BenchmarkMode(Mode.Throughput)
 @Warmup(iterations = 3)
 @Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS)
 @Threads(8)
 @Fork(2)
 @OutputTimeUnit(TimeUnit.MILLISECONDS)
 public class StringBuilderBenchmark {
 
     @Benchmark
     public void testStringAdd() {
         String a = "";
         for (int i = 0; i < 10; i++) {
             a += i;
         }
         print(a);
     }

     @Benchmark
     public void testStringBuilderAdd() {
         StringBuilder sb = new StringBuilder();
         for (int i = 0; i < 10; i++) {
             sb.append(i);
         }
         print(sb.toString());
     }

     private void print(String a) {
     }
 }

執行方式

這個程式碼裡面有好多註解,你第一次見可能不知道什麼意思。先不用管,我待會一一介紹。

我們來執行這個測試,執行JMH基準測試有多種方式,一個是生成jar檔案執行, 一個是直接寫main函式或寫單元測試執行。

一般對於大型的測試,需要測試時間比較久,執行緒比較多的話,就需要去寫好了丟到linux程式裡執行, 不然本機執行很久時間什麼都幹不了了。

mvn clean package
java -jar target/benchmarks.jar

先編譯打包之後,然後執行就可以了。當然在執行的時候可以輸入-h引數來看幫助。

另外如果對於一些小的測試,比如我寫的上面這個小例子,在IDE裡面就可以完成了,丟到linux上去太麻煩。 這時候可以在裡面新增一個main函式如下:

public static void main(String[] args) throws RunnerException {
    Options options = new OptionsBuilder()
            .include(StringBuilderBenchmark.class.getSimpleName())
            .output("E:/Benchmark.log")
            .build();
    new Runner(options).run();
}

這裡其實也比較簡單,new個Options,然後傳入要執行哪個測試,選擇基準測試報告輸出檔案地址,然後通過Runner的run方法就可以跑起來了。

報告結果

我們跑一下這個基準測試,完成後開啟E:/Benchmark.log,結果如下:

 # JMH version: 1.20
 # VM version: JDK 1.8.0_131, VM 25.131-b11
 # VM invoker: C:\Program Files\Java\jdk1.8.0_131\jre\bin\java.exe
 # VM options: -javaagent:E:\Program Files\JetBrains\IntelliJ IDEA 2017.3\lib\idea_rt.jar=62744:E:\Program Files\JetBrains\IntelliJ IDEA 2017.3\bin -Dfile.encoding=UTF-8
 # Warmup: 3 iterations, 1 s each
 # Measurement: 10 iterations, 5 s each
 # Timeout: 10 min per iteration
 # Threads: 16 threads, will synchronize iterations
 # Benchmark mode: Throughput, ops/time
 # Benchmark: com.xncoding.benchmark.string.StringBuilderBenchmark.testStringAdd
 
 # Run progress: 0.00% complete, ETA 00:03:32
 # Fork: 1 of 2
 # Warmup Iteration   1: 7332.410 ops/ms
 # Warmup Iteration   2: 8758.506 ops/ms
 # Warmup Iteration   3: 9078.783 ops/ms
 Iteration   1: 8824.713 ops/ms
 Iteration   2: 9084.977 ops/ms
 Iteration   3: 9412.712 ops/ms
 Iteration   4: 8843.631 ops/ms
 Iteration   5: 9030.556 ops/ms
 Iteration   6: 9090.677 ops/ms
 Iteration   7: 9493.148 ops/ms
 Iteration   8: 8664.593 ops/ms
 Iteration   9: 8835.227 ops/ms
 Iteration  10: 8570.212 ops/ms
 
 # Run progress: 25.00% complete, ETA 00:03:15
 # Fork: 2 of 2
 # Warmup Iteration   1: 5350.686 ops/ms
 # Warmup Iteration   2: 8862.238 ops/ms
 # Warmup Iteration   3: 8086.594 ops/ms
 Iteration   1: 9105.306 ops/ms
 Iteration   2: 8288.588 ops/ms
 Iteration   3: 9307.902 ops/ms
 Iteration   4: 9195.150 ops/ms
 Iteration   5: 8715.555 ops/ms
 Iteration   6: 9075.069 ops/ms
 Iteration   7: 9041.037 ops/ms
 Iteration   8: 9187.099 ops/ms
 Iteration   9: 9145.134 ops/ms
 Iteration  10: 9124.229 ops/ms
 
 
 Result "com.xncoding.benchmark.string.StringBuilderBenchmark.testStringAdd":
   9001.776 ±(99.9%) 253.496 ops/ms [Average]
   (min, avg, max) = (8288.588, 9001.776, 9493.148), stdev = 291.926
   CI (99.9%): [8748.280, 9255.272] (assumes normal distribution)
 
 
 # JMH version: 1.20
 # VM version: JDK 1.8.0_131, VM 25.131-b11
 # VM invoker: C:\Program Files\Java\jdk1.8.0_131\jre\bin\java.exe
 # VM options: -javaagent:E:\Program Files\JetBrains\IntelliJ IDEA 2017.3\lib\idea_rt.jar=62744:E:\Program Files\JetBrains\IntelliJ IDEA 2017.3\bin -Dfile.encoding=UTF-8
 # Warmup: 3 iterations, 1 s each
 # Measurement: 10 iterations, 5 s each
 # Timeout: 10 min per iteration
 # Threads: 16 threads, will synchronize iterations
 # Benchmark mode: Throughput, ops/time
 # Benchmark: com.xncoding.benchmark.string.StringBuilderBenchmark.testStringBuilderAdd
 
 # Run progress: 50.00% complete, ETA 00:02:07
 # Fork: 1 of 2
 # Warmup Iteration   1: 27202.528 ops/ms
 # Warmup Iteration   2: 26500.586 ops/ms
 # Warmup Iteration   3: 27190.346 ops/ms
 Iteration   1: 27891.257 ops/ms
 Iteration   2: 28704.541 ops/ms
 Iteration   3: 27785.951 ops/ms
 Iteration   4: 26841.454 ops/ms
 Iteration   5: 26024.288 ops/ms
 Iteration   6: 25592.494 ops/ms
 Iteration   7: 25626.875 ops/ms
 Iteration   8: 25302.248 ops/ms
 Iteration   9: 25519.780 ops/ms
 Iteration  10: 25275.334 ops/ms
 
 # Run progress: 75.00% complete, ETA 00:01:02
 79# Fork: 2 of 2
 # Warmup Iteration   1: 30376.008 ops/ms
 # Warmup Iteration   2: 25131.064 ops/ms
 # Warmup Iteration   3: 25622.342 ops/ms
 Iteration   1: 25386.845 ops/ms
 Iteration   2: 25825.139 ops/ms
 Iteration   3: 26029.607 ops/ms
 Iteration   4: 25531.748 ops/ms
 Iteration   5: 25374.934 ops/ms
 Iteration   6: 25204.530 ops/ms
 Iteration   7: 22934.211 ops/ms
 Iteration   8: 23907.677 ops/ms
 Iteration   9: 24337.963 ops/ms
 Iteration  10: 24660.626 ops/ms
 
 
 Result "com.xncoding.benchmark.string.StringBuilderBenchmark.testStringBuilderAdd":
   25687.875 ±(99.9%) 1167.955 ops/ms [Average]
   (min, avg, max) = (22934.211, 25687.875, 28704.541), stdev = 1345.019
   CI (99.9%): [24519.920, 26855.830] (assumes normal distribution)
 

 # Run complete. Total time: 00:04:08

 Benchmark                                     Mode  Cnt      Score      Error   Units
 StringBuilderBenchmark.testStringAdd         thrpt   20   9001.776 ±  253.496  ops/ms
 StringBuilderBenchmark.testStringBuilderAdd  thrpt   20  25687.875 ± 1167.955  ops/ms

仔細看,三大部分,第一部分是字串用加號連線執行的結果,第二部分是StringBuilder執行的結果,第三部分就是兩個的簡單結果比較。這裡注意我們forks傳的2,所以每個測試有兩個fork結果。

前兩部分是一樣的,簡單說下。首先會寫出每部分的一些引數設定,然後是預熱迭代執行(Warmup Iteration), 然後是正常的迭代執行(Iteration),最後是結果(Result)。這些看看就好,我們最關注的就是第三部分, 其實也就是最終的結論。千萬別看歪了,他輸出的也確實很不爽,error那列其實沒有內容,score的結果是xxx ± xxx,單位是每毫秒多少個操作。可以看到,StringBuilder的速度還確實是要比String進行文字疊加的效率好太多。

註解介紹

好了,當你對JMH有了一個基本認識後,現在來詳細解釋一下前面程式碼中的各個註解含義。

@BenchmarkMode

基準測試型別。這裡選擇的是Throughput也就是吞吐量。根據原始碼點進去,每種型別後面都有對應的解釋,比較好理解,吞吐量會得到單位時間內可以進行的運算元。

  • Throughput: 整體吞吐量,例如“1秒內可以執行多少次呼叫”。

  • AverageTime: 呼叫的平均時間,例如“每次呼叫平均耗時xxx毫秒”。

  • SampleTime: 隨機取樣,最後輸出取樣結果的分佈,例如“99%的呼叫在xxx毫秒以內,99.99%的呼叫在xxx毫秒以內”

  • SingleShotTime: 以上模式都是預設一次 iteration 是 1s,唯有 SingleShotTime 是隻執行一次。往往同時把 warmup 次數設為0,用於測試冷啟動時的效能。

  • All(“all”, “All benchmark modes”);

@Warmup

上面我們提到了,進行基準測試前需要進行預熱。一般我們前幾次進行程式測試的時候都會比較慢, 所以要讓程式進行幾輪預熱,保證測試的準確性。其中的引數iterations也就非常好理解了,就是預熱輪數。

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

@Measurement

度量,其實就是一些基本的測試引數。

  1. iterations 進行測試的輪次

  2. time 每輪進行的時長

  3. timeUnit 時長單位

都是一些基本的引數,可以根據具體情況調整。一般比較重的東西可以進行大量的測試,放到伺服器上執行。

@Threads

每個程序中的測試執行緒,這個非常好理解,根據具體情況選擇,一般為cpu乘以2。

@Fork

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

@OutputTimeUnit

這個比較簡單了,基準測試結果的時間型別。一般選擇秒、毫秒、微秒。

@Benchmark

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

@Param

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

@Setup

方法級註解,這個註解的作用就是我們需要在測試之前進行一些準備工作,比如對一些資料的初始化之類的。

@TearDown

方法級註解,這個註解的作用就是我們需要在測試之後進行一些結束工作,比如關閉執行緒池,資料庫連線等的,主要用於資源的回收等。

@State

當使用@Setup引數的時候,必須在類上加這個引數,不然會提示無法執行。

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

  1. Thread: 該狀態為每個執行緒獨享。

  2. Group: 該狀態為同一個組裡面所有執行緒共享。

  3. Benchmark: 該狀態在所有執行緒間共享。

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

第二個例子

再來看一個更常規一點效能測試的例子,

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

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

/**
 * Calculator
 *
 * @author XiongNeng
 * @version 1.0
 * @since 2018/1/7
 */
public interface Calculator {
    /**
     * calculate sum of an integer array
     *
     * @param numbers
     * @return
     */
    public long sum(int[] numbers);

    /**
     * shutdown pool or reclaim any related resources
     */
    public void shutdown();
}

具體的兩種實現程式碼我就不貼了,主要說明一下序列演算法和並行演算法實現原理:

  • 序列演算法:使用 for-loop 來計算 n 個正整數之和。

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

進行 benchmark 的程式碼如下:

/**
 * 自然數求和的序列和並行演算法效能測試
 *
 * @author XiongNeng
 * @version 1.0
 * @since 2018/1/7
 */
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
public class SecondBenchmark {
    @Param({"10000", "100000", "1000000"})
    private int length;

    private int[] numbers;
    private Calculator singleThreadCalc;
    private Calculator multiThreadCalc;

    public static void main(String[] args) throws Exception {
        Options opt = new OptionsBuilder()
                .include(SecondBenchmark.class.getSimpleName())
                .forks(1)
                .warmupIterations(5)
                .measurementIterations(2)                .build();
        Collection<RunResult> results =  new Runner(opt).run();
        ResultExporter.exportResult("單執行緒與多執行緒求和效能", results, "length", "微秒");
    }

    @Benchmark
    public long singleThreadBench() {
        return singleThreadCalc.sum(numbers);
    }

    @Benchmark
    public long multiThreadBench() {
        return multiThreadCalc.sum(numbers);
    }

    @Setup
    public void prepare() {
        numbers = IntStream.rangeClosed(1, length).toArray();
        singleThreadCalc = new SinglethreadCalculator();
        multiThreadCalc = new MultithreadCalculator(Runtime.getRuntime().availableProcessors());
    }

    @TearDown
    public void shutdown() {
        singleThreadCalc.shutdown();
        multiThreadCalc.shutdown();
    }
}

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

 

參考文章

  • Java使用JMH進行簡單的基準測試Benchmark

  • Java 併發程式設計筆記:JMH 效能測試框架

  • JMH - Java Microbenchmark Harness