JAVA拾遺 — JMH與8個測試陷阱

Kirito的技術分享發表於2019-01-22

前言

JMH 是 Java Microbenchmark Harness(微基準測試)框架的縮寫(2013年首次釋出)。與其他眾多測試框架相比,其特色優勢在於它是由 Oracle 實現 JIT 的相同人員開發的。在此,我想特別提一下 Aleksey Shipilev (JMH 的作者兼佈道者)和他優秀的部落格文章。筆者花費了一個週末,將 Aleksey 大神的部落格,特別是那些和 JMH 相關的文章通讀了幾遍,外加一部公開課視訊 《"The Lesser of Two Evils" Story》 ,將自己的收穫歸納在這篇文章中,文中不少圖片都來自 Aleksey 公開課視訊。

閱讀本文前

本文沒有花費專門的篇幅在文中介紹 JMH 的語法,如果你使用過 JMH,那當然最好,但如果沒聽過它,也不需要擔心(跟我一週前的狀態一樣)。我會從 Java Developer 角度來談談一些常見的程式碼測試陷阱,分析他們和作業系統底層以及 Java 底層的關聯性,並藉助 JMH 來幫助大家擺脫這些陷阱。

通讀本文,需要一些作業系統相關以及部分 JIT 的基礎知識,如果遇到陌生的知識點,可以留意章節中的維基百科連結,以及筆者推薦的部落格。

筆者能力有限,未能完全理解 JMH 解決的全部問題,如有錯誤以及疏漏歡迎留言與我交流。

初識 JMH

測試精度

測試精度

上圖給出了不同型別測試的耗時數量級,可以發現 JMH 可以達到微秒級別的的精度。

這樣幾個數量級的測試所面臨的挑戰也是不同的。

  • 毫秒級別的測試並不是很困難
  • 微秒級別的測試是具備挑戰性的,但並非無法完成,JMH 就做到了
  • 納秒級別的測試,目前還沒有辦法精準測試
  • 皮秒級別…Holy Shit

圖解:

Linpack : Linpack benchmark 一類基礎測試,度量系統的浮點計算能力

SPEC:Standard Performance Evaluation Corporation 工業界的測試標準組織

pipelining:系統匯流排通訊的耗時

Benchmark 分類

測試在不同的維度可以分為很多類:整合測試,單元測試,API 測試,壓力測試… 而 Benchmark 通常譯為基準測試(效能測試)。你可以在很多開源框架的包層級中發現 Benchmark,用於闡釋該框架的基準水平,從而量化其效能。

基準測試又可以細分為 :Micro benchmark,Kernels,Synthetic benchmark,Application benchmarks.etc.本文的主角便屬於 Benchmark 的 Micro benchmark。基礎測試分類詳細介紹 here

motan中的benchmark

為什麼需要有 Benchmark

If you cannot measure it, you cannot improve it.

--Lord Kelvin

俗話說,沒有實踐就沒有發言權,Benchmark 為應用提供了資料支援,是評價和比較方法好壞的基準,Benchmark 的準確性,多樣性便顯得尤為重要。

Benchmark 作為應用框架,產品的基準畫像,存在統一的標準,避免了不同測評物件自說自話的尷尬,應用框架各自使用有利於自身場景的測評方式必然不可取,例如 Standard Performance Evaluation Corporation (SPEC) 即上文“測試精度”提到的詞便是工業界的標準組織之一,JMH 的作者 Aleksey 也是其中的成員。

JMH 長這樣

@Benchmark
public void measure() {
    // this method was intentionally left blank.
}
複製程式碼

使用起來和單元測試一樣的簡單

它的測評結果

Benchmark                                Mode  Cnt           Score           Error  Units
JMHSample_HelloWorld.measure  thrpt    5  3126699413.430 ± 179167212.838  ops/s
複製程式碼

為什麼需要 JMH 測試

你可能會想,我用下面的方式來測試有什麼不好?

long start = System.currentTimeMillis();
measure();
System.out.println(System.currentTimeMillis()-start);
複製程式碼

