基準測試框架JMH快速上手

關注“Java藝術”一起來充電吧!

JMHJava Microbenchmark Harness,是Java用來做基準測試的一個工具,該工具由OpenJDK提供並維護,測試結果可信度高。

基準測試Benchmark是測量、評估軟件性能指標的一種測試,對某個特定目標場景的某項性能指標進行定量的和可對比的測試。


項目中添加依賴

創建一個基準測試項目,在項目中引入JMHjar包,目前JMH的最新版本爲1.23。以maven爲例,依賴配置如下。

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

另外,我們也可以直接用在需要進行基準測試的項目中,以單元測試方式使用。


註解方式使用

在運行時,註解配置被用於解析生成BenchmarkListEntry配置類實例。

一個方法對應一個@Benchmark註解,一個@Benchmark註解對應一個基準測試方法。

註釋在類上的註解,或者註釋在類的字段上的註解,則是類中所有基準測試方法共用的配置。

1

@Benchmark


聲明一個public方法爲基準測試方法。

public class JsonBenchmark {
    
    @Benchmark
    @Test // 因爲這是一個單元測試類,所以多了一個@Test註解,可以忽略
    public void testGson() {
        new GsonParser().fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

}


2

@BenchmarkMode

通過JMH我們可以輕鬆的測試出某個接口的吞吐量、平均執行時間等指標的數據。

假設我想測試testGson方法的平均耗時,那麼可以使用@BenchmarkMode註解指定測試維度爲Mode.AverageTime

public class JsonBenchmark {
    
    @BenchmarkMode(Mode.AverageTime) // 指定mode爲Mode.AverageTime
    @Benchmark
    @Test // 因爲這是一個單元測試類,所以多了一個@Test註解,可以忽略
    public void testGson() {
        new GsonParser().fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

}


3

@Measurement

假設我想執行testGson方法五次,取五次執行的耗時平均值,那麼可以使用@Measurement註解。

public class JsonBenchmark {
    
    @BenchmarkMode(Mode.AverageTime) // 指定mode爲Mode.AverageTime
    @Benchmark
    @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
    @Test // 因爲這是一個單元測試類,所以多了一個@Test註解,可以忽略
    public void testGson() {
        new GsonParser().fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

}

@Measurement註解有三個配置項:

  • iterations:迭代次數,即執行多少次該方法;

  • time與timeUnit:執行一次等待的時間,timeUnit指定時間單位,本例中:執行完一次等待一秒鐘再執行下一次。


4

@Warmup

爲了數據準確,我們可能需要讓testGson方法做下熱身運動。如在方法中創建GsonParser對象,預熱可以避免首次創建GsonParser時因多了類加載的耗時而導致測試結果不準備的情況。jvm使用JIT即時編譯器,一定的預熱次數可讓JITtestGson方法的調用鏈路完成編譯,去掉解釋執行對測試結果的影響。

@Warmup註解用於配置預熱參數。

public class JsonBenchmark {
    
    @BenchmarkMode(Mode.AverageTime) // 指定mode爲Mode.AverageTime
    @Benchmark
    @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
    @Test // 因爲這是一個單元測試類,所以多了一個@Test註解,可以忽略
    public void testGson() {
        new GsonParser().fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

}

@Warmup註解有三個配置項:

  • iterations:迭代次數,預熱執行多少次該方法;

  • time與timeUnit:執行一次等待的時間,timeUnit指定時間單位,本例中:執行完一次等待一秒鐘再執行下一次。

@Warmup@Measurement註解的配置項是一樣的。方法總的迭代執行次數等於@Warmup@Measurement註解的iterations的總和,前@Warmup指定的iterations次數不參與統計。

5

@OutputTimeUnit

OutputTimeUnit註解用於指定輸出的方法執行耗時的單位。如果方法執行耗時爲秒級別,爲了便於 觀察結果,我們可以使用@OutputTimeUnit指定輸出的耗時時間單位爲秒;如果方法執行耗時爲毫秒級別,爲了便於觀察結果,我們可以使用@OutputTimeUnit指定輸出的耗時時間單位爲毫秒,否則使用默認的秒做單位,會輸出10的負幾次方這樣的數字,不太直觀。

public class JsonBenchmark {
    
