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

Java 性能測試難題

現在的 JVM 已經越來越為智能,它可以在編譯階段、加載階段、運行階段對代碼進行優化。比如你寫了一段不怎麼聰明的代碼,到了 JVM 這裡,它發現幾處可以優化的地方,就順手幫你優化了一把。這對程序的運行固然美妙,卻讓開發者不能準確了解程序的運行情況。在需要進行性能測試時,如果不知道 JVM 優化細節,可能會導致你的測試結果差之毫厘,失之千里,同樣的,Java 誕生之初就有一次編譯、隨處運行的口號,JVM 提供了底層支持,也提供了內存管理機制,這些機制都會對我們的性能測試結果造成不可預測的影響。

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 版本。

<!--jmh 基準測試 -->
<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>
    <scope>provided</scope>
</dependency>

快速測試

下面使用註解的方式指定測試參數,通過一個例子展示 JMH 基準測試的具體用法,先看一次運行效果,然後再了解每個註解的具體含義。

這個例子是使用 JMH 測試,使用加號拼接字符串和使用 StringBuilderappend 方法拼接字符串時的速度如何,每次拼接1000個數字進行平均速度比較。

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;

/**
 * <p>
 * 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 方法,稍等片刻後可以得到詳細的運行輸出結果。

// 開始測試 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,可見 StringBuilderappend 方法進行字符串拼接速度快的多,這也是我們推薦使用 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 方法中指定的級別最高。

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 經過檢查發現的根本不會使用到的代碼。比如下面這個代碼片段。

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;

/**
 * <p>
 * 測試死代碼消除
 *
 * @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 會自動消除這段代碼, 因為它沒有任何意義。

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 源文件編譯的過程中,編譯器通過語法分析,可以發現某些能直接得到計算結果而不會再次更改的代碼,然後會將計算結果記錄下來,這樣在執行的過程中就不需要再次運算了。比如這段代碼。

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;

/**
 * <p>
 * 測試常量摺疊
 * 
 * @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();
    }
}

test1 中使用 final 修飾的 PI1 進行對象計算,因為 PI1 不能再次更改,所以 test1 的計算結果必定是不會更改的,所以 JVM 會進行常量摺疊優化,而 test2 使用的 PI2 可能會被修改,所以只能每次進行計算。

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 嗎?

參考:

最後的話

文章已經收錄在 Github.com/niumoo/JavaNotes ,歡迎Star和指教。更有一線大廠面試點,Java程序員需要掌握的核心知識等文章,也整理了很多我的文字,歡迎 Star 和完善,希望我們一起變得優秀。

文章有幫助可以點個「」或「分享」,都是支持,我都喜歡!
文章每周持續更新,要實時關注我更新的文章以及分享的乾貨,可以關注「 未讀代碼 」公眾號或者我的博客

公眾號