難道 JMH 不是這麼測試的嗎?

@Benchmark
public void measure() {
}
複製程式碼

事實上,這是本文的核心問題,建議在閱讀時時刻帶著這樣的疑問,為什麼不使用第一種方式來測試。在下面的章節中,我將列舉諸多的測試陷阱,他們都會為這個問題提供論據,這些陷阱會啟發那些對“測試”不感冒的開發者。

預熱

在初識 JMH 小節的最後,花少量的篇幅來給 JMH 涉及的知識點開個頭,介紹一個 Java 測試中比較老生常談的話題 — 預熱(warm up),它存在於下面所有的測試中。

«Warmup» = waiting for the transient responses to settle down

特別是在編寫 Java 測試程式時,預熱從來都是不可或缺的一環,它使得結果更加真實可信。

warmup plateaus

上圖展示了一個樣例測評程式隨著迭代次數增多執行耗時變化的曲線,可以發現在 120 次迭代之後,效能才趨於最終穩定,這意味著:預熱階段需要有至少 120 次迭代,才能得到準確的基礎測試報告。(JVM 初始化時的一些準備工作以及 JIT 優化是主要原因,但不是唯一原因)。需要被說明的事,JMH 的執行相對耗時,因為,預熱被前置在每一個測評任務之前。

使用 JMH 解決 12 個測試陷阱

陷阱1:死碼消除

死碼消除

measureWrong 方法想要測試 Math.log 的效能,得到的結果和空方法 baseline 一致,而 measureRight 相比 measureWrong 多了一個 return,正確的得到了測試結果。

這是由於 JIT 擅長刪除“無效”的程式碼,這給我們的測試帶來了一些意外,當你意識到 DCE 現象後,應當有意識的去消費掉這些孤立的程式碼,例如 return。JMH 不會自動實施對冗餘程式碼的消除。

死碼消除這個概念很多人其實並不陌生,註釋的程式碼,不可達的程式碼塊,可達但不被使用的程式碼等等,我這裡補充一些 Aleksey 提到的概念,用以闡釋為何一般測試方法難以避免引用物件發生死碼消除現象:

  1. Fast object combinator.
  2. Need to escape object to limit thread-local optimizations.
  3. Publishing the object ⇒ reference heap write ⇒ store barrier.

很絕望,個人水平有限,我沒能 get 到這些點,只能原封不動地貼給大家看了。

JMH 提供了專門的 API — Blockhole 來避免死碼消除問題。

@Benchmark
public void measureRight(Blackhole bh) {
    bh.consume(Math.log(PI));
}
複製程式碼

陷阱2:常量摺疊與常量傳播

常量摺疊 (Constant folding) 是一個在編譯時期簡化常數的一個過程,常數在表示式中僅僅代表一個簡單的數值,就像是整數 2,若是一個變數從未被修改也可作為常數,或者直接將一個變數被明確地被標註為常數,例如下面的描述:

  i = 320 * 200 * 32;
複製程式碼

多數的現代編譯器不會真的產生兩個乘法的指令再將結果儲存下來,取而代之的,他們會辨識出語句的結構,並在編譯時期將數值計算出來(在這個例子,結果為 2,048,000)。

有些編譯器,常數摺疊會在初期就處理完,例如 Java 中的 final 關鍵字修飾的變數就會被特殊處理。而將常數摺疊放在較後期的階段的編譯器,也相當常見。

private double x = Math.PI;

// 編譯器會對 final 變數特殊處理 
private final double wrongX = Math.PI;

@Benchmark
public double baseline() { // 2.220 ± 0.352 ns/op
    return Math.PI;
}

@Benchmark
public double measureWrong_1() { // 2.220 ± 0.352 ns/op
    // 錯誤,結果可以被預測,會發生常量摺疊
    return Math.log(Math.PI);
}

@Benchmark
public double measureWrong_2() { // 2.220 ± 0.352 ns/op
    // 錯誤,結果可以被預測,會發生常量摺疊
    return Math.log(wrongX);
}