    @BenchmarkMode(Mode.AverageTime) // 指定mode爲Mode.AverageTime
    @Benchmark
    @OutputTimeUnit(TimeUnit.NANOSECONDS) // 指定輸出的耗時時長的單位
    @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
    @Test // 因爲這是一個單元測試類,所以多了一個@Test註解,可以忽略
    public void testGson() {
        new GsonParser().fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

}


6

@Fork

@Fork用於指定fork出多少個子進程來執行同一基準測試方法。假設我們不需要多個進程,那麼 可以使用@Fork指定爲進程數爲1。

public class JsonBenchmark {
    
    @BenchmarkMode(Mode.AverageTime) // 指定mode爲Mode.AverageTime
    @Benchmark
    @Fork(1)
    @OutputTimeUnit(TimeUnit.NANOSECONDS) // 指定輸出的耗時時長的單位
    @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
    @Test // 因爲這是一個單元測試類,所以多了一個@Test註解,可以忽略
    public void testGson() {
        new GsonParser().fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

}


7

@Threads

@Threads註解用於指定使用多少個線程來執行基準測試方法,如果使用@Threads指定線程數爲2,且使用@Fork指定進程數爲2,那麼兩個進程都會分別創建兩個線程來執行@Measurement指定的iterations次基準測試方法。一個進程下的兩個線程執行的方法次數總和等於@Measurement配置的iterations

public class JsonBenchmark {
    
    @BenchmarkMode(Mode.AverageTime) // 指定mode爲Mode.AverageTime
    @Benchmark
    @Fork(1)
    @Threads(2)
    @OutputTimeUnit(TimeUnit.NANOSECONDS) // 指定輸出的耗時時長的單位
    @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
    @Test // 因爲這是一個單元測試類,所以多了一個@Test註解,可以忽略
    public void testGson() {
        new GsonParser().fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

}


8

公共註解

假設我們需要在JsonBenchmark類中創建兩個基準測試方法,一個是testGson,另一個是testJackson,用於對比GsonJackson這兩個框架解析json的性能。那麼我們可以將除@Benchmark註解外的其它註解都聲明到類上,讓兩個基準測試方法都使用同樣的配置。

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Threads(2)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
public class JsonBenchmark {

    @Benchmark
    @Test
    public void testGson() {
        new GsonParser().fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

    @Benchmark
    @Test
    public void testJackson() {
        new JacksonParser().fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }
}

如果不想每執行一次方法都創建一個GsonParserJacksonParser實例,我們可以將GsonParserJacksonParser對象聲明爲JsonBenchmark的字段。(GsonParserJacksonParser是筆者封裝的工具類,配合設計模式使用,爲項目提供隨時切換解析框架的功能)。

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Threads(2)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@State(Scope.Benchmark)
public class JsonBenchmark {

     private GsonParser gsonParser = new GsonParser();
     private JacksonParser jacksonParser = new JacksonParser();

    @Benchmark
    @Test
    public void testGson() {
        gsonParser.fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

    @Benchmark
    @Test
    public void testJackson() {
        jacksonParser.fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }
}

還需要使用@State註解指定字段的共享域。在本例中,我們使用@Threads註解聲明創建兩個線程來執行基準測試方法,假設我們配置@State(Scope.Thread),那麼在不同線程中,gsonParserjacksonParser這兩個字段都是不同的實例。

testGson方法爲例,我們可以認爲JMH會爲每個線程克隆出一個gsonParser對象。如果在testGson方法中打印gsonParser對象的hashCode,你會發現,相同線程打印的結果相同,不同線程打印的結果不同。例如:

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Threads(2)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@State(Scope.Benchmark)
public class JsonBenchmark {

