JMH--一款由OpenJDK開發的基準測試工具

子月生發表於2020-08-29

什麼是JMH

JMH 是 OpenJDK 團隊開發的一款基準測試工具,一般用於程式碼的效能調優,精度甚至可以達到納秒級別,適用於 java 以及其他基於 JVM 的語言。和 Apache JMeter 不同,JMH 測試的物件可以是任一方法,顆粒度更小,而不僅限於rest api。

使用時,我們只需要通過配置告訴 JMH 測試哪些方法以及如何測試,JMH 就可以為我們自動生成基準測試的程式碼

JMH生成基準測試程式碼的原理

我們只需要通過配置(主要是註解)告訴 JMH 測試哪些方法以及如何測試,JMH 就可以為我們自動生成基準測試的程式碼。

那麼 JMH 是如何做到的呢?

要使用 JMH,我們的 JMH 配置專案必須是 maven 專案。在一個 JMH配置專案中,我們可以在pom.xml看到以下配置。JMH 自動生成基準測試程式碼的本質就是使用 maven 外掛的方式,在 package 階段對配置專案進行解析和包裝

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>2.2</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <finalName>${uberjar.name}</finalName>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>org.openjdk.jmh.Main</mainClass>
                                </transformer>
                            </transformers>
                            <filters>
                                <filter>
                                    <artifact>*:*</artifact>
                                    <excludes>
                                        <exclude>META-INF/*.SF</exclude>
                                        <exclude>META-INF/*.DSA</exclude>
                                        <exclude>META-INF/*.RSA</exclude>
                                    </excludes>
                                </filter>
                            </filters>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

從入門例子開始

下面會先介紹整個使用流程,再通過一個入門例子來演示如何使用 JMH。

步驟

如果我有一個 A 專案,我希望對這個專案裡的某些方法進行 JMH 測試,可以這麼做:

  1. 建立單獨的 JMH 配置專案B。

新建一個獨立的配置專案 B(建議使用 archetype 生成,可以確保配置正確),B 依賴了 A。

當然,我們也可以直接將專案 A 作為 JMH 配置專案,但這樣做會導致 JMH 滲透到 A 專案中,所以,最好不要這麼做。

  1. 配置專案B

在 B 專案裡面,我們可以使用 JMH 的註解或物件來指定測試哪些方法以及如何測試,等等。

  1. 構建和執行

在正確配置 pom.xml 的前提下,使用 mvn 命令打包 B 專案,JMH 會為我們自動生成基準測試程式碼,並單獨打包成 benchmarks.jar。執行 benchmarks.jar,基準測試就可以跑起來了。

注意,JMH 也支援使用 Java API 的方式來執行,但官方並不推薦,所以,本文也不會介紹。

下面開始入門例子。

專案環境說明

maven:3.6.3

作業系統:win10

JDK:8u231

JMH:1.25

建立 JMH 配置專案

為了保證配置的正確性,建議使用 archetype 生成 JMH 配置專案。cmd 執行下面這段程式碼:

mvn archetype:generate ^
-DinteractiveMode=false ^
-DarchetypeGroupId=org.openjdk.jmh ^
-DarchetypeArtifactId=jmh-java-benchmark-archetype ^
-DarchetypeVersion=1.25 ^
-DgroupId=cn.zzs.jmh ^
-DartifactId=jmh-test01 ^
-Dversion=1.0.0

注:如果使用 linux,請將“^”替代為“\”。

命令執行後,在當前目錄下生成了一個 maven 專案,如下。這個專案就是本文說到的 JMH 配置專案。這裡 archetype 還提供了一個例子MyBenchmark

└─jmh-test01
    │  pom.xml
    │
    └─src
        └─main
            └─java
                └─cn
                    └─zzs
                        └─jmh
                                MyBenchmark.java

配置 JMH 配置專案

配置 pom.xml

因為是使用 archetype 生成的專案,所以pom.xml 檔案已經包含了比較完整的 JMH 配置,如下(省略部分)。如果自己手動建立配置專案,則需要拷貝下面這些內容。

    <dependencies>
        <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>
    </dependencies>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <jmh.version>1.25</jmh.version>
        <javac.target>1.8</javac.target>
        <uberjar.name>benchmarks</uberjar.name>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.2.1</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <finalName>${uberjar.name}</finalName>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>org.openjdk.jmh.Main</mainClass>
                                </transformer>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
                            </transformers>
                            <filters>
                                <filter>
                                    <artifact>*:*</artifact>
                                    <excludes>
                                        <exclude>META-INF/*.SF</exclude>
                                        <exclude>META-INF/*.DSA</exclude>
                                        <exclude>META-INF/*.RSA</exclude>
                                    </excludes>
                                </filter>
                            </filters>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

配置Benchmark方法

專案裡的 MyBenchmark 類就是一個簡單的示例,testMethod 方法就是一個 Benchmark 方法。我們可以直接在 testMethod 方法中編寫測試程式碼,也可以呼叫父專案的方法。

testMethod 方法上加了@Benchmark註解,@Benchmark註解用來告訴 JMH 在 mvn package 時生成這個方法的基準測試程式碼

當然,我們還可以增加其他的配置來影響 JMH 如何生成基準測試程式碼,這裡暫時不展開。

package cn.zzs.jmh;

import org.openjdk.jmh.annotations.Benchmark;

public class MyBenchmark {

    @Benchmark
    public void testMethod() {
        // place your benchmarked code here
    }

}

打包和執行

分別執行以下命令,完成對專案的打包:

cd jmh-test01
mvn clean package

這時,target 目錄下,不僅生成了專案本身的 jar 包,還生成了一個 benchmarks.jar。這個包就是 JMH 為我們生成的基準測試程式碼。

└─jmh-test01
    │  pom.xml
    │
    ├─src
    │  └─main
    │      └─java
    │          └─cn
    │              └─zzs
    │                  └─jmh
    │                          MyBenchmark.java
    │
    └─target
          benchmarks.jar
          jmh-test01-1.0.0.jar

執行以下命令:

java -jar target/benchmarks.jar

這時,我們的基準測試就開始執行了。

D:\growUp\git_repository\java-tools\jmh-demo\jmh-test01>java -jar target/benchmarks.jar
# JMH version: 1.25
# VM version: JDK 1.8.0_231, Java HotSpot(TM) 64-Bit Server VM, 25.231-b11
# VM invoker: D:\growUp\installation\jdk1.8.0_231\jre\bin\java.exe
# VM options: <none>
# Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: cn.zzs.jmh.MyBenchmark.testMethod

# Run progress: 0.00% complete, ETA 00:08:20
# Fork: 1 of 5
# Warmup Iteration   1: 3955731078.669 ops/s
# Warmup Iteration   2: 3910971792.656 ops/s
# Warmup Iteration   3: 3881001464.578 ops/s
# Warmup Iteration   4: 3916172600.571 ops/s
# Warmup Iteration   5: 3956321997.093 ops/s
Iteration   1: 3942596162.384 ops/s
Iteration   2: 3962073081.983 ops/s
Iteration   3: 3956347169.335 ops/s
Iteration   4: 3935835073.222 ops/s
Iteration   5: 3934716909.315 ops/s

# ······

# Run progress: 80.00% complete, ETA 00:01:40
# Fork: 5 of 5
# Warmup Iteration   1: 3398845405.179 ops/s
# Warmup Iteration   2: 3716777120.646 ops/s
# Warmup Iteration   3: 3414803497.798 ops/s
# Warmup Iteration   4: 3621211396.229 ops/s
# Warmup Iteration   5: 3616308570.681 ops/s
Iteration   1: 3898056365.287 ops/s
Iteration   2: 3935143498.460 ops/s
Iteration   3: 3943901632.014 ops/s
Iteration   4: 3906292827.077 ops/s
Iteration   5: 3918607665.065 ops/s


Result "cn.zzs.jmh.MyBenchmark.testMethod":
  3949010528.035 ±(99.9%) 16881035.344 ops/s [Average]
  (min, avg, max) = (3898056365.287, 3949010528.035, 3975167080.768), stdev = 22535699.213
  CI (99.9%): [3932129492.691, 3965891563.378] (assumes normal distribution)

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

Benchmark                Mode  Cnt           Score          Error  Units
MyBenchmark.testMethod  thrpt   25  3949010528.035 ± 16881035.344  ops/s

在頭部分列印了MyBenchmark.testMethod這個 Benchmark 方法的配置資訊,如下:

# JMH version: 1.25
# VM version: JDK 1.8.0_231, Java HotSpot(TM) 64-Bit Server VM, 25.231-b11
# VM invoker: D:\growUp\installation\jdk1.8.0_231\jre\bin\java.exe
# VM options: <none>  
# Warmup: 5 iterations, 10 s each  ---------------預熱5個迭代,每個迭代10s
# Measurement: 5 iterations, 10 s each------------正式測試5個迭代,每個迭代10s
# Timeout: 10 min per iteration-------------------每個迭代的超時時間10min
# Threads: 1 thread, will synchronize iterations--使用1個執行緒測試
# Benchmark mode: Throughput, ops/time------------使用吞吐量作為測試指標
# Benchmark: cn.zzs.jmh.MyBenchmark.testMethod

在最後列印了這個 Benchmark 方法的測試結果,如下。它的吞吐是 3949010528.035 ± 16881035.344 ops/s。注意,一個 Benchmark 的測試結果是沒有意義的,只有多個 Benchmark 對比才可能得出結論


Benchmark                Mode  Cnt           Score          Error  Units
MyBenchmark.testMethod  thrpt   25  3949010528.035 ± 16881035.344  ops/s

詳細配置

通過上面的入門例子簡單介紹瞭如何使用 JMH,接下來將繼續對Benchmark 方法的配置 。針對這一點,官方沒有給出具體的文件,而是提供了 30 多個示例程式碼供我們學習JMH Samples

這些示例程式碼並不好讀懂,尤其是涉及到 JVM 的部分。其實,只要我們讀懂 1-11、20 的例子就行,這些例子已經足夠我們日常使用。

至於其他的,大多是介紹 JVM 或者本地機器的某些機制將影響到測試的準確性,以及通過什麼方法減少這些影響,非常難懂。本文不會介紹這部分內容,大部分情況下,JMH 已經儘量為我們遮蔽這些因素帶來的影響,我們只要使用預設配置就可以。

以下只針對 1-11、20 的例子進行總結和補充。有誤的地方,歡迎指正。

在介紹以下內容之前,這裡先介紹下一個 Benchmark 方法的組成部分(只是一個大致結果,並不準確),如下。要很好地理解後面的內容,最後掌握這個結構。

//Benchmark
public void Benchmark01(){
    // ······
    // 預熱
    // 每個迴圈為一個iteration
    for(iterations){
        // 每個迴圈為一個invocation
    	while(!timeout){
            // 呼叫我們的測試方法
        }
	}
	// ······
    // 測試
    // 每個迴圈為一個iteration
    for(iterations){
        // 每個迴圈為一個invocation,這裡會統計每次invocation的開銷
    	while(!timeout){
            // 呼叫我們的測試方法
        }
	}
    // ······
}

@Benchmark

@Benchmark用於告訴 JMH 哪些方法需要進行測試,只能註解在方法上,有點類似 junit 的@Test。在測試專案進行 package 時,JMH 會針對註解了@Benchmark的方法生成 Benchmark 方法程式碼。

    @Benchmark
    public void wellHelloThere() {
        // this method was intentionally left blank.
    }

通常情況下,每個 Benchmark 方法都執行在獨立的程式中,互不干涉。

@BenchmarkMode

@BenchmarkMode用於指定當前 Benchmark 方法使用哪種模式測試。JMH 提供了4種不同的模式,用於輸出不同的結果指標,如下:

模式 描述
Throughput 吞吐量,ops/time。單位時間內執行操作的平均次數
AverageTime 每次操作所需時間,time/op。執行每次操作所需的平均時間
SampleTime 同 AverageTime。區別在於 SampleTime 會統計取樣 x% 達到了多少 time/op,如下。
sample_time_01
SingleShotTime 同 AverageTime。區別在於 SingleShotTime 只執行一次操作。這種模式的結果存在較大隨機性。

@BenchmarkMode支援陣列,也就是說可以為同一個方法同時指定多種模式,生成基準測試程式碼時,JMH 將按照不同模式分別生成多個獨立的 Benchmark 方法。另外,我們可以使用@OutputTimeUnit來指定時間單位,可以精確到納秒級別。

    /*
     * 使用一種模式
     */
    @Benchmark
    @BenchmarkMode(Mode.Throughput)
    @OutputTimeUnit(TimeUnit.SECONDS)
    public void measureThroughput() throws InterruptedException {
        TimeUnit.MILLISECONDS.sleep(100);
    }
    /*
     * 使用多種模式
     */
    @Benchmark
    @BenchmarkMode({Mode.Throughput, Mode.AverageTime, Mode.SampleTime, Mode.SingleShotTime})
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public void measureMultiple() throws InterruptedException {
        TimeUnit.MILLISECONDS.sleep(100);
    }
    /*
     * 使用所有模式
     */
    @Benchmark
    @BenchmarkMode(Mode.All)
    @OutputTimeUnit(TimeUnit.MICROSECONDS)
    public void measureAll() throws InterruptedException {
        TimeUnit.MILLISECONDS.sleep(100);
    }