@Benchmark
public double measureRight() { // 22.590 ± 2.636  ns/op
    return Math.log(x);
}
複製程式碼

經過 JMH 可以驗證這一點:只有最後的 measureRight 正確測試出了 Math.log 的效能,measureWrong_1,measureWrong_2 都受到了常量摺疊的影響。

常數傳播(Constant propagation) 是一個替代表示式中已知常數的過程,也是在編譯時期進行,包含前述所定義,內建函式也適用於常數,以下列描述為例:

  int x = 14;
  int y = 7 - x / 2;
  return y * (28 / x + 2);
複製程式碼

傳播可以理解變數的替換,如果進行持續傳播,上式會變成:

  int x = 14;
  int y = 0;
  return 0;
複製程式碼

陷阱3:永遠不要在測試中寫迴圈

這個陷阱對我們做日常測試時的影響也是巨大的,所以我直接將他作為了標題:永遠不要在測試中寫迴圈!

本節設計不少知識點,迴圈展開(loop unrolling),JIT & OSR 對迴圈的優化。對於前者迴圈展開的定義,建議讀者直接檢視 wiki 的定義,而對於後者 JIT & OSR 對迴圈的優化,推薦兩篇 R 大的知乎回答:

迴圈長度的相同、迴圈體程式碼相同的兩次for迴圈的執行時間相差了100倍?

OSR(On-Stack Replacement)是怎樣的機制?

對於第一個回答,建議不要看問題,直接看答案;第二個回答,闡釋了 OSR 都對迴圈做了哪些手腳。

測試一個耗時較短的方法,入門級程式設計師(不瞭解動態編譯的同學)會這樣寫,通過迴圈放大,再求均值。

public class BadMicrobenchmark {
    public static void main(String[] args) {
        long startTime = System.nanoTime();
        for (int i = 0; i < 10_000_000; i++) {
            reps();
        }
        long endTime = System.nanoTime();
        System.out.println("ns/op : " + (endTime - startTime));
    }
}
複製程式碼

實際上,這段程式碼的結果是不可預測的,太多影響因子會干擾結果。原理暫時不表,通過 JMH 來看看幾個測試方法,下面的 Benchmark 嘗試對 reps 方法迭代不同的次數,想從中獲得 reps 真實的效能。(注意,在 JMH 中使用迴圈也是不可取的,除非你是 Benchmark 方面的專家,否則在任何時候,你都不應該寫迴圈)

int x = 1;
int y = 2;

@Benchmark
public int measureRight() {
    return (x + y);
}

private int reps(int reps) {
    int s = 0;
    for (int i = 0; i < reps; i++) {
        s += (x + y);
    }
    return s;
}

@Benchmark
@OperationsPerInvocation(1)
public int measureWrong_1() {
    return reps(1);
}

@Benchmark
@OperationsPerInvocation(10)
public int measureWrong_10() {
    return reps(10);
}

@Benchmark
@OperationsPerInvocation(100)
public int measureWrong_100() {
    return reps(100);
}

@Benchmark
@OperationsPerInvocation(1000)
public int measureWrong_1000() {
    return reps(1000);
}

@Benchmark
@OperationsPerInvocation(10000)
public int measureWrong_10000() {
    return reps(10000);
}

@Benchmark
@OperationsPerInvocation(100000)
public int measureWrong_100000() {
    return reps(100000);
}
複製程式碼

結果如下:

Benchmark                               Mode  Cnt  Score   Error  Units
JMHSample_11_Loops.measureRight         avgt    5  2.343 ± 0.199  ns/op
JMHSample_11_Loops.measureWrong_1       avgt    5  2.358 ± 0.166  ns/op
JMHSample_11_Loops.measureWrong_10      avgt    5  0.326 ± 0.354  ns/op
JMHSample_11_Loops.measureWrong_100     avgt    5  0.032 ± 0.011  ns/op
JMHSample_11_Loops.measureWrong_1000    avgt    5  0.025 ± 0.002  ns/op
JMHSample_11_Loops.measureWrong_10000   avgt    5  0.022 ± 0.005  ns/op
JMHSample_11_Loops.measureWrong_100000  avgt    5  0.019 ± 0.001  ns/op
複製程式碼

