歡迎光臨
每天分享高質量文章

Java 併發測試神器:基準測試神器-JMH

出處:https://sq.163yun.com/blog/article/179671960481783808


效能測試這個話題非常龐大,我們可以從網路聊到作業系統,再從作業系統聊到核心,再從核心聊到你懷疑人生有木有。

先拍幾個磚出來吧,我在寫程式碼的時候經常有這種懷疑:寫法A快還是寫法B快,某個位置是用ArrayList還是LinkedList,HashMap還是TreeMap,HashMap的初始化size要不要指定,指定之後究竟比預設的DEFAULT_SIZE效能好多少。。。

如果你還是透過for迴圈或者手擼method來測試你的內容的話,那麼JMH就是你必須要明白的內容了,因為已經有人把基準測試的輪子造好了,接下來我們就一起看看這個輪子怎麼用:

JMH只適合細粒度的方法測試,並不適用於系統之間的鏈路測試!

JMH只適合細粒度的方法測試,並不適用於系統之間的鏈路測試!

JMH只適合細粒度的方法測試,並不適用於系統之間的鏈路測試!

JMH入門

JMH是一個工具包,如果我們要透過JMH進行基準測試的話,直接在我們的pom檔案中引入JMH的依賴即可:

  1. org.openjdk.jmh
  • jmh-core
  • 1.19
  • org.openjdk.jmh
  • jmh-generator-annprocess
  • 1.19

透過一個HelloWorld程式來看一下JMH如果工作:

  1. @Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
  2. @Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
  3. public class JMHSample_01_HelloWorld {
  4.  
  5. static class Demo {
  6. int id;
  7. String name;
  8. public Demo(int id, String name) {
  9. this.id = id;
  10. this.name = name;
  11. }
  12. }
  13.  
  14. static List<Demo> demoList;
  15. static {
  16. demoList = new ArrayList();
  17. for (int i = 0; i < 10000; i ++) {
  18. demoList.add(new Demo(i, "test"));
  19. }
  20. }
  21.  
  22. @Benchmark
  23. @BenchmarkMode(Mode.AverageTime)
  24. @OutputTimeUnit(TimeUnit.MICROSECONDS)
  25. public void testHashMapWithoutSize() {
  26. Map map = new HashMap();
  27. for (Demo demo : demoList) {
  28. map.put(demo.id, demo.name);
  29. }
  30. }
  31.  
  32. @Benchmark
  33. @BenchmarkMode(Mode.AverageTime)
  34. @OutputTimeUnit(TimeUnit.MICROSECONDS)
  35. public void testHashMap() {
  36. Map map = new HashMap((int)(demoList.size() / 0.75f) + 1);
  37. for (Demo demo : demoList) {
  38. map.put(demo.id, demo.name);
  39. }
  40. }
  41.  
  42. public static void main(String[] args) throws RunnerException {
  43. Options opt = new OptionsBuilder()
  44. .include(JMHSample_01_HelloWorld.class.getSimpleName())
  45. .forks(1)
  46. .build();
  47.  
  48. new Runner(opt).run();
  49. }
  50. }
  51.  
  52. ======================================執行結果======================================
  53. Benchmark Mode Cnt Score Error Units
  54. JMHSample_01_HelloWorld.testHashMap avgt 5 147.865 ± 81.128 us/op
  55. JMHSample_01_HelloWorld.testHashMapWithoutSize avgt 5 224.897 ± 102.342 us/op
  56. ======================================執行結果======================================

上面的程式碼用中文翻譯一下:分別定義兩個基準測試的方法testHashMapWithoutSize和 testHashMap,這兩個基準測試方法執行流程是:每個方法執行前都進行5次預熱執行,每隔1秒進行一次預熱操作,預熱執行結束之後進行5次實際測量執行,每隔1秒進行一次實際執行,我們此次基準測試測量的是平均響應時長,單位是us。

預熱?為什麼要預熱?因為 JVM 的 JIT 機制的存在,如果某個函式被呼叫多次之後,JVM 會嘗試將其編譯成為機器碼從而提高執行速度。為了讓 benchmark 的結果更加接近真實情況就需要進行預熱。

從上面的執行結果我們看出,針對一個Map的初始化引數的給定其實有很大影響,當我們給定了初始化引數執行執行的速度是沒給定引數的2/3,這個最佳化速度還是比較明顯的,所以以後大家在初始化Map的時候能給定引數最好都給定了,程式碼是處處最佳化的,積少成多。

