如何在Java中做基準測試?JMH使用初體驗

王有志發表於2023-04-07

大家好,我是王有志,歡迎和我聊技術,聊漂泊在外的生活。快來加入我們的Java提桶跑路群:共同富裕的Java人

最近公司在搞新專案,由於是實驗性質,且不會直接面對客戶的專案,這次的技術選型非常激進,如,直接使用了Java 17。

作為公司裡練習兩年半的個人練習生,我自然也是深度的參與到了技術選型的工作中。不知道大家在技術選型中有沒有關注過技術元件給出的基準測試?比如說,HikariCP的基準測試

又或者是Caffeine的基準測試

如果你仔細閱讀過它們的基準測試報告,你會發現一項很有意思的技術:Java Microbenchmark Harness,簡稱JMH

Tips:有些技術只需要學會如何使用即可,沒有必要非得“卷”原始碼;有些“小眾”技術你沒有聽過,也不必慌,沒有人是什麼都會的。

認識JMH

接觸JMH之前,我通常用System.currentTimeMillis()來計算方法的執行時間:

long start = System.currentTimeMillis();
......
long duration = System.currentTimeMillis() - start;

大部分時候這麼做都很靈,但某些場景下JVM會進行JIT編譯和內聯最佳化,導致程式碼在最佳化前後的執行效率差別非常大,此時這個“土”方法就不靈了。那麼該如何準確的計算方法的執行時間呢?

Java團隊為開發者提供了JMH基準測試套件:

JMH is a Java harness for building, running, and analysing nano/micro/milli/macro benchmarks written in Java and other languages targeting the JVM.

JMH是用於構建,執行和分析Java和其它基於JVM的語言編寫的程式的基準測試套件。JMH提供了預熱的能力,透過預熱讓JVM知道哪些是熱點程式碼,除此之外,JMH還提供了吞吐量的測試指標。相較於“土”方法,JMH可以支援更多種的測試場景,而且基於JMH得出的測試結果也會更全面,更準確

使用JMH

專案中引入JMH的依賴:

<dependency>
  <groupId>org.openjdk.jmh</groupId>
  <artifactId>jmh-core</artifactId>
  <version>1.36</version>
  <scope>test</scope>
</dependency>
<dependency>  
  <groupId>org.openjdk.jmh</groupId>  
  <artifactId>jmh-generator-annprocess</artifactId>  
  <version>1.36</version>  
</dependency>

引入依賴後就可以編寫一個簡單的基準測試了,這裡使用簡化後的JMH官方示例

package org.openjdk.jmh.samples;

import org.openjdk.jmh.annotations.Benchmark;  
import org.openjdk.jmh.annotations.BenchmarkMode;  
import org.openjdk.jmh.annotations.Mode;  
import org.openjdk.jmh.annotations.OutputTimeUnit;  
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;  
  
import java.util.concurrent.TimeUnit;

public class JMHSample_02_BenchmarkModes {

  @Benchmark
  @BenchmarkMode(Mode.AverageTime)
  @OutputTimeUnit(TimeUnit.MILLISECONDS)
  public void measureAvgTime() throws InterruptedException {
    TimeUnit.MILLISECONDS.sleep(100);
  }
  
  public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder()
                .include(JMHSample_02_BenchmarkModes.class.getSimpleName())
                .forks(1)
                .build();
    new Runner(opt).run();
  }
}

執行這個示例,會輸出如下結果:

以空行為分割的話,JMH的輸出可以分為3個部分:

  • 基礎資訊,包括環境資訊和基準測試配置;
  • 測試資訊,每次預熱(Warmup)和正式執行(Iteration)的資訊;
  • 結果資訊,基準測試的結果。

Tips

  • IDEA中不能使用DeBug模式執行,否則會報錯
  • 注意依賴中的scope標籤為test,在src\main\java路徑下是無法訪問到JMH的。

啟動測試

從示例中不難發現,在IDEA中執行測試需要先構建Options,並透過Runner去執行。我們來構建一個最簡單的Options

Options opt = new OptionsBuilder().build();

new Runner(opt).run();

這樣的Options會執行散落在程式各處的基準測試方法(使用Benchmark註解的方法)。如果不需要執行所有的基準測試方法,通常在構建Options時會指定測試的範圍:

Options opt = new OptionsBuilder()
                  .include(JMHSample_02_BenchmarkModes.class.getSimpleName())
                  .build();

這時基準測試僅限於Test類中的基準測試方法。除此之外,你可能還會嫌棄控制檯輸出樣式醜陋,或者要提交的基準測試報告中需要用圖示來直觀的表達,這個時候可以控制輸出結果的格式並指定結果輸出檔案:

