[轉帖]JDK 從8升級到11,使用 G1 GC,HBase 性能下降近20%。JDK 到底幹了什麼原創

https://heapdump.cn/article/2601008

 

編者按:筆者在 HBase 業務場景中嘗試將 JDK 從 8 升級到 11,使用 G1 GC 作爲垃圾回收器,但是性能下降 20%。到底是什麼導致了性能衰退?又該如何定位解決?本文介紹如果通過使用 JFR、火焰圖等工具確定問題,最後通過版本逐一驗證找到了引起性能問題的代碼。在畢昇 JDK 中率先修復問題最後將修復推送到上游社區中。希望通過本文的介紹讓讀者瞭解到如何解決大版本升級中遇到的性能問題;同時也提醒 Java 開發者要正確地使用參數(使用前要理解參數的含義)。
HBase 從 2.3.x 開始正式默認的支持 JDK 11,HBase 對於 JDK 11 的支持指的是 HBase 本身可以通過 JDK 11 的編譯、同時相關的測試用例全部通過。由於 HBase 依賴 Hadoop 和 Zookeeper,而目前最新的 Hadoop 和 Zookeeper 尚未支持 JDK 11,所以 HBase 中仍然有一個 jira 來關注 JDK 11 支持的問題,具體參考:https://issues.apache.org/jira/browse/HBASE-22972。
G1 GC 從 JDK 9 以後就成爲默認的 GC,而且 HBase 在新的版本中也採用 G1 GC,對於 HBase 是否可以在生產環境中使用 JDK 11?筆者嘗試使用 JDK 11 來運行新的 HBase,驗證 JDK 11 是否比 JDK 8 有優勢。

1環境介紹

驗證的方式非常簡單,搭建一個 3 節點的 HBase 集羣,安裝 HBase,採用的版本爲 2.3.2,關於 HBase 環境搭建可以參考官網。
另外爲了驗證,使用一個額外的客戶端機器,通過 HBase 自帶的 PerformanceEvaluation 工具(簡稱 PE)來驗證 HBase 讀、寫性能。PE 支持隨機的讀、寫、掃描,順序讀、寫、掃描等。
例如一個簡單的隨機寫命令如下:
hbase org.apache.hadoop.hbase.PerformanceEvaluation --rows=10000 --valueSize=8000 randomWrite 5
該命令的含義是:創建 5 個客戶端,並且執行持續的寫入測試。每個客戶端每次寫入 8000 字節,共寫入 10000 行。
PE 使用起來非常簡單,是 HBase 壓測中非常流行的工具,關於 PE 更多的用法可以參考相關手冊。
本次測試爲了驗證讀寫性能,採用如下配置:
org.apache.hadoop.hbase.PerformanceEvaluation --writeToWAL=true --nomapred --size=256 --table=Test1 --inmemoryCompaction=BASIC --presplit=50 --compress=SNAPPY sequentialWrite 120
JDK 採用 JDK 8u222 和 JDK 11.0.8 分別進行測試,當切換 JDK 時,客戶端和 3 臺 HBase 服務器統一切換。JDK 的運行參數爲:
-XX:+PrintGCDetails -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:-ResizePLAB
注意:這裏禁止 ResizePLAB 是業務根據 HBase 優化資料設置。

2測試結果:JDK 11性能下降

通過 PE 進行測試,運行結束有 TPS 數據,表示性能。
image.png
在相同的硬件環境、相同的 HBase,僅僅使用不同的 JDK 來運行。同時爲了保證結果的準確性,多次運行,取平均值。測試結果如下:
image.png
從表中可以快速地計算得到吞吐量下降,運行時間增加。
image.png
結論:使用 G1 GC,JDK 11 相對於 JDK 8 來說性能明顯下降。

3原因分析

從 JDK 8 到 JDK 11, G1 GC 做了非常多的優化用於提高性能。爲什麼 JDK 11 對於應用者來說更不友好?簡單的總結一下從 JDK 8 到 JDK 11 做的一些比較大的設計變化,如下表所示:
image.png

由於從 JDK 8 到 JDK 11 特性變化太多,對於這樣的性能下降問題,爲了能快速有效的解決,我們做了如下的嘗試。

3.1統一 JDK 8 和 JDK 11 的參數,驗證效果

由於 JDK 11 和 JDK 8 實現變化很多,部分功能完全不同,但是這些變化的功能一般都有參數控制,一種有效的嘗試:梳理 JDK 8 和 JDK 11 關於 G1 的參數,將它們設置爲相同的值,比如關閉 IHOP 的自適應,關閉線程調整等。這裏簡單的給出 JDK 8 和 JDK 11 不同參數的比較,如下圖所示:
image.png
將兩者參數都設置爲和 JDK 8 一樣的值,重新驗證測試,結果不變,JDK 11 性能仍然下降。

3.2GC日誌分析,確定JDK 11性能下降點