透過上面的內容我們已經基本可以看出來JMH的寫法雛形了,後面的介紹主要是一些註解的使用:

@Benchmark

@Benchmark標簽是用來標記測試方法的,只有被這個註解標記的話,該方法才會參與基準測試,但是有一個基本的原則就是被@Benchmark標記的方法必須是public的。

@Warmup

@Warmup用來配置預熱的內容,可用於類或者方法上,越靠近執行方法的地方越準確。一般配置warmup的引數有這些:

  • iterations:預熱的次數。
  • time:每次預熱的時間。
  • timeUnit:時間單位,預設是s。
  • batchSize:批處理大小,每次操作呼叫幾次方法。(後面用到)

@Measurement

用來控制實際執行的內容,配置的選項本warmup一樣。

@BenchmarkMode

@BenchmarkMode主要是表示測量的緯度,有以下這些緯度可供選擇:

  • Mode.Throughput 吞吐量緯度
  • Mode.AverageTime 平均時間
  • Mode.SampleTime 抽樣檢測
  • Mode.SingleShotTime 檢測一次呼叫
  • Mode.All 運用所有的檢測樣式 在方法級別指定@BenchmarkMode的時候可以一定指定多個緯度,例如: @BenchmarkMode({Mode.Throughput, Mode.AverageTime, Mode.SampleTime, Mode.SingleShotTime}),代表同時在多個緯度對標的方法進行測量。

@OutputTimeUnit

@OutputTimeUnit代表測量的單位,比如秒級別,毫秒級別,微妙級別等等。一般都使用微妙和毫秒級別的稍微多一點。該註解可以用在方法級別和類級別,當用在類級別的時候會被更加精確的方法級別的註解改寫,原則就是離標的更近的註解更容易生效。

@State

在很多時候我們需要維護一些狀態內容,比如在多執行緒的時候我們會維護一個共享的狀態,這個狀態值可能會在每隔執行緒中都一樣,也有可能是每個執行緒都有自己的狀態,JMH為我們提供了狀態的支援。該註解只能用來標註在類上,因為類作為一個屬性的載體。 @State的狀態值主要有以下幾種:

  • Scope.Benchmark 該狀態的意思是會在所有的Benchmark的工作執行緒中共享變數內容。
  • Scope.Group 同一個Group的執行緒可以享有同樣的變數
  • Scope.Thread 每隔執行緒都享有一份變數的副本,執行緒之間對於變數的修改不會相互影響。 下麵看兩個常見的@State的寫法:

1.直接在內部類中使用@State作為“PropertyHolder”

  1. public class JMHSample_03_States {
  2.  
  3. @State(Scope.Benchmark)
  4. public static class BenchmarkState {
  5. volatile double x = Math.PI;
  6. }
  7.  
  8. @State(Scope.Thread)
  9. public static class ThreadState {
  10. volatile double x = Math.PI;
  11. }
  12.  
  13. @Benchmark
  14. public void measureUnshared(ThreadState state) {
  15. state.x++;
  16. }
  17.  
  18. @Benchmark
  19. public void measureShared(BenchmarkState state) {
  20. state.x++;
  21. }
  22.  
  23. public static void main(String[] args) throws RunnerException {
  24. Options opt = new OptionsBuilder()
  25. .include(JMHSample_03_States.class.getSimpleName())
  26. .threads(4)
  27. .forks(1)
  28. .build();
  29.  
  30. new Runner(opt).run();
  31. }
  32. }

2.在Main類中直接使用@State作為註解,是Main類直接成為“PropertyHolder”

  1. @State(Scope.Thread)
  2. public class JMHSample_04_DefaultState {
  3. double x = Math.PI;
  4.  
  5. @Benchmark
  6. public void measure() {
  7. x++;
  8. }
  9.  
  10. public static void main(String[] args) throws RunnerException {
  11. Options opt = new OptionsBuilder()
  12. .include(JMHSample_04_DefaultState.class.getSimpleName())
  13. .forks(1)
  14. .build();
  15.  
  16. new Runner(opt).run();
  17. }
  18. }

我們試想以下@State的含義,它主要是方便框架來控制變數的過程邏輯,透過@State標示的類都被用作屬性的容器,然後框架可以透過自己的控制來配置不同級別的隔離情況。被@Benchmark標註的方法可以有引數,但是引數必須是被@State註解的,就是為了要控制引數的隔離。