如果不看事先給出的錯誤和正確的提示,上述的結果,你會選擇相信哪一個?實際上跑分耗時從 2.358 隨著迭代次數變大,降為了 0.019。手動測試迴圈的程式碼 BadMicrobenchmark 也存在同樣的問題,實際上它沒有做預熱,效果只會比 JMH 測試迴圈更加不可信。

Aleksey 在視訊中給出結論:假設單詞迭代的耗時是 ? ns. 在 JIT,OSR,迴圈展開等因素的多重作用下,多次迭代的耗時理論值為 ?? ns, 其中 ? ∈ [0; +∞)。

正確的測試迴圈的姿勢可以看這裡:here

陷阱4:使用 Fork 隔離多個測試方法

相信我,這個陷阱中涉及到的例子絕對是 JMH sample 中最詭異的,並且我還沒有找到科學的解釋(說實話視訊中這一段我嘗試聽了好幾遍,沒聽懂,原諒我的聽力)

首先定義一個 Counter 介面,並實現了兩份程式碼完全相同的實現類:Counter1,Counter2

public interface Counter {
    int inc();
}

public class Counter1 implements Counter {
    private int x;

    @Override
    public int inc() {
        return x++;
    }
}

public class Counter2 implements Counter {
    private int x;

    @Override
    public int inc() {
        return x++;
    }
}
複製程式碼

接著讓他們在同一個 VM 中按照先手順序進行評測:

public int measure(Counter c) {
    int s = 0;
    for (int i = 0; i < 10; i++) {
        s += c.inc();
    }
    return s;
}

/*
 * These are two counters.
 */
Counter c1 = new Counter1();
Counter c2 = new Counter2();

/*
 * We first measure the Counter1 alone...
 * Fork(0) helps to run in the same JVM.
 */
@Benchmark
@Fork(0)
public int measure_1_c1() {
    return measure(c1);
}

/*
 * Then Counter2...
 */
@Benchmark
@Fork(0)
public int measure_2_c2() {
    return measure(c1);
}

/*
 * Then Counter1 again...
 */
@Benchmark
@Fork(0)
public int measure_3_c1_again() {
    return measure(c1);
}

@Benchmark
@Fork(1)
public int measure_4_forked_c1() {
    return measure(c1);
}

@Benchmark
@Fork(1)
public int measure_5_forked_c2() {
    return measure(c2);
}
複製程式碼

這一個例子中多了一個 Fork 註解,讓我來簡單介紹下它。Fork 這個關鍵字顧名思義,是用來將執行環境複製一份的意思,在我們之前的多個測試中,實際上每次測評都是預設使用了相互隔離的,完全一致的測評環境,這得益於 JMH。每個試驗執行在單獨的 JVM 程式中。也可以指定(額外的) JVM 引數,例如這裡為了演示執行在同一個 JVM 中的弊端,特地做了反面的教材:Fork(0)。試想一下 c1,c2,c1 again 的耗時結果會如何?

Benchmark                                 Mode  Cnt   Score   Error  Units
JMHSample_12_Forking.measure_1_c1         avgt    5   2.518 ± 0.622  ns/op
JMHSample_12_Forking.measure_2_c2         avgt    5  14.080 ± 0.283  ns/op
JMHSample_12_Forking.measure_3_c1_again   avgt    5  13.462 ± 0.164  ns/op
JMHSample_12_Forking.measure_4_forked_c1  avgt    5   3.861 ± 0.712  ns/op
JMHSample_12_Forking.measure_5_forked_c2  avgt    5   3.574 ± 0.220  ns/op
複製程式碼

你會不會感到驚訝,第一次執行的 c1 竟然耗時最低,在我的認知中,JIT 起碼會啟動預熱的作用,無論如何都不可能先執行的方法比之後的方法快這麼多!但這個結果也和 Aleksey 視訊中介紹的相符。