@Warmup和@Measurement

@Warmup@Measurement分別用於配置預熱迭代和測試迭代。其中,iterations 用於指定迭代次數,time 和 timeUnit 用於每個迭代的時間,batchSize 表示執行多少次 Benchmark 方法為一個 invocation。

    @Benchmark
    @Warmup(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS, batchSize = 10)
    @Measurement(iterations = 5, time = 100, timeUnit = TimeUnit.MILLISECONDS, batchSize = 10)
    public double measure() {
        //······
    }

@State

個人理解,State 就是被注入到 Benchmark 方法中的物件,它的資料和方法可以被 Benchmark 方法使用。在 JMH 中,註解了@State的類在測試專案進行 package 時可以被注入到 Benchmark 方法中。

配置方式

State 的配置方式有兩種。

第一種是 Benchmark 不在 State 的類裡。這時需要在測試方法的入參列表裡顯式注入該 State。

public class JMHSample_03_States {

    @State(Scope.Benchmark)
    public static class BenchmarkState {
        volatile double x = Math.PI;
    }

    @State(Scope.Thread)
    public static class ThreadState {
        volatile double x = Math.PI;
    }

    @Benchmark
    public void measureUnshared(ThreadState state) {
        state.x++;
    }

    @Benchmark
    public void measureShared(BenchmarkState state) {
        state.x++;
    }
}