Options opt = new OptionsBuilder()
                  .include(JMHSample_02_BenchmarkModes.class.getSimpleName())
                  .result("result.json")
                  .resultFormat(ResultFormatType.JSON)
.build();

再結合以下網站,可以很輕鬆的構建出測試結果圖示:

例如,我透過JMH Visual Chart構建出的測試結果:

實際上,OptionsBuilder提供的功能遠不止如此,不過其中大部分功能都可以透過下文中提到註解進行配置,在此就不進行多餘的說明了。

常用註解

JMH可以透過註解非常簡單的完成基準測試的配置,接下來對其中常用的15個註解進行詳細說明。

註解:Benchmark

註解Benchmark的宣告:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Benchmark {
}

Benchmark用於方法上且該方法必須使用public修飾,表明該方法為基準測試方法

註解:BenchmarkMode

註解BenchmarkMode的宣告:

@Inherited  
@Target({ElementType.METHOD, ElementType.TYPE})  
@Retention(RetentionPolicy.RUNTIME)  
public @interface BenchmarkMode {
  Mode[] value();  
}

BenchmarkMode用於方法或類上,表明測試指標。列舉類Mode提供了4種測試指標:

  • Mode.Throughput吞吐量,單位時間內執行的次數;
  • Mode.AverageTime平均時間,執行方法的平均耗時;
  • Mode.SampleTime操作時間取樣,並輸出結果分佈;
  • Mode.SingleShotTime單次操作時間,通常在不進行預熱時測試冷啟動的時間。

我們來看下Mode.SampleTime的輸出結果:

除單獨使用以上測試指標外,還可以指定Mode.All進行全部指標的基準測試。

註解:OutputTimeUnit

註解OutputTimeUnit的宣告:

@Inherited
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface OutputTimeUnit { 
  TimeUnit value();
}

OutputTimeUnit用於方法或類上,表明輸出結果的時間單位。好了,示例中的註解我們已經瞭解完畢,接下來我們看其它較為關鍵的註解。

註解:Timeout

註解Timeout的宣告:

@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface Timeout {

  int time();
  
  TimeUnit timeUnit() default TimeUnit.SECONDS;
}

Timeout用於方法或類上,指定了基準測試方法的超時時間**。

註解:Warmup

註解Warmup的宣告:

@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface Warmup {
  int BLANK_ITERATIONS = -1;
  int BLANK_TIME = -1;
  int BLANK_BATCHSIZE = -1;

  int iterations() default BLANK_ITERATIONS;

  int time() default BLANK_TIME;

  TimeUnit timeUnit() default TimeUnit.SECONDS;

  int batchSize() default BLANK_BATCHSIZE;
}

Warmup用於方法或類上,用於做預熱配置。提供了4個引數:

  • iterations,預熱迭代的次數;
  • time,每個預熱迭代的時間;
  • timeUnit,時間單位;
  • batchSize,每個操作呼叫的次數。

預熱的執行結果並不會被統計到測試結果中,因為JIT機制的存在某些方法被反覆呼叫後,JVM會將其編譯為機器碼,使其執行效率大大提高。

註解:Measurement

註解Measurement的宣告:

@Inherited
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Measurement {
  int BLANK_ITERATIONS = -1;
  int BLANK_TIME = -1;
  int BLANK_BATCHSIZE = -1;

  int iterations() default BLANK_ITERATIONS;
  
  int time() default BLANK_TIME;
  
  TimeUnit timeUnit() default TimeUnit.SECONDS;
  
  int batchSize() default BLANK_BATCHSIZE;
}

MeasurementWarmup的使用方法完全一致,引數含義也完全相同,區別在於Measurement屬於正式測試的配置,結果會被統計

註解:Group

註解Group的宣告:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Group {
  String value() default "group";
}

Group用於方法上,為測試方法分組

註解:State

註解State的宣告:

@Inherited
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface State {
  Scope value();
}

State用於類上,表明了類中變數的作用範圍。列舉類Scope提供了3種作用域:

  • Scope.Benchmark,每個測試方法中使用一個變數;
  • Scope.Group,每個分組中使用同一個變數;
  • Scope.Thread,每個執行緒中使用同一個變數。

忘記了是在哪看到有人說Scope.Benchmark的作用域是所有的基準測試方法,這個是錯誤的,Scope.Benchmark會為每個基準測試方法生成一個物件,例如:

@State(Scope.Benchmark)
public static class ThreadState {
}

@Benchmark
@BenchmarkMode(Mode.SingleShotTime)
public void test1(State state) {
  System.out.println("test1執行" + VM.current().addressOf(state));
}

@Benchmark
@BenchmarkMode(Mode.SingleShotTime)
public void test2(State state) {
  System.out.println("test2執行" + VM.current().addressOf(state));
}

這個例子中,test1test2使用的是不同的State物件。

