3.實戰java高併發程序設計--JDK併發包---3.4 使用JMH進行性能測試

3.4.1 什麼是JMH

JMH(Java Microbenchmark Harness)是一個在OpenJDK項目中發佈的,專門用於性能測試的框架,其精度可以到達毫秒級。通過JMH可以對多個方法的性能進行定量分析。比如,當要知道執行一個函數需要多少時間,或者當對一個算法有多種不同實現時,需要選取性能最好的那個。

3.4.2 Hello JMH

要想使用JMH,首先需要得到JMH的jar包,一種簡單可行的方式是使用Maven進行導入,代碼如下:

其中被度量的代碼爲函數MainApp()。類似於JUnit,被度量代碼用註解@Benchmark標註,這裏僅僅爲一個空函數。在main()函數中,首先對測試用例進行配置,使用Builder模式配置測試,將配置參數存入Options對象,並使用Options對象構造Runner啓動測試。

這是一個測試結果的報告,前面部分表示測試的基本信息。比如,使用的Java路徑,預熱代碼的迭代次數,測量代碼的迭代次數,使用的線程數量,測試的統計單位等。從fork 1 往下開始顯示了每一次熱身中的性能指標,預熱測試不會作爲最終的統計結果。預熱的目的是讓Java虛擬機對被測代碼進行足夠多的優化,比如,在預熱後,被測代碼應該得到了充分的JIT編譯和優化。從第17行開始顯示測量迭代的情況,每一次迭代都顯示了當前的執行速率,即一個操作所花費的時間。在進行20次迭代後,進行統計,在本例中,第29行顯示了wellHelloThere()函數的平均執行花費時間爲0.001μs,誤差爲0.001μs。

3.4.3 JMH的基本概念和配置

爲了能夠更好地使用JMH的各項功能,首先需要對JMH的基本概念有所瞭解。

1.模式(Mode)Mode表示JMH的測量方式和角度,共有4種。

● Throughput:整體吞吐量,表示1秒內可以執行多少次調用。

● AverageTime:調用的平均時間,指每一次調用所需要的時間。

● SampleTime:隨機取樣,最後輸出取樣結果的分佈,例如“99%的調用在xxx毫秒以內,99.99%的調用在xxx毫秒以內”。

● SingleShotTime:以上模式都是默認一次Iteration是1秒,唯有SingleShotTime只運行一次。往往同時把 warmup 次數設爲0,用於測試冷啓動時的性能。

2.迭代(Iteration)

迭代是JMH的一次測量單位。在大部分測量模式下,一次迭代表示1秒。在這一秒內會不間斷調用被測方法,並採樣計算吞吐量、平均時間等。

3.預熱(Warmup)

由於Java虛擬機的JIT的存在,同一個方法在JIT編譯前後的時間將會不同。通常只考慮方法在JIT編譯後的性能

4.狀態(State)

通過State可以指定一個對象的作用範圍,範圍主要有兩種。一種爲線程範圍,也就是一個對象只會被一個線程訪問。在多線程池測試時,會爲每一個線程生成一個對象。另一種是基準測試範圍(Benchmark),即多個線程共享一個實例。

5.配置類(Options/OptionsBuilder)

在測試開始前,首先要對測試進行配置。通常需要指定一些參數,比如指定測試類(include)、使用的進程個數(fork)、預熱迭代次數(warmupIterations)。在配置啓動測試時,需要使用配置類,比如:

3.4.4 理解JMH中的Mode

在JMH中,吞吐量和方法執行的平均時間是最爲常用的統計方式。下面是吞吐量的測量方法:

另外一種有趣的統計方式是採樣,即不再計算每個執行方法的平均執行時間,而是通過採樣得到部分方法的執行時間\

3.4.5 理解JMH中的State

JMH中的State可以理解爲變量或者數據模型的作用域,通常包括整個Benchmark級別和Thread線程級別。聲明瞭兩個數據模型,一個是Benchmark級別,另一個是Thread級別

3.4.6 有關性能的一些思考