第二種是 Benchmark 在 State 的類裡。這時不需要在測試方法的入參列表裡顯式注入該 State。

@State(Scope.Thread)
public class JMHSample_04_DefaultState {

    double x = Math.PI;

    @Benchmark
    public void measure() {
        x++;
    }

}

Scope

Scope 是@State的屬性,用於描述 State 的作用範圍,主要有三種:

scope 描述
Benchmark Benchmark 中所有執行緒都使用同一個 State
Group Benchmark 中同一 Benchmark 組(使用@Group標識,後面再講)使用一個 State
Thread Benchmark 中每個執行緒使用同一個 State

@Setup 和 @TearDown

這兩個註解只能定義在註解了 State 裡,其中,@Setup類似於 junit 的@Before,而@TearDown類似於 junit 的@After

@State(Scope.Thread)
public class JMHSample_05_StateFixtures {

    double x;

    @Setup(Level.Iteration)
    public void prepare() {
        System.err.println("init............");
        x = Math.PI;
    }

    @TearDown(Level.Iteration)
    public void check() {
        System.err.println("destroy............");
        assert x > Math.PI : "Nothing changed?";
    }


    @Benchmark
    public void measureRight() {
        x++;
    }

}

這兩個註解註釋的方法的呼叫時機,主要受 Level 的控制,JMH 提供了三種 Level,如下:

  1. Trial