對於 JDK 8 和 JDK 11 同時配置日誌收集功能,重新測試,獲得 GC 日誌。通過 GC 日誌分析,我們發現差異主要在 G1 young gc 的 object copy 階段(耗時基本在這),JDK 11 的 Young GC 耗時大概 200ms,JDK 8 的 Young GC 耗時大概 100ms,兩者設置的目標停頓時間都是 100ms。
JDK 11 中 GC 日誌片段:
image.png
JDK 8中 GC 日誌片段:
image.png
我們對整個日誌做了統計,有以下發現:
併發標記時機不同,混合回收的時機也不同;
單次 GC 中對象複製的耗時不同,JDK 11 明顯更長;
總體 GC 次數 JDK 11 的更多,包括了併發標記的停頓次數;
總體 GC 的耗時 JDK 11 更多。
image.png
針對 Young GC 的性能劣化,我們重點關注測試了和 Young GC 相關的參數,例如:調整 UseDynamicNumberOfGCThreads、G1UseAdaptiveIHOP 、GCTimeRatio 均沒有效果。
下面我們嘗試使用不同的工具來進一步定位到底哪裏出了問題。

3.3JFR分析-確認日誌分析結果

畢昇 JDK 11和畢昇 JDK 8 都引入了 JFR,JFR 作爲 JVM 中問題定位的新貴,我們也在該案例進行了嘗試,關於JFR的原理和使用,參考本系列的技術文章:Java Flight Recorder - 事件機制詳解。

3.3.1JDK 11總體信息

image.png
JDK 8 中通過 JFR 收集信息。

3.3.2JDK 8總體信息

image.png
JFR 的結論和我們前面分析的結論一致,JDK 11 中中斷比例明顯高於 JDK 8。

3.3.3JDK 11中垃圾回收發生的情況

image.png

3.3.4JDK 8中垃圾回收發生的情況

image.png
從圖中可以看到在 JDK 11 中應用消耗內存的速度更快(曲線速率更爲陡峭),根據垃圾回收的原理,內存的消耗和分配相關。

3.3.5JDK 11中VM操作

image.png

3.3.6JDK 8中VM操作

image.png
通過 JFR 整體的分析,得到的結論和我們前面的一致,確定了 Young GC 可能存在問題,但是沒有更多的信息。

3.4火焰圖-發現熱點

爲了進一步的追蹤 Young GC 裏面到底發生了什麼導致對象賦值更爲耗時,我們使用Async-perf 進行了熱點採集。關於火焰圖的使用參考本系列的技術文章:使用 perf 解決 JDK8 小版本升級後性能下降的問題

3.4.1JDK 11的火焰圖

image.png

3.4.2JDK 11 GC部分火焰圖

image.png

3.4.3JDK 8的火焰圖

image.png

3.4.4JDK 8 GC部分火焰圖

image.png
通過分析火焰圖,並比較 JDK 8 和 JDK 11 的差異,可以得到:
在 JDK 11 中,耗時主要在:
1)G1ParEvacuateFollowersClosure::do_void()
2)G1RemSet::scan_rem_set
在 JDK 8 中,耗時主要在:
1)G1ParEvacuateFollowersClosure::do_void()
更一步,我們對 JDK 11 裏面新出現的 scan_rem_set() 進行更進一步分析,發現該函數僅僅和引用集相關,通過修改 RSet 相關參數(修改 G1ConcRefinementGreenZone ),將 RSet 的處理儘可能地從Young GC的操作中移除。火焰圖中參數不再成爲熱點,但是 JDK 11 仍然性能下降。
比較 JDK 8 和 JDK 11 中 G1ParEvacuateFollowersClosure::do_void() 中的不同,除了數組處理外其他的基本沒有變化,我們將 JDK 11 此處的代碼修改和 JDK 8 完全一樣,但是性能仍然下降。
結論:雖然 G1ParEvacuateFollowersClosure::do_void() 是性能下降的觸發點,但是此處並不是問題的根因,應該是其他的原因造成了該函數調用次數增加或者耗時增加。

3.5逐個版本驗證-最終確定問題

我們分析了所有可能的情況,仍然無法快速找到問題的根源,只能使用最笨的辦法,逐個版本來驗證從哪個版本開始性能下降。
在大量的驗證中,對於 JDK 9、JDK 10,以及小版本等都重新做了構建(關於 JDK 的構建可以參考官網),我們發現 JDK 9-B74 和 JDK 9-B73 有一個明顯的區別。爲此我們分析了 JDK 9-B73 合入的代碼。發現該代碼和 PLAB 的設置相關,爲此梳理了所有 PLAB 相關的變動:
B66 版本爲了解決 PLAB size 獲取不對的問題(根據 GC 線程數量動態調整,但是開啓 UseDynamicNumberOfGCThreads 後該值有問題,默認是關閉)修復了 bug。具體見 jira:Determining the desired PLAB size adjusts to the the number of threads at the wrong place
B74 發現有問題(desired_plab_sz 可能會有相除截斷問題和沒有對齊的問題),重新修改,具體見 8079555: REDO - Determining the desired PLAB size adjusts to the the number of threads at the wrong place
B115 中發現 B74 的修改,動態調整 PLAB 大小後,會導致很多情況 PLAB 過小(大概就是不走 PLAB,走了直接分配),頻繁的話會導致性能大幅下降,又做了修復 Net PLAB size is clipped to max PLAB size as a whole, not on a per thread basis