     private GsonParser gsonParser = new GsonParser();
     private JacksonParser jacksonParser = new JacksonParser();

    @Benchmark
    @Test
    public void testGson() {
        System.out.println("current Thread:" + Thread.currentThread().getName() + "==>" + gsonParser.hashCode());
        gsonParser.fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }

    @Benchmark
    @Test
    public void testJackson() {
        jacksonParser.fromJson("{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}", JsonTestModel.class);
    }
}

執行testGson方法輸出的結果如下:

current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-1==>2063684770
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-2==>1629232880
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-1==>2063684770
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-2==>1629232880
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-1==>2063684770
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-2==>1629232880
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-1==>2063684770
current Thread:com.msyc.common.JsonBenchmark.testGson-jmh-worker-2==>1629232880
......


9

@Param

使用@Param註解可指定基準方法執行參數,@Param註解只能指定String類型的值,可以是一個數組,參數值將在運行期間按給定順序遍歷。假設@Param註解指定了多個參數值,那麼JMH會爲每個參數值執行一次基準測試。

例如,我們想測試不同複雜度的json字符串使用Gson框架與使用Jackson框架解析的性能對比,代碼如下。

@BenchmarkMode(Mode.AverageTime)
@Fork(1)
@Threads(2)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@State(Scope.Thread)
public class JsonBenchmark {

    private GsonParser gsonParser = new GsonParser();
    private JacksonParser jacksonParser = new JacksonParser();

    // 指定參數有三個值
    @Param(value =
                {"{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 13:00:00\",\"flag\":true,\"threads\":5,\"shardingIndex\":0}",
                "{\"startDate\":\"2020-04-01 16:00:00\",\"endDate\":\"2020-05-20 14:00:00\"}",
                "{\"flag\":true,\"threads\":5,\"shardingIndex\":0}"})
    private String jsonStr;

    @Benchmark
    @Test
    public void testGson() {
        gsonParser.fromJson(jsonStr, JsonTestModel.class);
    }

    @Benchmark
    @Test
    public void testJackson() {
        jacksonParser.fromJson(jsonStr, JsonTestModel.class);
    }

}

測試結果如下:

Benchmark                                                                                                                      (jsonStr)  Mode  Cnt      Score       Error  Units
JsonBenchmark.testGson     {"startDate":"2020-04-01 16:00:00","endDate":"2020-05-20 13:00:00","flag":true,"threads":5,"shardingIndex":0}  avgt    5  12180.763 ±  2481.973  ns/op
JsonBenchmark.testGson                                               {"startDate":"2020-04-01 16:00:00","endDate":"2020-05-20 14:00:00"}  avgt    5   8154.709 ±  3393.881  ns/op
JsonBenchmark.testGson                                                                       {"flag":true,"threads":5,"shardingIndex":0}  avgt    5   9994.171 ±  5737.958  ns/op
JsonBenchmark.testJackson  {"startDate":"2020-04-01 16:00:00","endDate":"2020-05-20 13:00:00","flag":true,"threads":5,"shardingIndex":0}  avgt    5  15663.060 ±  9042.672  ns/op
JsonBenchmark.testJackson                                            {"startDate":"2020-04-01 16:00:00","endDate":"2020-05-20 14:00:00"}  avgt    5  13776.828 ± 11006.412  ns/op
JsonBenchmark.testJackson                                                                    {"flag":true,"threads":5,"shardingIndex":0}  avgt    5   9824.283 ±   311.555  ns/op


非註解使用

使用註解與不使用註解其實都是一樣,只不過使用註解更加方便。在運行時,註解配置被用於解析生成BenchmarkListEntry配置類實例,而在代碼中使用Options配置也是被解析成一個個BenchmarkListEntry配置類實例(每個方法對應一個)。

非註解方式我們可以使用OptionsBuilder構造一個Options,例如,非註解方式實現上面的例子。

public class BenchmarkTest{