JMH samples 中的這個示例主要還是想要表達同一個 JVM 中執行的測評程式碼會互相影響,從結果也可以發現:c1,c2,c1_again 的實現相同,跑分卻不同,因為執行在同一個 JVM 中;而 forked_c1 和 forked_c2 則表現出了一致的效能。所以沒有特殊原因,Fork 的值一般都需要設定為 >0。

陷阱5:方法內聯

熟悉 C/C++ 的朋友不會對方法內聯感到陌生,方法內聯就是把目標方法的程式碼“複製”到發起呼叫的方法之中,避免發生真實的方法呼叫(減少了操作指令週期)。在 Java 中,無法手動編寫內聯方法,但 JVM 會自動識別熱點方法,並對它們使用方法內聯優化。一段程式碼需要執行多少次才會觸發 JIT 優化通常這個值由 -XX:CompileThreshold 引數進行設定:

  • 1、使用 client 編譯器時,預設為1500;
  • 2、使用 server 編譯器時,預設為10000;

但是一個方法就算被 JVM 標註成為熱點方法,JVM 仍然不一定會對它做方法內聯優化。其中有個比較常見的原因就是這個方法體太大了,分為兩種情況。

  • 如果方法是經常執行的,預設情況下,方法大小小於 325 位元組的都會進行內聯(可以通過-XX:MaxFreqInlineSize=N來設定這個大小)
  • 如果方法不是經常執行的,預設情況下,方法大小小於 35 位元組才會進行內聯(可以通過-XX:MaxInlineSize=N來設定這個大小)

我們可以通過增加這個大小,以便更多的方法可以進行內聯;但是除非能夠顯著提升效能,否則不推薦修改這個引數。因為更大的方法體會導致程式碼記憶體佔用更多,更少的熱點方法會被快取,最終的效果不一定好。

如果想要知道方法被內聯的情況,可以使用下面的JVM引數來配置

-XX:+PrintCompilation //在控制檯列印編譯過程資訊
-XX:+UnlockDiagnosticVMOptions //解鎖對JVM進行診斷的選項引數。預設是關閉的,開啟後支援一些特定引數對JVM進行診斷
-XX:+PrintInlining //將內聯方法列印出來
複製程式碼

方法內聯的其他隱含條件

  • 雖然 JIT 號稱可以針對程式碼全域性的執行情況而優化,但是 JIT 對一個方法內聯之後,還是可能因為方法被繼承,導致需要型別檢查而沒有達到效能的效果
  • 想要對熱點的方法使用上內聯的優化方法,最好儘量使用final、private、static這些修飾符修飾方法,避免方法因為繼承,導致需要額外的型別檢查,而出現效果不好情況。

方法內聯也可能對 Benchmark 產生影響;或者說有時候我們為了優化程式碼,而故意觸發內聯,也可以通過 JMH 來和非內聯方法進行效能對比:

public void target_blank() {
    // this method was intentionally left blank
}

@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public void target_dontInline() {
    // this method was intentionally left blank
}

@CompilerControl(CompilerControl.Mode.INLINE)
public void target_inline() {
    // this method was intentionally left blank
}
複製程式碼
Benchmark                                Mode  Cnt   Score    Error  Units
JMHSample_16_CompilerControl.blank       avgt    3   0.323 ±  0.544  ns/op
JMHSample_16_CompilerControl.dontinline  avgt    3   2.099 ±  7.515  ns/op
JMHSample_16_CompilerControl.inline      avgt    3   0.308 ±  0.264  ns/op
複製程式碼

可以發現,內聯與不內聯的效能差距是巨大的,有一些空間換時間的味道,在 JMH 中使用 CompilerControl.Mode 來控制內聯是否開啟。

陷阱6:偽共享與快取行

又遇到了我們的老朋友:CPU Cache 和快取行填充。這個併發效能殺手,我在之前的文章中專門介紹過,如果你沒有看過,可以戳這裡:JAVA 拾遺 — CPU Cache 與快取行。在 Benchmark 中,有時也不能忽視快取行對測評的影響。

受限於篇幅,在此不展開有關偽共享的陷阱,完整的測評可以戳這裡:JMHSample_22_FalseSharing