重新修改了代碼,打印 PLAB 的大小。對比後發現 desired_plab_sz 大小,在性能正常的版本中該值爲 1024 或者 4096(分別是 YoungPLAB 和 OLDPLAB),在性能下降的版本中該值爲 258。由此確認 desired_plab_sz 不正確的計算導致了性能下降。

3.6PLAB 爲什麼會引起性能下降?

PLAB 是 GC 工作線程在並行複製內存時使用的緩存,用於減少多個並行線程在內存分配時的鎖競爭。PLAB 的大小直接影響 GC 工作線程的效率。
在 GC 引入動態線程調整的功能時,將原來 PLABSize 的大小作爲多個線程的總體 PLAB 的大小,將 PLAB 重新計算,如下面代碼片段:
其中 desired_plab_sz 主要來自 YoungPLABSize 和 OldPLABSIze 的設置。所以這樣的代碼修改改變了 YoungPLABSize、OldPLABSize 參數的語義。

另外,在本例中,通過參數顯式地禁止了 ResizePLAB 是觸發該問題的必要條件,當打開 ResizePLAB 後,PLAB 會根據 GC 工作線程晉升對象的大小和速率來逐步調整 PLAB 的大小。
注意,衆多資料說明:禁止 ResziePLAB 是爲了防止 GC 工作線程的同步,這個說法是不正確的,PLAB 的調整耗時非常的小。PLAB 是 JVM 根據 GC 工作線程使用內存的情況,根據數學模型來調整大小,由於模型的誤差,可能導致 PLAB 的大小調整不一定有人工調參效果好。如果你沒有對 YoungPLABSize、OldPLABSize 進行調優,並不建議禁止 ResizePLAB。在 HBase 測試中,當打開 ResizePLAB 後 JDK 8 和 JDK 11 性能基本相同,也從側面說明了該參數的使用情況。

3.7解決方法&修復方法

由於該問題是 JDK 9 引入,在 JDK 9, JDK 10, JDK 11, JDK 12, JDK 13, JDK 14, JDK 15, JDK 16 都會存在性能下降的問題。
我們對該問題進行了修正,並提交到社區,具體見Jira: https://bugs.openjdk.java.net/browse/JDK-8257145;代碼見:https://github.com/openjdk/jdk/pull/1474;該問題在JDK 17中被修復。
同時該問題在畢昇 JDK 所有版本中第一時間得到解決。

當然對於短時間內無法切換 JDK 的同學,遇到這個問題,該如何解決?難道要等到 JDK 17?一個臨時的方法是顯式地設置 YoungPLABSize 和 OldPLABSize 的值。YoungPLABSize 設置爲 YoungPLABSize* ParallelGCThreads,其中 ParallelGCThreads 爲 GC 並行線程數。例如 YoungPLABSize 原來爲 1024,ParallelGCThreads 爲 8,在 JDK 9~16,將 YoungPLABSize 設置爲 8192 即可。
其中參數 ParallelGCThreads 的計算方法爲:沒有設置該參數時,當 CPU 個數小於等於 8, ParallelGCThreads 等於 CPU 個數,當 CPU 個數大於 8,ParallelGCThreads 等於 CPU 個數的 5/8)。

3.8小結

本文分享了針對 JDK 升級後性能下降的解決方法。Java 開發人員如果遇到此類問題,可以按照下面的步驟嘗試自行解決:
1.對齊不同 JDK 版本的參數,確保參數相同,看是否可以快速重現;
2.分析 GC 日誌,確定是否由 GC 引起。如果是,建議將所有的參數重新驗證,包括移除原來的參數。本例中一個最大的失誤是,在分析過程中沒有將原來業務提供的參數 ResizePLAB 移除重新測試,浪費了很多時間。如果執行該步驟後,定位問題可能可以節約很多時間;
3.使用一些工具,比如 JFR、NMT、火焰圖等。本例中嘗試使用這些工具,雖然無果,但基本上確認了問題點;
4.最後的最後,如果還是沒有解決,請聯繫畢昇 JDK 社區(點擊原文進入社區)。畢昇 JDK 社區每雙週週二舉行技術例會,同時有一個技術交流羣討論 GCC、LLVM 和 JDK 等相關編譯技術,感興趣的同學可以添加如下微信小助手入羣。

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