Benchmark 開始前或結束後執行,如下。Level 為 Benchmark 的 Setup 和 TearDown 方法的開銷不會計入到最終結果。

//Benchmark
public void Benchmark01(){
    // call Setup method
    // 每個迴圈為一個iteration
    for(iterations){
        // 每個迴圈為一個invocation,這裡會統計每次invocation的開銷
    	while(!timeout){
            // 呼叫我們的測試方法
        }
	}
    // call TearDown method
}
  1. Iteration

Benchmark 裡每個 Iteration 開始前或結束後執行,如下。Level 為 Iteration 的 Setup 和 TearDown 方法的開銷不會計入到最終結果。

//Benchmark
public void Benchmark01(){
    // 每個迴圈為一個iteration
    for(iterations){
        // call Setup method
        // 每個迴圈為一個invocation,這裡會統計每次invocation的開銷
    	while(!timeout){
            // 呼叫我們的測試方法
        }
        // call TearDown method
	}
}
  1. Invocation

Iteration 裡每次方法呼叫開始前或結束後執行,如下。Level 為 Invocation 的 Setup 和 TearDown 方法的開銷將計入到最終結果

//Benchmark
public void Benchmark01(){
    // 每個迴圈為一個iteration
    for(iterations){
        // 每個迴圈為一個invocation,這裡會統計每次invocation的開銷
    	while(!timeout){
            // call Setup method
            // 呼叫我們的測試方法
            // call TearDown method
        }
	}
}

以上內容基本可以滿足 JMH 的日常使用需求,至於其他示例的內容,後面有空再做補充。

參考資料

openjdk官網

相關原始碼請移步:https://github.com/ZhangZiSheng001/jmh-demo

本文為原創文章,轉載請附上原文出處連結:https://www.cnblogs.com/ZhangZiSheng001/p/13581390.html

相關文章