    @Test
    public void test() throws RunnerException {
        Options options = new OptionsBuilder()
                .include(JsonBenchmark.class.getSimpleName())
                .exclude("testJackson")
                .forks(1)
                .threads(2)
                .timeUnit(TimeUnit.NANOSECONDS)
                .warmupIterations(5)
                .warmupTime(TimeValue.seconds(1))
                .measurementIterations(5)
                .measurementTime(TimeValue.seconds(1))
                .mode(Mode.AverageTime)
                .build();
        new Runner(options).run();
    }

}
  • include:導入一個基準測試類。調用方法傳遞的是類的簡單名稱,不含包名。

  • exclude:排除哪些方法。默認JMH會爲include導入的類的每個public方法都生成一個BenchmarkListEntry配置類實例,也就是把每個public方法都當成是基準測試方法,這時我們就可以使用exclude排除不需要參與基準測試的方法。例如本例中使用exclude排除了testJackson方法。

打JAR包放服務器上執行

對於大型的測試,需要測試時間比較久、線程比較多的情況,我們可以將寫好的基準測試項目打包成jar包丟到linux服務器上執行。對於吞吐量基準測試,建議放到服務器上執行,其結果會更準確一些,硬件、系統貼近線上環境、也不受本機開啓的應用數、硬件配置等因素影響。

java -jar my-benchmarks.jar


在IDEA中執行

對於一般的方法執行耗時測試,我們不需要把測試放到服務器上執行,例如測試對比幾個json解析框架的性能。

idea中,我們可以編寫一個單元測試方法,在單元測試方法中創建一個org.openjdk.jmh.runner.Runner,調用Runnerrun方法執行基準測試。但JMH不會去掃描包,不會執行每個基準測試方法,這需要我們通過配置項來告知JMH需要執行哪些基準測試方法。

public class BenchmarkTest{

    @Test
    public void test() throws RunnerException {
        Options options = null; // 創建Options
        new Runner(options).run();
    }

}

完整例子如下:

public class BenchmarkTest{
     @Test
     public void test() throws RunnerException {
        Options options = new OptionsBuilder()
                 .include(JsonBenchmark.class.getSimpleName())
                 // .output("/tmp/json_benchmark.log")
                 .build();
        new Runner(options).run();
     }
}

Options在前面已經介紹過了,由於本例中JsonBenchmark這個類已經使用了註解,因此Options只需要配置需要執行基準測試的類。如果需要執行多個基準測試類,include方法可以多次調用。

如果需要將測試結果輸出到文件,可調用output方法配置文件路徑,不配置則輸出到控制檯。


在IDEA中使用插件JMH Plugin執行

插件源碼地址:https://github.com/artyushov/idea-jmh-plugin

安裝:在IDEA中搜索JMH Plugin,安裝後重啓即可使用。

  • 1、只執行單個Benchmark方法

在方法名稱所在行,IDEA會有一個▶️執行符號,右鍵點擊運行即可。如果寫的是單元測試方法, IDEA會提示你選擇執行單元測試還是基準測試。

  • 2、執行一個類中的所有Benchmark方法

在類名所在行,IDEA會有一個▶️執行符號,右鍵點擊運行,該類下的所有被@Benchmark註解註釋的方法都會執行。如果寫的是單元測試方法,IDEA會提示你選擇執行單元測試還是基準測試。

官方提供的JMH使用例子

  • 官方的demo

http://hg.openjdk.java.net/code-tools/jmh/file/tip/jmh-samples/src/main/java/org/openjdk/jmh/samples/
  • 翻譯的demo

https://github.com/Childe-Chen/goodGoodStudy/tree/master/src/main/java/com/cxd/benchmark

公衆號:Java藝術

掃碼關注最新動態

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章