jvm系列(九):如何優化Java GC「譯」
本文由CrowHawk(https://crowhawk.github.io/2017/08/21/jvm_4/)翻譯,是Java GC調優的經典佳作。
本文翻譯自Sangmin Lee發表在Cubrid上的"Become a Java GC Expert"系列文章的第三篇《How to Tune Java Garbage Collection》,本文的作者是韓國人,寫在JDK 1.8發布之前,雖然有些地方有些許過時,但整體內容還是非常有價值的。譯者此前也看到有人翻譯了本文,發現其中有許多錯漏生硬和語焉不詳之處,因此決定自己翻譯一份,供大家分享。
筆者將基于實際生產環境中的案例,介紹幾個GC優化的***參數設置。在此我們假設你已經理解了本系列前兩篇文章的內容,因此為了更深入的理解本文所講內容。
GC優化是必要的嗎?
或者更準確地說,GC優化對Java基礎服務來說是必要的嗎?答案是否定的,事實上GC優化對Java基礎服務來說在有些場合是可以省去的,但前提是這些正在運行的Java系統,必須包含以下參數或行為:
- 內存大小已經通過-Xms和-Xmx參數指定過
- 運行在server模式下(使用-server參數)
- 系統中沒有殘留超時日志之類的錯誤日志
換句話說,如果你在運行時沒有手動設置內存大小并且打印出了過多的超時日志,那你就需要對系統進行GC優化。
不過你需要時刻謹記一句話:GC tuning is the last task to be done.
現在來想一想GC優化的最根本原因,垃圾收集器的工作就是清除Java創建的對象,垃圾收集器需要清理的對象數量以及要執行的GC數量均取決于已創建的對象數量。因此,為了使你的系統在GC上表現良好,首先需要減少創建對象的數量。
俗話說“冰凍三尺非一日之寒”,我們在編碼時要首先要把下面這些小細節做好,否則一些瑣碎的不良代碼累積起來將讓GC的工作變得繁重而難于管理:
使用 StringBuilder或 StringBuffer來代替 String盡量少輸出日志
盡管如此,仍然會有我們束手無策的情況。XML和JSON解析過程往往占用了最多的內存,即使我們已經盡可能地少用String、少輸出日志,仍然會有大量的臨時內存(大約10-100MB)被用來解析XML或JSON文件,但我們又很難棄用XML和JSON。在此,你只需要知道這一過程會占據大量內存即可。
如果在經過幾次重復的優化后應用程序的內存用量情況有所改善,那么久可以啟動GC優化了。
筆者總結了GC優化的兩個目的:
- 將進入老年代的對象數量降到***
- 減少Full GC的執行時間
將進入老年代的對象數量降到***
除了可以在JDK 7及更高版本中使用的G1收集器以外,其他分代GC都是由Oracle JVM提供的。關于分代GC,就是對象在Eden區被創建,隨后被轉移到Survivor區,在此之后剩余的對象會被轉入老年代。也有一些對象由于占用內存過大,在Eden區被創建后會直接被傳入老年代。老年代GC相對來說會比新生代GC更耗時,因此,減少進入老年代的對象數量可以顯著降低Full GC的頻率。你可能會以為減少進入老年代的對象數量意味著把它們留在新生代,事實正好相反,新生代內存的大小是可以調節的。
降低Full GC的時間
Full GC的執行時間比Minor GC要長很多,因此,如果在Full GC上花費過多的時間(超過1s),將可能出現超時錯誤。
- 如果通過減小老年代內存來減少Full GC時間,可能會引起 OutOfMemoryError或者導致Full GC的頻率升高。
- 另外,如果通過增加老年代內存來降低Full GC的頻率,Full GC的時間可能因此增加。
因此,你需要把老年代的大小設置成一個“合適”的值。
影響GC性能的參數
正如我在系列的***篇文章《理解Java GC》末尾提到的,不要幻想著“如果有人用他設置的GC參數獲取了不錯的性能,我們為什么不復制他的參數設置呢?”,因為對于不用的Web服務,它們創建的對象大小和生命周期都不相同。
舉一個簡單的例子,如果一個任務的執行條件是A,B,C,D和E,另一個完全相同的任務執行條件只有A和B,那么哪一個任務執行速度更快呢?作為常識來講,答案很明顯是后者。
Java GC參數的設置也是這個道理,設置好幾個參數并不會提升GC執行的速度,反而會使它變得更慢。GC優化的基本原則是將不同的GC參數應用到兩個及以上的服務器上然后比較它們的性能,然后將那些被證明可以提高性能或減少GC執行時間的參數應用于最終的工作服務器上。
下面這張表展示了與內存大小相關且會影響GC性能的GC參數
筆者在進行GC優化時最常用的參數是 -Xms, -Xmx和 -XX:NewRatio。 -Xms和 -Xmx參數通常是必須的,所以 NewRatio的值將對GC性能產生重要的影響。
有些人可能會問如何設置***代內存大小,你可以用 -XX:PermSize和 -XX:MaxPermSize參數來進行設置,但是要記住,只有當出現 OutOfMemoryError錯誤時你才需要去設置***代內存。
還有一個會影響GC性能的因素是垃圾收集器的類型,下表展示了關于GC類型的可選參數(基于JDK 6.0):
除了G1收集器外,可以通過設置上表中每種類型***行的參數來切換GC類型,最常見的非侵入式GC就是Serial GC,它針對客戶端系統進行了特別的優化。
會影響GC性能的參數還有很多,但是上述的參數會帶來最顯著的效果,請切記,設置太多的參數并不一定會提升GC的性能。
GC優化的過程
GC優化的過程和大多數常見的提升性能的過程相似,下面是筆者使用的流程:
1.監控GC狀態
你需要監控GC從而檢查系統中運行的GC的各種狀態,具體方法請查看系列的第二篇文章《如何監控Java GC》
2.分析監控結果后決定是否需要優化GC
在檢查GC狀態后,你需要分析監控結構并決定是否需要進行GC優化。如果分析結果顯示運行GC的時間只有0.1-0.3秒,那么就不需要把時間浪費在GC優化上,但如果運行GC的時間達到1-3秒,甚至大于10秒,那么GC優化將是很有必要的。
但是,如果你已經分配了大約10GB內存給Java,并且這些內存無法省下,那么就無法進行GC優化了。在進行GC優化之前,你需要考慮為什么你需要分配這么大的內存空間,如果你分配了1GB或2GB大小的內存并且出現了 OutOfMemoryError,那你就應該執行堆轉儲(heap dump)來消除導致異常的原因。
注意:
堆轉儲(heap dump)是一個用來檢查Java內存中的對象和數據的內存文件。該文件可以通過執行JDK中的 jmap命令來創建。在創建文件的過程中,所有Java程序都將暫停,因此,不要再系統執行過程中創建該文件。
你可以在互聯網上搜索heap dump的詳細說明。對于韓國讀者,可以直接參考我去年發布的書:《The story of troubleshooting for Java developers and system operators》 (Sangmin Lee, Hanbit Media, 2011, 416 pages)
3.設置GC類型/內存大小
如果你決定要進行GC優化,那么你需要選擇一個GC類型并且為它設置內存大小。此時如果你有多個服務器,請如上文提到的那樣,在每臺機器上設置不同的GC參數并分析它們的區別。
4.分析結果
在設置完GC參數后就可以開始收集數據,請在收集至少24小時后再進行結果分析。如果你足夠幸運,你可能會找到系統的***GC參數。如若不然,你還需要分析輸出日志并檢查分配的內存,然后需要通過不斷調整GC類型/內存大小來找到系統的***參數。
5.如果結果令人滿意,將參數應用到所有服務器上并結束GC優化
如果GC優化的結果令人滿意,就可以將參數應用到所有服務器上,并停止GC優化。
在下面的章節中,你將會看到上述每一步所做的具體工作。
監控GC狀態并分析結果
在運行中的Web應用服務器(Web Application Server,WAS)上查看GC狀態的***方式就是使用 jstat命令。筆者在《如何監控Java GC》中已經介紹過了 jstat命令,所以在本篇文章中我將著重關注數據部分。
下面的例子展示了某個還沒有執行GC優化的JVM的狀態(雖然它并不是運行服務器)。
- $ jstat -gcutil 21719 1s
- S0 S1 E O P YGC YGCT FGC FGCT GCT
- 48.66 0.00 48.10 49.70 77.45 3428 172.623 3 59.050 231.673
- 48.66 0.00 48.10 49.70 77.45 3428 172.623 3 59.050 231.673
我們先看一下YGC(從應用程序啟動到采樣時發生 Young GC 的次數)和YGCT(從應用程序啟動到采樣時 Young GC 所用的時間(秒)),計算YGCT/YGC會得出,平均每次新生代的GC耗時50ms,這是一個很小的數字,通過這個結果可以看出,我們大可不必關注新生代GC對GC性能的影響。
現在來看一下FGC( 從應用程序啟動到采樣時發生 Full GC 的次數)和FGCT(從應用程序啟動到采樣時 Full GC 所用的時間(秒)),計算FGCT/FGC會得出,平均每次老年代的GC耗時19.68s。有可能是執行了三次Full GC,每次耗時19.68s,也有可能是有兩次只花了1s,另一次花了58s。不管是哪一種情況,GC優化都是很有必要的。
使用 jstat命令可以很容易地查看GC狀態,但是分析GC的***方式是加上 -verbosegc參數來生成日志。在之前的文章中筆者已經解釋了如何分析這些日志。HPJMeter是筆者最喜歡的用于分析 -verbosegc生成的日志的工具,它簡單易用,使用HPJmeter可以很容易地查看GC執行時間以及GC發生頻率。
此外,如果GC執行時間滿足下列所有條件,就沒有必要進行GC優化了:
- Minor GC執行非常迅速(50ms以內)
- Minor GC沒有頻繁執行(大約10s執行一次)
- Full GC執行非常迅速(1s以內)
- Full GC沒有頻繁執行(大約10min執行一次)
括號中的數字并不是絕對的,它們也隨著服務的狀態而變化。有些服務可能要求一次Full GC在0.9s以內,而有些則會放得更寬一些。因此,對于不同的服務,需要按照不同的標準考慮是否需要執行GC優化。
當檢查GC狀態時,不能只查看Minor GC和Full GC的時間,還必須要關注GC執行的次數。如果新生代空間太小,Minor GC將會非常頻繁地執行(有時每秒會執行一次,甚至更多)。此外,傳入老年代的對象數目會上升,從而導致Full GC的頻率升高。因此,在執行 jstat命令時,請使用 -gccapacity參數來查看具體占用了多少空間。
設置GC類型/內存大小
設置GC類型
Oracle JVM有5種垃圾收集器,但是在JDK 7以前的版本中,你只能在Parallel GC, Parallel Compacting GC 和CMS GC之中選擇,至于具體選擇哪個,則沒有具體的原則和規則。
既然這樣的話,我們如何來選擇GC呢?***的方法是把三種都用上,但是有一點必須明確——CMS GC通常比其他并行(Parallel)GC都要快(這是因為CMS GC是并發的GC),如果確實如此,那只選擇CMS GC就可以了,不過CMS GC也不總是更快,當出現concurrent mode failure時,CMS GC就會比并行GC更慢了。
Concurrent mode failure
現在讓我們來深入地了解一下concurrent mode failure。
并行GC和CMS GC的***區別是并行GC采用“標記-整理”(Mark-Compact)算法而CMS GC采用“標記-清除”(Mark-Sweep)算法(具體內容可參照譯者的文章《GC算法與內存分配策略》),compact步驟就是通過移動內存來消除內存碎片,從而消除分配的內存之間的空白區域。
對于并行GC來說,無論何時執行Full GC,都會進行compact工作,這消耗了太多的時間。不過在執行完Full GC后,下次內存分配將會變得更快(因為直接順序分配相鄰的內存)。
相反,CMS GC沒有compact的過程,因此CMS GC運行的速度更快。但是也是由于沒有整理內存,在進行磁盤清理之前,內存中會有很多零碎的空白區域,這也導致沒有足夠的空間分配給大對象。例如,在老年代還有300MB可用空間,但是連一個10MB的對象都沒有辦法被順序存儲在老年代中,在這種情況下,會報出“concurrent mode failure”的warning,然后系統執行compact操作。但是CMS GC在這種情況下執行的compact操作耗時要比并行GC高很多,并且這還會導致另一個問題,關于“concurrent mode failure”的詳細說明,可用參考Oracle工程師撰寫的《Understanding CMS GC Logs》。
綜上所述,你需要根據你的系統情況為其選擇一個最適合的GC類型。
每個系統都有最適合它的GC類型等著你去尋找,如果你有6臺服務器,我建議你每兩個服務器設置相同的參數,然后加上 -verbosegc參數再分析結果。
設置內存大小
下面展示了內存大小、GC運行次數和GC運行時間之間的關系:
大內存空間
- 減少了GC的次數
- 提高了GC的運行時間
小內存空間
- 增多了GC的次數
- 降低了GC的運行時間
關于如何設置內存的大小,沒有一個標準答案,如果服務器資源充足并且Full GC能在1s內完成,把內存設為10GB也是可以的,但是大部分服務器并不處在這種狀態中,當內存設為10GB時,Full GC會耗時10-30s,具體的時間自然與對象的大小有關。
既然如此,我們該如何設置內存大小呢?通常我推薦設為500MB,這不是說你要通過 -Xms500m和 -Xmx500m參數來設置WAS內存。根據GC優化之前的狀態,如果Full GC后還剩余300MB的空間,那么把內存設為1GB是一個不錯的選擇(300MB(默認程序占用)+ 500MB(老年代最小空間)+200MB(空閑內存))。這意味著你需要為老年代設置至少500MB空間,因此如果你有三個運行服務器,可以把它們的內存分別設置為1GB,1.5GB,2GB,然后檢查結果。
理論上來說,GC執行速度應該遵循1GB> 1.5GB> 2GB,1GB內存時GC執行速度最快。然而,理論上的1GB內存Full GC消耗1s、2GB內存Full GC消耗2 s在現實里是無法保證的,實際的運行時間還依賴于服務器的性能和對象大小。因此,***的方法是創建盡可能多的測量數據并監控它們。
在設置內存空間大小時,你還需要設置一個參數: NewRatio。 NewRatio的值是新生代和老年代空間大小的比例。如果 XX:NewRatio=1,則新生代空間:老年代空間=1:1,如果堆內存為1GB,則新生代:老年代=500MB:500MB。如果 NewRatio等于2,則新生代:老年代=1:2,因此, NewRatio的值設置得越大,則老年代空間越大,新生代空間越小。
你可能會認為把 NewRatio設為1會是***的選擇,然而事實并非如此,根據筆者的經驗,當 NewRatio設為2或3時,整個GC的狀態表現得更好。
完成GC優化最快地方法是什么?答案是比較性能測試的結果。為了給每臺服務器設置不同的參數并監控它們,***查看的是一或兩天后的數據。當通過性能測試來進行GC優化時,你需要在不同的測試時保證它們有相同的負載和運行環境。然而,即使是專業的性能測試人員,想精確地控制負載也很困難,并且需要大量的時間準備。因此,更加方便容易的方式是直接設置參數來運行,然后等待運行的結果(即使這需要消耗更多的時間)。
分析GC優化的結果
在設置了GC參數和 -verbosegc參數后,可以使用tail命令確保日志被正確地生成。如果參數設置得不正確或日志未生成,那你的時間就被白白浪費了。如果日志收集沒有問題的話,在收集一或兩天數據后再檢查結果。最簡單的方法是把日志從服務器移到你的本地PC上,然后用HPJMeter分析數據。
在分析結果時,請關注下列幾點(這個優先級是筆者根據自己的經驗擬定的,我認為選取GC參數時應考慮的最重要的因素是Full GC的運行時間。):
- 單次Full GC運行時間
- 單次Minor GC運行時間
- Full GC運行間隔
- Minor GC運行間隔
- 整個Full GC的時間
- 整個Minor GC的運行時間
- 整個GC的運行時間
- Full GC的執行次數
- Minor GC的執行次數
找到***的GC參數是件非常幸運的,然而在大多數時候,我們并不會如此幸運,在進行GC優化時一定要小心謹慎,因為當你試圖一次完成所有的優化工作時,可能會出現 OutOfMemoryError錯誤。
優化案例
到目前為止,我們一直在從理論上介紹GC優化,現在是時候將這些理論付諸實踐了,我們將通過幾個例子來更深入地理解GC優化。
示例1
下面這個例子是針對Service S的優化,對于最近剛開發出來的Service S,執行Full GC需要消耗過多的時間。
現在看一下執行 jstat-gcutil的結果
- S0 S1 E O P YGC YGCT FGC FGCT GCT
- 12.16 0.00 5.18 63.78 20.32 54 2.047 5 6.946 8.993
左邊的Perm區的值對于最初的GC優化并不重要,而YGC參數的值更加對于這次優化更為重要。
平均執行一次Minor GC和Full GC消耗的時間如下表所示:
37ms對于Minor GC來說還不賴,但1.389s對于Full GC來說意味著當GC發生在數據庫Timeout設置為1s的系統中時,可能會頻繁出現超時現象。
首先,你需要檢查開始GC優化前內存的使用情況。使用 jstat-gccapacity命令可以檢查內存用量情況。在筆者的服務器上查看到的結果如下:
- NGCMN NGCMX NGC S0C S1C EC OGCMN OGCMX OGC OC PGCMN PGCMX PGC PC YGC FGC
- 212992.0 212992.0 212992.0 21248.0 21248.0 170496.0 1884160.0 1884160.0 1884160.0 1884160.0 262144.0 262144.0 262144.0 262144.0 54 5
其中的關鍵值如下:
- 新生代內存用量:212,992 KB
- 老年代內存用量:1,884,160 KB
因此,除了***代以外,被分配的內存空間加起來有2GB,并且新生代:老年代=1:9,為了得到比使用 jstat更細致的結果,還需加上 -verbosegc參數獲取日志,并把三臺服務器按照如下方式設置(除此以外沒有使用任何其他參數):
- NewRatio=2
- NewRatio=3
- NewRatio=4
一天后我得到了系統的GC log,幸運的是,在設置完NewRatio后系統沒有發生任何Full GC。
這是為什么呢?這是因為大部分對象在創建后很快就被回收了,所有這些對象沒有被傳入老年代,而是在新生代就被銷毀回收了。
在這樣的情況下,就沒有必要去改變其他的參數值了,只要選擇一個最合適的 NewRatio值即可。那么,如何確定***的NewRatio值呢?為此,我們分析一下每種 NewRatio值下Minor GC的平均響應時間。
在每種參數下Minor GC的平均響應時間如下:
- NewRatio=2:45ms
- NewRatio=3:34ms
- NewRatio=4:30ms
我們可以根據GC時間的長短得出NewRatio=4是***的參數值(盡管NewRatio=4時新生代空間是最小的)。在設置完GC參數后,服務器沒有發生Full GC。
為了說明這個問題,下面是服務執行一段時間后執行 jstat–gcutil的結果:
- S0 S1 E O P YGC YGCT FGC FGCT GCT
- 8.61 0.00 30.67 24.62 22.38 2424 30.219 0 0.000 30.219
你可能會認為是服務器接收的請求少才使得GC發生的頻率較低,實際上,雖然Full GC沒有執行過,但Minor GC被執行了2424次。
示例2
這是一個Service A的例子。我們通過公司內部的應用性能管理系統(APM)發現JVM暫停了相當長的時間(超過8秒),因此我們進行了GC優化。我們努力尋找JVM暫停的原因,后來發現是因為Full GC執行時間過長,因此我們決定進行GC優化。
在GC優化的開始階段,我們加上了 -verbosegc參數,結果如下圖所示:
圖1:進行GC優化之前STW的時間
上圖是由HPJMeter生成的圖片之一。橫坐標表示JVM執行的時間,縱坐標表示每次GC的時間。CMS為綠點,表示Full GC的結果,而Parallel Scavenge為藍點,表示Minor GC的結果。
之前我說過CMS GC是最快的GC,但是上面的結果顯示在一些時候CMS耗時達到了15s。是什么導致了這一結果?請記住我之前說的:CMS在執行compact(整理)操作時會顯著變慢。此外,服務的內存通過 -Xms1g和 =Xmx4g設置了,而分配的內存只有4GB。
因此筆者將GC類型從CMS GC改為了Parallel GC,把內存大小設為2GB,并把 NewRatio設為3。在執行 jstat-gcutil幾小時后的結果如下:
- S0 S1 E O P YGC YGCT FGC FGCT GCT
- 0.00 30.48 3.31 26.54 37.01 226 11.131 4 11.758 22.890
Full GC的時間縮短了,變成了每次3s,跟15s比有了顯著提升。但是3s依然不夠快,為此筆者創建了以下6種情況:
- Case 1: -XX:+UseParallelGC-Xms1536m-Xmx1536m-XX:NewRatio=2
- Case 2: -XX:+UseParallelGC-Xms1536m-Xmx1536m-XX:NewRatio=3
- Case 3: -XX:+UseParallelGC-Xms1g-Xmx1g-XX:NewRatio=3
- Case 4: -XX:+UseParallelOldGC-Xms1536m-Xmx1536m-XX:NewRatio=2
- Case 5: -XX:+UseParallelOldGC-Xms1536m-Xmx1536m-XX:NewRatio=3
- Case 6: -XX:+UseParallelOldGC-Xms1g-Xmx1g-XX:NewRatio=3
上面哪一種情況最快?結果顯示,內存空間越小,運行結果最少。下圖展示了性能***的Case 6的結果圖,它的最慢響應時間只有1.7s,并且響應時間的平均值已經被控制到了1s以內。
圖2:Case 6的持續時間圖
基于上圖的結果,按照Case 6調整了GC參數,但這卻導致每晚都會發生 OutOfMemoryError。很難解釋發生異常的具體原因,簡單地說,應該是批處理程序導致了內存泄漏,我們正在解決相關的問題。
如果只對GC日志做一些短時間的分析就將相關參數部署到所有服務器上來執行GC優化,這將是非常危險的。切記,只有當你同時仔細分析服務的執行情況和GC日志后,才能保證GC優化沒有錯誤地執行。
在上文中,我們通過兩個GC優化的例子來說明了GC優化是怎樣執行的。正如上文中提到的,例子中設置的GC參數可以設置在相同的服務器之上,但前提是他們具有相同的CPU、操作系統、JDK版本并且運行著相同的服務。此外,不要把我使用的參數照搬到你的應用上,它們可能在你的機器上并不能起到同樣良好的效果。
總結
筆者沒有執行heap dump并分析內存的詳細內容,而是通過自己的經驗進行GC優化。精確地分析內存可以得到更好的優化效果,不過這種分析一般只適用于內存使用量相對固定的場景。如果服務嚴重過載并占有了大量的內存,則建議你根據之前的經驗進行GC優化。
筆者已經在一些服務上設置了G1 GC參數并進行了性能測試,但還沒有應用于正式的生產環境。G1 GC的速度快于任何其他的GC類型,但是你必須要升級到JDK 7。此外,暫時還無法保證它的穩定性,沒有人知道運行時是否會出現致命的錯誤,因此G1 GC暫時還不適合投入應用。
等未來JDK 7真正穩定了(這并不是說它現在不穩定),并且WAS針對JDK 7進行優化后,G1 GC最終能按照預期的那樣來工作,等到那一天我們可能就不再需要GC優化了。