TipsVM.current().addressOf()jol-core中提供的功能。

註解:Setup

註解Setup的宣告:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Setup {
  Level value() default Level.Trial;
}

Setup用於方法上,基準測試前的初始化操作。列舉類Level提供了3個級別:

  • Level.Trial,所有基準測試執行時;
  • Level.Iteration,每次迭代時;
  • Level.Invocation,每次方法呼叫時。

Tips:一次迭代中,可能會出現多次方法呼叫。

註解:TearDown

註解TearDown的宣告:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TearDown {
  Level value() default Level.Trial;
}

TearDown用於方法上,與Setup的作用相反,是基準測試後的操作,同樣使用Level提供了3個級別。

註解:Param

註解Param的宣告:

@Inherited
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Param {
  
  String BLANK_ARGS = "blank_blank_blank_2014";

  String[] value() default { BLANK_ARGS };
}

Param用於欄位上,用於指定不同的引數,需要搭配State註解來使用。舉個例子:

@State(Scope.Benchmark)
public class Test {
  @Param({"10", "100", "1000", "10000"})
  int count;

  @Benchmark
  @Warmup(iterations = 0)
  @BenchmarkMode(Mode.SingleShotTime)
  public void loop() throws InterruptedException {
    for(int i = 0; i < count; i++) {
      TimeUnit.MILLISECONDS.sleep(1);
    }
  }
}

上述程式碼測試了程式在迴圈10次,100次,1000次和10000次時的效能。

註解:Threads

註解Threads的宣告:

@Inherited
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Threads {

  int MAX = -1;

  int value();  
}

Threads用於方法和類上,指定基準測試中的並行執行緒數。當使用MAX時,將會使用所有可用執行緒進行測試,即Runtime.getRuntime().availableProcessors()返回的執行緒數。

註解:GroupThreads

註解GroupThreads的宣告:

@Inherited
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface GroupThreads {
  int value() default 1;
}

GroupThreads用於方法上,指定基準測試分組中使用的執行緒數

註解:Fork

註解Fork的宣告:

@Inherited  
@Target({ElementType.METHOD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Fork {
  int BLANK_FORKS = -1;
  
  String BLANK_ARGS = "blank_blank_blank_2014";
  
  int value() default BLANK_FORKS;
  
  int warmups() default BLANK_FORKS;
  
  String jvm() default BLANK_ARGS;

  String[] jvmArgs() default { BLANK_ARGS };
  
  String[] jvmArgsPrepend() default { BLANK_ARGS };

  String[] jvmArgsAppend() default { BLANK_ARGS };
}

Fork用於方法和類上,指定基準測試中Fork的子程式Fork提供了6個引數:

  • value,表示Fork出的子程式數量;
  • warmups,預熱次數;
  • jvm,JVM的位置;
  • jvmArgs,需要替換的JVM引數;
  • jvmArgsPrepend,需要新增的JVM引數;
  • jvmArgsAppend,需要追加的JVM引數。

Fork設定為0時,JMH會在當前JVM中執行基準測試。由於可能處於使用者的JVM中,無法反應真實的服務端場景,無法準確的反應實際效能,因此JMH推薦進行 Fork設定

另外可以利用Fork提供的JVM設定,將JVM設定為Server模式:

@Fork(value = 1, jvmArgsAppend = {"-Xmx1024m", "-server"})

註解:CompilerControl

註解CompilerControl的宣告:

@Target({ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.TYPE})  
@Retention(RetentionPolicy.RUNTIME)  
public @interface CompilerControl {  
 
  Mode value();  
   
  enum Mode {  
    BREAK("break"),
    PRINT("print"),
    EXCLUDE("exclude"),
    INLINE("inline"),
    DONT_INLINE("dontinline"),
    COMPILE_ONLY("compileonly");
  }  
}

CompilerControl用於方法,構造器或類上,指定編譯方式。其內部列舉類提供了6種編譯方式:

  • BREAK,將斷點插入到編譯後的程式碼;
  • PRINT,列印方法及其配置;
  • EXCLUDE,禁止編譯;
  • INLINE,使用內聯;
  • DONT_INLINE,禁止內聯;
  • COMPILE_ONLY,僅編譯;

結語

關於JMH的使用,我們就聊到這裡了,希望今天的內容能夠幫助你學習並掌握一種更準確的效能測試方法。

最後提供一個練習使用JMH的思路:大家都看到了文章開頭Caffeine給出的基準測試結果了,但由於是Caffeine作者自己提供的基準測試,難免有些“既當裁判又當選手”的嫌疑,或者說他選取了一些對Caffeine有利的角度來展示結果,那麼可以結合你自己的實際使用場景,給Caffeine及其競品做一次基準測試。


好了,今天就到這裡了,Bye~~

相關文章