性能是一個重要且很複雜的話題。簡單來說,性能調優就是要加快系統的執行,因此就是要儘可能使用執行速度快的組件。有時候,我們會不自覺地詢問,哪個組件更快,哪個方法更快?但是,這個看起來很簡單的問題卻是沒有答案的。在大部分場景中,並沒有絕對的快或者慢。性能需要從不同角度、不同場景進行評估和取捨。一個典型的例子就是時間複雜度和空間複雜度的關係。如果一個算法時間上很快,但是消耗的內存空間極其龐大,還能說它是一個好的算法嗎?反之,如果一個算法內存消耗很少,但是執行時間卻很長,可能同樣也是不可取的。對性能的優化和研究就是需要在各種不同的場景下,對組件進行全方位的性能分析,並結合實際應用情況進行取捨和權衡。

下面以HashMap和ConcurrentHashMap爲例進行性能分析和比較。首先,從嚴格意義上說,這兩個模塊無法進行比較,因爲它們的功能是不等價的。只有在等價的功能下,去比較性能纔是有意義的。HashMap並不是一個線程安全的組件,而ConcurrentHashMap卻是線程安全的組件。因此,這裏再引入一個線程安全的組件,它由HashMap包裝而成:Collections.synchronizedMap(new HashMap())。

其次,雖然同屬於Map接口的實現,但依然很難說HashMap和ConcurrentHashMap誰快誰慢。在Map接口中,有多達20種方法。如果HashMap的get()方法比ConcurrentHashMap的快,也不能說明它的put()方法或者size()方法同樣也會更快。因此,快慢的比較不能離開具體的使用場景。

最後,除了執行時間的比較,還有空間使用的比較,顯而易見,ConcurrentHashMap內存結構更加複雜,也使用了更多的內存空間。但在絕大部分場合,這裏產生的內存消耗都是可以接受的。

下面這段代碼顯示了對HashMap、Collections.synchronizedMap(new HashMap())和ConcurrentHashMap的JMH性能測試。

可以看到,在單線程下,ConcurrentHashMap的get()方法比HashMap的略快,但是size()方法卻比HashMap的慢很多。當HashMap進行同步後,由於同步鎖的開銷,size()方法的性能急劇下降,與ConcurrentHashMap的size()方法在一個數量級上,但依然比ConcurrentHashMap快。

由於使用了兩個線程,一般來說,吞吐量可以增加一倍。尤其是HashMap這個完全不關心線程安全的實現,增加線程數量可以幾乎等比增加其吞吐量。值得注意的是,ConcurrentHashMap的size()方法的吞吐量也等比例增加一倍。但是HashMap的同步包裝由於引入了線程競爭性能反而出現下降。對於get()方法,由於ConcurrentHashMap的合理優化,避免了線程競爭,因此其性能和HashMap幾乎等同,甚至略勝。而同步的HashMap在兩個線程的場景中出現了嚴重的性能損失。

。在JDK 8後,對ConcurrentHashMap的size()方法有了極大的更新。以下是使用JDK 8進行的兩個線程的性能測試

可以看到,在JDK 8中,ConcurrentHashMap的size()方法的性能有了極大的提升(實際上是以犧牲精確性爲代價的)。

3.4.7 CopyOnWriteArrayList類與ConcurrentLinkedQueue類

CopyOnWriteArrayList類和ConcurrentLinkedQueue類是兩個重要的高併發隊列。CopyOn-WriteArrayList類通過寫複製來提升併發能力。ConcurrentLinkedQueue類則通過CAS操作和鎖分離來提高系統性能。那麼在實際應用中,對於這兩個功能上極其相似的組件,應該如何選擇呢?根據實際的應用場景,兩者的性能表現可能會有所差異。

可以看到,在併發條件下,寫的性能遠遠低於讀的性能。而對於CopyOnWriteArrayList類來說,當內部存有1000個元素的時候,由於複製的成本,寫性能要遠遠低於只包含少數元素的List,但依然優於ConcurrentLinkedQueue類。就讀的性能而言,進行只讀不寫的Get操作,兩者性能都不錯。但是由於實現上的差異,ConcurrentLinkedQueue類的size操作明顯要慢於CopyOnWriteArrayList類的。因此,可以得出結論,即便有少許的寫入,在併發場景下,複製的消耗依然相對較小,當元素總量不大時,在絕大部分場景中,CopyOnWriteArrayList類要優於ConcurrentLinkedQueue類。


 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章