但是有些情況下我們需要對引數進行一些初始化或者釋放的操作,就像Spring提供的一些init和destory方法一樣,JHM也提供有這樣的鉤子:

  • @Setup 必須標示在@State註解的類內部,表示初始化操作
  • @TearDown 必須表示在@State註解的類內部,表示銷毀操作 

初始化和銷毀的動作都只會執行一次。

  1. @State(Scope.Thread)
  2. public class JMHSample_05_StateFixtures {
  3. double x;
  4.  
  5. @Setup
  6. public void prepare() {
  7. x = Math.PI;
  8. }
  9.  
  10. @TearDown
  11. public void check() {
  12. assert x > Math.PI : "Nothing changed?";
  13. }
  14.  
  15. @Benchmark
  16. public void measureRight() {
  17. x++;
  18. }
  19.  
  20. public static void main(String[] args) throws RunnerException {
  21. Options opt = new OptionsBuilder()
  22. .include(JMHSample_05_StateFixtures.class.getSimpleName())
  23. .forks(1)
  24. .jvmArgs("-ea")
  25. .build();
  26.  
  27. new Runner(opt).run();
  28. }
  29. }

雖然我們可以執行初始化和銷毀的動作,但是總是感覺還缺點啥?對,就是初始化的粒度。因為基準測試往往會執行多次,那麼能不能保證每次執行方法的時候都初始化一次變數呢? @Setup和@TearDown提供了以下三種緯度的控制:

  • Level.Trial 只會在個基礎測試的前後執行。包括Warmup和Measurement階段,一共只會執行一次。
  • Level.Iteration 每次執行記住測試方法的時候都會執行,如果Warmup和Measurement都配置了2次執行的話,那麼@Setup和@TearDown配置的方法的執行次數就4次。
  • Level.Invocation 每個方法執行的前後執行(一般不推薦這麼用)

@Param

在很多情況下,我們需要測試不同的引數的不同結果,但是測試的了邏輯又都是一樣的,因此如果我們編寫鍍鉻benchmark的話會造成邏輯的冗餘,幸好JMH提供了@Param引數來幫助我們處理這個事情,被@Param註解標示的引陣列會一次被benchmark消費到。

  1. @State(Scope.Benchmark)
  2. public class ParamTest {
  3.  
  4. @Param({"1", "2", "3"})
  5. int testNum;
  6.  
  7. @Benchmark
  8. public String test() {
  9. return String.valueOf(testNum);
  10. }
  11.  
  12. public static void main(String[] args) throws RunnerException {
  13. Options opt = new OptionsBuilder()
  14. .include(ParamTest.class.getSimpleName())
  15. .forks(1)
  16. .build();
  17.  
  18. new Runner(opt).run();
  19. }
  20. }

@Threads

測試執行緒的數量,可以配置在方法或者類上,代表執行測試的執行緒數量。

通常看到這裡我們會比較迷惑Iteration和Invocation區別,我們在配置Warmup的時候預設的時間是的1s,即1s的執行作為一個Iteration,假設每次方法的執行是100ms的話,那麼1個Iteration就代表10個Invocation。

JMH進階

透過以上的內容我們已經基本可以掌握JMH的使用了,下麵就主要介紹一下JMH提供的一些高階特性了。

不要編寫無用程式碼

因為現代的編譯器非常聰明,如果我們在程式碼使用了沒有用處的變數的話,就容易被編譯器最佳化掉,這就會導致實際的測量結果可能不準確,因為我們要在測量的方法中避免使用void方法,然後記得在測量的結束位置傳回結果。這麼做的目的很明確,就是為了與編譯器鬥智鬥勇,讓編譯器不要改變這段程式碼執行的初衷。

Blackhole介紹

Blackhole會消費傳進來的值,不提供任何資訊來確定這些值是否在之後被實際使用。 Blackhole處理的事情主要有以下幾種:

  • 死程式碼消除:入參應該在每次都被用到,因此編譯器就不會把這些引數最佳化為常量或者在計算的過程中對他們進行其他最佳化。
  • 處理記憶體壁:我們需要盡可能減少寫的量,因為它會幹擾快取,汙染寫緩衝區等。 這很可能導致過早地撞到記憶體壁

我們在上面說到需要消除無用程式碼,那麼其中一種方式就是透過Blackhole,我們可以用Blackhole來消費這些傳回的結果。