JMH 為解決偽共享問題,提供了 @State 註解,但並不能在單一物件內部對個別的欄位增加,如果有必要,可以使用併發包中的 @Contended 註解來處理。

Aleksey 曾為 Java 併發包提供過優化,其中就包括 @Contended 註解。

陷阱7:分支預測

分支預測(Branch Prediction)是這篇文章中介紹的最後一個 Benchmark 中的“搗蛋鬼”。還是從一個具體的 Benchmark 中觀察結果。下面的程式碼嘗試遍歷了兩個長度相等的陣列,一個有序,一個無序,並在迭代時加入了一個判斷語句,這是分支預測的關鍵:if(v > 0)

private static final int COUNT = 1024 * 1024;

private byte[] sorted;
private byte[] unsorted;

@Setup
public void setup() {
    sorted = new byte[COUNT];
    unsorted = new byte[COUNT];
    Random random = new Random(1234);
    random.nextBytes(sorted);
    random.nextBytes(unsorted);
    Arrays.sort(sorted);
}

@Benchmark
@OperationsPerInvocation(COUNT)
public void sorted(Blackhole bh1, Blackhole bh2) {
    for (byte v : sorted) {
        if (v > 0) { //關鍵
            bh1.consume(v);
        } else {
            bh2.consume(v);
        }
    }
}

@Benchmark
@OperationsPerInvocation(COUNT)
public void unsorted(Blackhole bh1, Blackhole bh2) {
    for (byte v : unsorted) {
        if (v > 0) { //關鍵
            bh1.consume(v);
        } else {
            bh2.consume(v);
        }
    }
}
複製程式碼
Benchmark                               Mode  Cnt  Score   Error  Units
JMHSample_36_BranchPrediction.sorted    avgt   25  2.752 ± 0.154  ns/op
JMHSample_36_BranchPrediction.unsorted  avgt   25  8.175 ± 0.883  ns/op
複製程式碼

從結果看,有序陣列的遍歷比無序陣列的遍歷快了 2-3 倍。關於這點的介紹,最佳的解釋來自於 Stack Overflow 一個 2w 多讚的答案:Why is it faster to process a sorted array than an unsorted array?

分叉路口

假設我們是在 19 世紀,而你負責為火車選擇一個方向,那時連電話和手機還沒有普及,當火車開來時,你不知道火車往哪個方向開。於是你的做法(演算法)是:叫停火車,此時火車停下來,你去問司機,然後你確定了火車往哪個方向開,並把鐵軌扳到了對應的軌道。

還有一個需要注意的地方是,火車的慣性是非常大的,所以司機必須在很遠的地方就開始減速。當你把鐵軌扳正確方向後,火車從啟動到加速又要經過很長的時間。

那麼是否有更好的方式可以減少火車的等待時間呢?

有一個非常簡單的方式,你提前把軌道扳到某一個方向。那麼到底要扳到哪個方向呢,你使用的手段是——“瞎蒙”:

  • 如果蒙對了,火車直接通過,耗時為 0。
  • 如果蒙錯了,火車停止,然後倒回去,你將鐵軌扳至反方向,火車重新啟動,加速,行駛。

如果你很幸運,每次都蒙對了,火車將從不停車,一直前行!如果不幸你蒙錯了,那麼將浪費很長的時間。

雖然不嚴謹,但你可以用同樣的道理去揣測 CPU 的分支預測,有序陣列使得這樣的預測大部分情況下是正確的,所以帶有判斷條件時,有序陣列的遍歷要比無序陣列要快。

這同時也啟發我們:在大規模迴圈邏輯中要儘量避免大量判斷(是不是可以抽取到迴圈外呢?)。

陷阱8:多執行緒測試

多執行緒測試

在 4 核的系統之上執行一個測試方法,得到如上的測試結果, Ops/nsec 代表了單位時間內的執行次數,Scale 代表 2,4 執行緒相比 1 執行緒的執行次數倍率。

