大家好,我是王有志,歡迎和我聊技術,聊漂泊在外的生活。快來加入我們的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;
}
Measurement
與Warmup
的使用方法完全一致,引數含義也完全相同,區別在於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));
}
這個例子中,test1
和test2
使用的是不同的State物件。
Tips:VM.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~~