天天看點

Java基準測試指南

Java基準測試指南

基準測試簡介

什麼是基準測試

基準測試是指通過設計科學的測試方法、測試工具和測試系統,實作對一類測試對象的某項性能名額進行定量的和可對比的測試。

現代軟體常常都把高性能作為目标。那麼,何為高性能,性能就是快,更快嗎?顯然,如果沒有一個量化的标準,難以衡量性能的好壞。

不同的基準測試其具體内容和範圍也存在很大的不同。如果是專業的性能工程師,更加熟悉的可能是類似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優化掉)。
  • 使用并發測試。
  • 測試結果呈現。

應用場景

  1. 當你已經找出了熱點函數,而需要對熱點函數進行進一步的優化時,就可以使用 JMH 對優化的效果進行定量的分析。
  2. 想定量地知道某個函數需要執行多長時間,以及執行時間和輸入 n 的相關性。
  3. 一個函數有兩種不同實作(例如 JSON 序列化/反序列化有 Jackson 和 Gson 實作),不知道哪種實作性能更好。

JMH 概念

  • Iteration

    - iteration 是 JMH 進行測試的最小機關,包含一組 invocations。
  • Invocation

    - 一次 benchmark 方法調用。
  • Operation

    - benchmark 方法中,被測量操作的執行。如果被測試的操作在 benchmark 方法中循環執行,可以使用

    @OperationsPerInvocation

    表明循環次數,使測試結果為單次 operation 的性能。
  • Warmup

    - 在實際進行 benchmark 前先進行預熱。因為某個函數被調用多次之後,JIT 會對其進行編譯,通過預熱可以使測量結果更加接近真實情況。

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

也就是吞吐量。根據源碼點進去,每種類型後面都有對應的解釋,比較好了解,吞吐量會得到機關時間内可以進行的操作數。

  • Throughput

    - 整體吞吐量,例如“1 秒内可以執行多少次調用”。
  • AverageTime

    - 調用的平均時間,例如“每次調用平均耗時 xxx 毫秒”。
  • SampleTime

    - 随機取樣,最後輸出取樣結果的分布,例如“99%的調用在 xxx 毫秒以内,99.99%的調用在 xxx 毫秒以内”
  • SingleShotTime

    - 以上模式都是預設一次 iteration 是 1s,唯有 SingleShotTime 是隻運作一次。往往同時把 warmup 次數設為 0,用于測試冷啟動時的性能。
  • 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 裡有比較好的

例子

參考資料