1:傳回測試結果,防止編譯器最佳化

  1. @Benchmark
  2. public double measureRight_1() {
  3. return Math.log(x1) + Math.log(x2);
  4. }
  5.  
  6. 2.透過Blackhole消費中間結果,防止編譯器最佳化
  7. @Benchmark
  8. public void measureRight_2(Blackhole bh) {
  9. bh.consume(Math.log(x1));
  10. bh.consume(Math.log(x2));
  11. }

迴圈處理

我們雖然可以在Benchmark中定義迴圈邏輯,但是這麼做其實是不合適的,因為編譯器可能會將我們的迴圈進行展開或者做一些其他方面的迴圈最佳化,所以JHM建議我們不要在Beanchmark中使用迴圈,如果我們需要處理迴圈邏輯了,可以結合@BenchmarkMode(Mode.SingleShotTime)和@Measurement(batchSize = N)來達到同樣的效果.

  1. @State(Scope.Thread)
  2. public class JMHSample_26_BatchSize {
  3.  
  4. List<String> list = new LinkedList<>();
  5.  
  6. // 每個iteration中做5000次Invocation
  7. @Benchmark
  8. @Warmup(iterations = 5, batchSize = 5000)
  9. @Measurement(iterations = 5, batchSize = 5000)
  10. @BenchmarkMode(Mode.SingleShotTime)
  11. public List<String> measureRight() {
  12. list.add(list.size() / 2, "something");
  13. return list;
  14. }
  15.  
  16. @Setup(Level.Iteration)
  17. public void setup(){
  18. list.clear();
  19. }
  20.  
  21. public static void main(String[] args) throws RunnerException {
  22. Options opt = new OptionsBuilder()
  23. .include(JMHSample_26_BatchSize.class.getSimpleName())
  24. .forks(1)
  25. .build();
  26.  
  27. new Runner(opt).run();
  28. }
  29.  
  30. }

方法行內

方法行內:如果JVM監測到一些小方法被頻繁的執行,它會把方法的呼叫替換成方法體本身。比如說下麵這個:

  1. private int add4(int x1, int x2, int x3, int x4) {
  2. return add2(x1, x2) + add2(x3, x4);
  3. }
  4.  
  5. private int add2(int x1, int x2) {
  6. return x1 + x2;
  7. }

執行一段時間後JVM會把add2方法去掉,並把你的程式碼翻譯成:

  1. private int add4(int x1, int x2, int x3, int x4) {
  2. return x1 + x2 + x3 + x4;
  3. }

JMH提供了CompilerControl註解來控制方法行內,但是實際上我感覺比較有用的就是兩個了:

  • CompilerControl.Mode.DONT_INLINE:強制限制不能使用行內
  • CompilerControl.Mode.INLINE:強制使用行內 看一下官方提供的例子把:
  1. @State(Scope.Thread)
  2. @BenchmarkMode(Mode.AverageTime)
  3. @OutputTimeUnit(TimeUnit.NANOSECONDS)
  4. public class JMHSample_16_CompilerControl {
  5.  
  6. public void target_blank() {
  7.  
  8. }
  9.  
  10. @CompilerControl(CompilerControl.Mode.DONT_INLINE)
  11. public void target_dontInline() {
  12.  
  13. }
  14.  
  15. @CompilerControl(CompilerControl.Mode.INLINE)
  16. public void target_inline() {
  17.  
  18. }
  19.  
  20. @Benchmark
  21. public void baseline() {
  22.  
  23. }
  24.  
  25. @Benchmark
  26. public void dontinline() {
  27. target_dontInline();
  28. }
  29.  
  30. @Benchmark
  31. public void inline() {
  32. target_inline();
  33. }
  34.  
  35. public static void main(String[] args) throws RunnerException {
  36. Options opt = new OptionsBuilder()
  37. .include(JMHSample_16_CompilerControl.class.getSimpleName())
  38. .warmupIterations(0)
  39. .measurementIterations(3)
  40. .forks(1)
  41. .build();
  42.  
  43. new Runner(opt).run();
  44. }
  45. }
  46.  
  47. ======================================執行結果==============================
  48. Benchmark Mode Cnt Score Error Units
  49. JMHSample_16_CompilerControl.baseline avgt 3 0.896 ± 3.426 ns/op
  50. JMHSample_16_CompilerControl.dontinline avgt 3 0.344 ± 0.126 ns/op
  51. JMHSample_16_CompilerControl.inline avgt 3 0.391 ± 2.622 ns/op
  52. ======================================執行結果==============================

贊(0)

分享創造快樂