這個圖可供我們提出兩個問題:

  1. 為什麼 2 執行緒 -> 4 執行緒幾乎沒有變化?
  2. 為什麼 2 執行緒相比 1 執行緒只有 1.87 倍的變化,而不是 2 倍?

1 電源管理

降頻

第一個影響因素便是多執行緒測試會受到作業系統電源管理(Power Management)的影響,許多系統存在能耗和效能的優化管理。 (Ex: cpufreq, SpeedStep, Cool&Quiet, TurboBoost)

當我們主動對機器進行降頻之後,整體效能發生下降,但是 Scale 線上程數 1 -> 2 的過程中變成了嚴謹的 2 倍。

這樣的問題並非無法規避,補救方法便是禁用電源管理, 保證 CPU 的時脈頻率 。

JMH 通過長時間執行,保證執行緒不出現 park(time waiting) 狀態,來保證測試的精準性。

2 作業系統排程和分時呼叫模型

造成多執行緒測試陷阱的第二個問題,需要從執行緒排程模型出發來理解:分時排程模型和搶佔式排程模型。

分時排程模型是指讓所有的執行緒輪流獲得 CPU 的使用權,並且平均分配每個執行緒佔用的 CPU 的時間片,這個也比較好理解;搶佔式排程模型,是指優先讓可執行池中優先順序高的執行緒佔用 CPU,如果可執行池中的執行緒優先順序相同,那麼就隨機選擇一個執行緒,使其佔用 CPU。處於執行狀態的執行緒會一直執行,直至它不得不放棄 CPU。一個執行緒會因為以下原因而放棄 CPU。

需要注意的是,執行緒的排程不是跨平臺的,它不僅僅取決於 Java 虛擬機器,還依賴於作業系統。在某些作業系統中,只要執行中的執行緒沒有遇到阻塞,就不會放棄 CPU;在某些作業系統中,即使執行緒沒有遇到阻塞,也會執行一段時間後放棄 CPU,給其它執行緒執行的機會。

無論是那種模型,執行緒上下文的切換都會造成損耗。到這兒為止,還是隻回答了第一個問題:為什麼 2 執行緒相比 1 執行緒只有 1.87 倍的變化,而不是 2 倍?

由於上述的兩個圖我都是從 Aleksey 的視訊中摳出來的,並不清楚他的實際測試用例,對於 2 -> 4 執行緒效能差距並不大隻能理解為系統過載,按道理說 4 核的機器,執行 4 個執行緒應該不至於只比 2 個執行緒快這麼一點。

對於執行緒分時呼叫以及執行緒排程帶來的不穩定性,JMH 引入了 bogus iterations 的概念,它保障了在多執行緒測試過程中,只線上程處於忙碌狀態的過程中進行測量。

bogus iterations

bogus iterations 這個值得一提,我理解為“偽迭代”,並且也只在 JVM 的註釋以及 Aleksey 的幾個部落格中有介紹,可以理解為 JMH 的內部原理的專用詞。

總結

本文花了大量的篇幅介紹了 JMH 存在的意義,以及 JMH sample 中提到的諸多陷阱,這些陷阱會非常容易地被那些不規範的測評程式所觸發。我覺得作為 Java 語言的使用者,起碼有必要了解這些現象的存在,畢竟 JMH 已經幫你解決了諸多問題了,你不用擔心預熱問題,不用自己寫比較 low 的迴圈去評測,規避這些測試陷阱也變得相對容易。

實際上,本文設計的知識點,僅僅是 Aleksey 部落格中的內容、 JMH 的 38 個 sample 的冰山一角,有興趣的朋友可以戳這裡檢視所有的 JMH sample

陷阱內心 os:像我這麼diao的陷阱,還有 30 個!

kafka

例如 Kafka 這樣優秀的開源框架,提供了專門的 module 來做 JMH 的基礎測試。嘗試使用 JMH 作為你的 Benchmark 工具吧。

歡迎關注我的微信公眾號:「Kirito的技術分享」,關於文章的任何疑問都會得到回覆,帶來更多 Java 相關的技術分享。

關注微信公眾號

相關文章