基準測試簡介
什麼是基準測試
基準測試是指通過設計科學的測試方法、測試工具和測試系統,實作對一類測試對象的某項性能名額進行定量的和可對比的測試。
現代軟體常常都把高性能作為目标。那麼,何為高性能,性能就是快,更快嗎?顯然,如果沒有一個量化的标準,難以衡量性能的好壞。
不同的基準測試其具體内容和範圍也存在很大的不同。如果是專業的性能工程師,更加熟悉的可能是類似SPEC提供的工業标準的系統級測試;而對于大多數 Java 開發者,更熟悉的則是範圍相對較小、關注點更加細節的微基準測試(Micro-Benchmark)。何謂 Micro Benchmark 呢? 簡單地說就是在 method 層面上的 benchmark,精度可以精确到 微秒級。
何時需要微基準測試
微基準測試大多是 API 級别的性能測試。
微基準測試的适用場景:
- 如果開發公共類庫、中間件,會被其他子產品經常調用的 API。
- 對于性能,如響應延遲、吞吐量有嚴格要求的核心 API。
JMH 簡介
JMH(即 Java Microbenchmark Harness),是目前主流的微基準測試架構。JMH 是由 Hotspot JVM 團隊專家開發的,除了支援完整的基準測試過程,包括預熱、運作、統計和報告等,還支援 Java 和其他 JVM 語言。更重要的是,它針對 Hotspot JVM 提供了各種特性,以保證基準測試的正确性,整體準确性大大優于其他架構,并且,JMH 還提供了用近乎白盒的方式進行 Profiling 等工作的能力。
為什麼需要 JMH
死碼消除
所謂死碼,是指注釋的代碼,不可達的代碼塊,可達但不被使用的代碼等等 。
常量折疊與常量傳播
常量折疊(Constant folding),是一個在編譯時期簡化常數的一個過程,常數在表示式中僅僅代表一個簡單的數值,就像是整數
2
,若是一個變數從未被修改也可作為常數,或者直接将一個變數被明确地被标注為常數,例如下面的描述:
i = 320 * 200 * 32;
多數的現代編譯器不會真的産生兩個乘法的指令再将結果儲存下來,取而代之的,他們會辨識出語句的結構,并在編譯時期将數值計算出來(在這個例子,結果為2,048,000),通常會在中介碼(IR,intermediate representation)樹中進行。
常量傳播(Constant propagation),是一個替代表示式中已知常數的過程,也是在編譯時期進行,包含前述所定義,内建函數也适用于常數,以下列描述為例:
int x = 14;
int y = 7 - x / 2;
return y * (28 / x + 2);
傳播x變數将會變成:
int x = 14;
int y = 7 - 14 / 2;
return y * (28 / 14 + 2);
持續傳播,則會變成:(還可以再進一步的消除無用程式碼x及y來進行最佳化)
int x = 14;
int y = 0;
return 0;
常數傳播在編譯器中使用定義可達性(Reaching definition)分析實作,如果一個變數的所有定義可達性,都是賦予相同的數值,那麼這個變數将會是一個常數,而且會被常數取代。
JMH 的注意點
- 測試前需要預熱。
- 防止無用代碼進入測試方法中。
- 測試方法要有傳回值(否則會被JIT優化掉)。
- 使用并發測試。
- 測試結果呈現。
應用場景
- 當你已經找出了熱點函數,而需要對熱點函數進行進一步的優化時,就可以使用 JMH 對優化的效果進行定量的分析。
- 想定量地知道某個函數需要執行多長時間,以及執行時間和輸入 n 的相關性。
- 一個函數有兩種不同實作(例如 JSON 序列化/反序列化有 Jackson 和 Gson 實作),不知道哪種實作性能更好。
JMH 概念
-
- iteration 是 JMH 進行測試的最小機關,包含一組 invocations。Iteration
-
- 一次 benchmark 方法調用。Invocation
-
- benchmark 方法中,被測量操作的執行。如果被測試的操作在 benchmark 方法中循環執行,可以使用Operation
表明循環次數,使測試結果為單次 operation 的性能。@OperationsPerInvocation
-
- 在實際進行 benchmark 前先進行預熱。因為某個函數被調用多次之後,JIT 會對其進行編譯,通過預熱可以使測量結果更加接近真實情況。Warmup
JMH 快速入門
方式一:添加 maven 依賴
<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>
<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>
方式二:初始化benchmark工程(官方推薦)
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DarchetypeVersion=1.25 \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.0
測試代碼
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.*;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3)
@Measurement(iterations = 10, time = 5, timeUnit = TimeUnit.SECONDS)
@Threads(8)
@Fork(2)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class StringBuilderBenchmark {
@Benchmark
public void testStringAdd(Blackhole bh) {
String a = "";
for (int i = 0; i < 10; i++) {
a += i;
}
bh.consume(a);
}
@Benchmark
public void testStringBuilderAdd(Blackhole bh) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++) {
sb.append(i);
}
bh.consume(sb.toString());
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(StringBuilderBenchmark.class.getSimpleName())
.output("d:/Benchmark.log")
.build();
new Runner(options).run();
}
}
執行 JMH
指令行
建構 benchmark
cd test/
mvn clean package
運作 benchmark
java -jar target/benchmarks.jar
執行 main 方法
執行 main 方法,耐心等待測試結果,最終會生成一個測試報告,内容大緻如下;
# JMH version: 1.22
# VM version: JDK 1.8.0_181, Java HotSpot(TM) 64-Bit Server VM, 25.181-b13
# VM invoker: C:\Program Files\Java\jdk1.8.0_181\jre\bin\java.exe
# VM options: -javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2019.2.3\lib\idea_rt.jar=58635:D:\Program Files\JetBrains\IntelliJ IDEA 2019.2.3\bin -Dfile.encoding=UTF-8
# Warmup: 3 iterations, 10 s each
# Measurement: 10 iterations, 5 s each
# Timeout: 10 min per iteration
# Threads: 8 threads, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: io.github.dunwu.javatech.jmh.StringBuilderBenchmark.testStringAdd
# Run progress: 0.00% complete, ETA 00:05:20
# Fork: 1 of 2
# Warmup Iteration 1: 21803.050 ops/ms
# Warmup Iteration 2: 22501.860 ops/ms
# Warmup Iteration 3: 20953.944 ops/ms
Iteration 1: 21627.645 ops/ms
Iteration 2: 21215.269 ops/ms
Iteration 3: 20863.282 ops/ms
Iteration 4: 21617.715 ops/ms
Iteration 5: 21695.645 ops/ms
Iteration 6: 21886.784 ops/ms
Iteration 7: 21986.899 ops/ms
Iteration 8: 22389.540 ops/ms
Iteration 9: 22507.313 ops/ms
Iteration 10: 22124.133 ops/ms
# Run progress: 25.00% complete, ETA 00:04:02
# Fork: 2 of 2
# Warmup Iteration 1: 22262.108 ops/ms
# Warmup Iteration 2: 21567.804 ops/ms
# Warmup Iteration 3: 21787.002 ops/ms
Iteration 1: 21598.970 ops/ms
Iteration 2: 22486.133 ops/ms
Iteration 3: 22157.834 ops/ms
Iteration 4: 22321.827 ops/ms
Iteration 5: 22477.063 ops/ms
Iteration 6: 22154.760 ops/ms
Iteration 7: 21561.095 ops/ms
Iteration 8: 22194.863 ops/ms
Iteration 9: 22493.844 ops/ms
Iteration 10: 22568.078 ops/ms
Result "io.github.dunwu.javatech.jmh.StringBuilderBenchmark.testStringAdd":
21996.435 ±(99.9%) 412.955 ops/ms [Average]
(min, avg, max) = (20863.282, 21996.435, 22568.078), stdev = 475.560
CI (99.9%): [21583.480, 22409.390] (assumes normal distribution)
# JMH version: 1.22
# VM version: JDK 1.8.0_181, Java HotSpot(TM) 64-Bit Server VM, 25.181-b13
# VM invoker: C:\Program Files\Java\jdk1.8.0_181\jre\bin\java.exe
# VM options: -javaagent:D:\Program Files\JetBrains\IntelliJ IDEA 2019.2.3\lib\idea_rt.jar=58635:D:\Program Files\JetBrains\IntelliJ IDEA 2019.2.3\bin -Dfile.encoding=UTF-8
# Warmup: 3 iterations, 10 s each
# Measurement: 10 iterations, 5 s each
# Timeout: 10 min per iteration
# Threads: 8 threads, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: io.github.dunwu.javatech.jmh.StringBuilderBenchmark.testStringBuilderAdd
# Run progress: 50.00% complete, ETA 00:02:41
# Fork: 1 of 2
# Warmup Iteration 1: 241500.886 ops/ms
# Warmup Iteration 2: 134206.032 ops/ms
# Warmup Iteration 3: 86907.846 ops/ms
Iteration 1: 86143.339 ops/ms
Iteration 2: 74725.356 ops/ms
Iteration 3: 72316.121 ops/ms
Iteration 4: 77319.716 ops/ms
Iteration 5: 83469.256 ops/ms
Iteration 6: 87712.360 ops/ms
Iteration 7: 79421.899 ops/ms
Iteration 8: 80867.839 ops/ms
Iteration 9: 82619.163 ops/ms
Iteration 10: 87026.928 ops/ms
# Run progress: 75.00% complete, ETA 00:01:20
# Fork: 2 of 2
# Warmup Iteration 1: 228342.337 ops/ms
# Warmup Iteration 2: 124737.248 ops/ms
# Warmup Iteration 3: 82598.851 ops/ms
Iteration 1: 86877.318 ops/ms
Iteration 2: 89388.624 ops/ms
Iteration 3: 88523.558 ops/ms
Iteration 4: 87547.332 ops/ms
Iteration 5: 88376.087 ops/ms
Iteration 6: 88848.837 ops/ms
Iteration 7: 85998.124 ops/ms
Iteration 8: 86796.998 ops/ms
Iteration 9: 87994.726 ops/ms
Iteration 10: 87784.453 ops/ms
Result "io.github.dunwu.javatech.jmh.StringBuilderBenchmark.testStringBuilderAdd":
84487.902 ±(99.9%) 4355.525 ops/ms [Average]
(min, avg, max) = (72316.121, 84487.902, 89388.624), stdev = 5015.829
CI (99.9%): [80132.377, 88843.427] (assumes normal distribution)
# Run complete. Total time: 00:05:23
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
StringBuilderBenchmark.testStringAdd thrpt 20 21996.435 ± 412.955 ops/ms
StringBuilderBenchmark.testStringBuilderAdd thrpt 20 84487.902 ± 4355.525 ops/ms
JMH API
下面來了解一下 jmh 常用 API
@BenchmarkMode
基準測試類型。這裡選擇的是
Throughput
也就是吞吐量。根據源碼點進去,每種類型後面都有對應的解釋,比較好了解,吞吐量會得到機關時間内可以進行的操作數。
-
- 整體吞吐量,例如“1 秒内可以執行多少次調用”。Throughput
-
- 調用的平均時間,例如“每次調用平均耗時 xxx 毫秒”。AverageTime
-
- 随機取樣,最後輸出取樣結果的分布,例如“99%的調用在 xxx 毫秒以内,99.99%的調用在 xxx 毫秒以内”SampleTime
-
- 以上模式都是預設一次 iteration 是 1s,唯有 SingleShotTime 是隻運作一次。往往同時把 warmup 次數設為 0,用于測試冷啟動時的性能。SingleShotTime
-
- 所有模式All
@Warmup
上面我們提到了,進行基準測試前需要進行預熱。一般我們前幾次進行程式測試的時候都會比較慢, 是以要讓程式進行幾輪預熱,保證測試的準确性。其中的參數 iterations 也就非常好了解了,就是預熱輪數。
為什麼需要預熱?因為 JVM 的 JIT 機制的存在,如果某個函數被調用多次之後,JVM 會嘗試将其編譯成為機器碼進而提高執行速度。是以為了讓 benchmark 的結果更加接近真實情況就需要進行預熱。
@Measurement
度量,其實就是一些基本的測試參數。
-
- 進行測試的輪次iterations
-
- 每輪進行的時長time
-
- 時長機關timeUnit
都是一些基本的參數,可以根據具體情況調整。一般比較重的東西可以進行大量的測試,放到伺服器上運作。
@Threads
每個程序中的測試線程,這個非常好了解,根據具體情況選擇,一般為 cpu 乘以 2。
@Fork
進行 fork 的次數。如果 fork 數是 2 的話,則 JMH 會 fork 出兩個程序來進行測試。
@OutputTimeUnit
這個比較簡單了,基準測試結果的時間類型。一般選擇秒、毫秒、微秒。
@Benchmark
方法級注解,表示該方法是需要進行 benchmark 的對象,用法和 JUnit 的 @Test 類似。
@Param
屬性級注解,@Param 可以用來指定某項參數的多種情況。特别适合用來測試一個函數在不同的參數輸入的情況下的性能。
@Setup
方法級注解,這個注解的作用就是我們需要在測試之前進行一些準備工作,比如對一些資料的初始化之類的。
@TearDown
方法級注解,這個注解的作用就是我們需要在測試之後進行一些結束工作,比如關閉線程池,資料庫連接配接等的,主要用于資源的回收等。
@State
當使用 @Setup 參數的時候,必須在類上加這個參數,不然會提示無法運作。
State 用于聲明某個類是一個“狀态”,然後接受一個 Scope 參數用來表示該狀态的共享範圍。 因為很多 benchmark 會需要一些表示狀态的類,JMH 允許你把這些類以依賴注入的方式注入到 benchmark 函數裡。Scope 主要分為三種。
-
- 該狀态為每個線程獨享。Thread
-
- 該狀态為同一個組裡面所有線程共享。Group
-
- 該狀态在所有線程間共享。Benchmark
關于 State 的用法,官方的 code sample 裡有比較好的
例子。