為任務關鍵型Java應用優化垃圾回收(上)

文 學敏發表於2016-08-06

最近,我有機會去測試並優化幾個基於Java購物和入口網站程式,它們執行在Sun/Oracle的JVM上。訪問量最高的幾個應用在德國。在很多情況下,垃圾回收是Java伺服器效能的一個關鍵因素。本文中,我們會研究一些先進垃圾回收演算法思想以及一些重要的調節引數。我們會在各種真實場景中比較這些引數。

從垃圾回收的角度來看,Java伺服器程式可以有廣泛多樣的需求:

  1. 一些高流量應用需要響應大量請求並建立非常多的物件。有時候,一些使用了高資源消耗框架的中等流量應用也會遇到同樣的問題。總之,對垃圾回收來說,如何有效地清理這些生成的物件是一個很大的挑戰。
  2. 另外,一些應用需要長時間執行並且在執行過程提供穩定的服務,要求效能不會隨著時間而慢慢變差或者突然惡化。
  3. 某些場景需要嚴格限制使用者響應時間(比如網路遊戲或者投注應用等),幾乎不允許額外的GC暫停。

在很多場景中,你可以通過不同的優先順序將幾種需求結合起來。我的幾個樣例程式對第一點要求比第二點要高很多,但是絕大部分程式不會同時對這三方面要求都高。這給你留下了足夠權衡的空間。

預設配置下JVM GC的效能

JVM有很多改進,但仍然不能在程式執行時對任務做優化。除了上面提到的三點,預設的JVM設定還有一個優先順序僅次於它們的需求:減小記憶體佔用。考慮到成千上萬的使用者並不是在記憶體充足的伺服器上執行。對很多電子商務產品也很重要,因為這些應用大部分時間被配置在開發筆記本上執行,而不是在商用伺服器上。因此,如果你的伺服器配置著最小的堆空間和GC引數,比如下面這樣配置,

java -Xmx1024m -XX:MaxPermSize=256m -cp Portal.jar my.portal.Portal

這樣肯定會導致系統執行不夠高效。首先,好的做法不僅配置記憶體最大限制,也需要配置初始記憶體大小,以避免伺服器在啟動過程中逐步增加記憶體。否則代價會很大。當知道伺服器需要多少記憶體時(你應該及時地查明),最好將初始記憶體大小與最大記憶體設定相等。可以通過以下JVM引數來設定:

-Xms1024m -XX:PermSize=256m

最後一個經常在JVM配置的基本選項是配置新生代堆記憶體大小,與上面設定的方式類似:

-XX:NewSize=200m -XX:MaxNewSize=200m

下面的章節會對上面的配置以及更復雜的配置給出解釋。首先,讓我們看一個入口網站的應用,它執行在一臺相當慢的測試主機上。當進行負載測試時,它的垃圾回收是怎麼工作的:

圖1 堆大小稍微優化後的JVM在25小時左右的GC行為(-Xms1024m -Xmx1024m -XX:NewSize=200m -XX:MaxNewSize=200m)

其中,藍色的曲線表示總的堆記憶體佔用量隨時間的變化,垂直的灰色線條表示GC暫停的間隔。

除了曲線圖,GC操作的關鍵指標和效能顯示在最右邊。首先我們看一下在這次測試中,垃圾被建立(和回收)的平均量。30.5MB/s的數值被標為黃色,因為這是一個相當大但還可以的垃圾生成速率,對一個引導性的GC調優例子而言還算可以。其他值表示JVM在清理這些垃圾時的表現:99.55%的垃圾是在新生代中被清理的,老年代的只佔0.45%。這個結果相當不錯,因此標為綠色。

之所以有這樣的結果,可以從GC引入的暫停間隔看出來(以及處理使用者請求的工作執行緒):有很多但很短暫的新生代GC間隔,平均每6s一次,持續時間不會超過50ms。這些暫停使JVM停止執行的時間佔總時間的0.77%,但是每次暫停對等待伺服器響應的使用者來說完全感覺不到。

另一方面,老年代GC的暫停只佔總時間的0.19%。但是,在這段時間內老年代GC只清理了0.45%的垃圾,而新生代GC用佔0.77%的時間清理了99.55%的垃圾。可見,與新生代GC相比,老年代GC是多麼低效。另外,老年代GC的暫停平均觸發速率不到一個小時一次,但平均持續時間可達到8s,最大異常值甚至達到19s。由於這些暫停會真正地停止JVM處理使用者請求的執行緒,因此暫停應儘量不頻發且持續時間短。

通過以上觀察可以得出分代垃圾回收的基本調優目標:

  • 新生代GC儘量回收多的垃圾,避免老年代GC頻發且持續時間較短。

分代垃圾回收的基本思想與堆記憶體大小調整

先從下圖開始。這個圖可以通過JDK工具得到,比如jstat或者jvisualvm以及它的visualgc外掛:

圖2 JVM的堆記憶體結構,包括新生代的子分割槽(最左列)

Java的堆記憶體由永久代(Perm),老年代(Old)和新生代(New or Young)組成。新生代進一步劃分為一個Eden空間和兩個Survivor空間S0、S1。Eden空間是物件被建立時的地方,經過幾輪新生代GC後,他們有可能被存放在Survivor空間。如果想了解更多,可以讀一下Sun/Oracle的白皮書Memory Management in the Java HotSpot Virtual Machine

預設情況下,作為整體的新生代特別是Survivor空間太小,導致在GC清理大部分記憶體之前就無法儲存更多物件。因此,這些物件被過早地儲存在老年代中,這會導致老年代被迅速填滿,必須頻繁地清理垃圾。這也是圖1中產生較多的Full GC暫停的原因。

(譯者注:一般新生代的垃圾回收也稱為Minor GC,老年代的垃圾回收稱為Major GC或Full GC)

優化新生代記憶體大小

優化分代垃圾回收意味著讓新生代,特別是Survivor空間,比預設情形大。但是同時也要考慮虛擬機器使用的具體GC演算法。

當前硬體上執行的Sun/Oracle虛擬機器使用了ParallelGC作為預設GC演算法。如果使用的不是預設演算法,可以通過顯式配置JVM引數來實現:

-XX:+UseParallelGC

預設情況下,這個演算法並不在固定大小的Eden和Survivor空間中執行。它使用了一種自適應調整大小的策略,稱為“AdaptiveSizePolicy”策略。正如描述的那樣,它可以適應很多場景,包括伺服器以外的機器的使用。但在伺服器上執行時,這並不是最優策略。為了可以顯式地設定固定的Survivor空間大小,可以通過以下JVM引數關閉它:

-XX:-UseAdaptiveSizePolicy

一旦這麼設定後,就不能進一步增加新生代空間的大小,但我們可以有效地為Survivor空間設定合適的大小:

-XX:NewSize=400m -XX:MaxNewSize=400m -XX:SurvivorRatio=6

“SurvivorRatio=6”表示Survivor空間是Eden空間的1/6或者是整個新生代空間的1/8,在這個例子中就是50MB,而自適應大小策略經常執行在非常小的空間上,大約只有幾MB。使用現在的配置,重複上面的負載測試,我們得到了下面的結果:

圖3 堆記憶體優化後的JVM在50小時內的GC行為(-Xms1024m -Xmx1024m -XX:NewSize=400m -XX:MaxNewSize=400m -XX:-UseAdapativeSizePolicy -XX:SurvivorRatio=6)

這次的測試時間是上次的兩倍,而垃圾的平均建立速率和之前基本一致(30.2MB/s,之前是30.5MB/s)。然而,整個過程只有兩次老年代(Full)GC暫停,25小時左右才發生一次。這是因為老年代垃圾死亡速率(所謂的promation rate)從137kB/s減小到了6kB/s,老年代的垃圾回收只佔整體的0.02%。同時新生代GC的暫停持續時間僅僅從平均48ms增加到57ms,兩次暫停的間隔從6s增長到10s。總之,關閉了自適應大小調整,合理地優化堆記憶體大小,使GC暫停佔總時間的比例從0.95%減小到0.59%,這是一個非常棒的結果。

優化後,使用ParNew演算法作為預設ParallelGC的替代,也能得到相似的結果。這個演算法是為了與CMS演算法相容而開發的,可以通過JVM引數來配置-XX:+UseParNewGC。關於CMS下面會提到。這個演算法不使用自適應大小策略,可以執行在固定Survivor大小的空間上。因此,即使使用預設的配置SurvivorRatio=8,也比ParallelGC擁有更高的伺服器利用率。

避免老年代GC的長時間暫停

上述結果的最後一個問題就是,老年代GC的長時間暫停平均為8s左右。通過適當的優化,老年代GC暫停已經很少了,但是一旦觸發,對使用者來說還是很煩人的。因為在暫停期間,JVM不能執行工作執行緒。在我們的例子中,8s的長度是由低速老舊的測試機導致的,在現代硬體上速度能快3倍左右。另一方面,現在的應用一般使用1G以上的堆記憶體,可以容納更多的物件。當前的網路應用使用的堆記憶體能達到64GB,(至少)需要一半的記憶體來儲存存活的物件。在這種情況下,8s對老年代暫停來說是很短的。這些應用中的老年代GC可以很隨意地就接近1分鐘,對於互動式網路應用來說是絕對不能接受的。

緩解這個問題的一個選擇就是採用並行的方式處理老年代GC。預設情況下,在Java 6中,ParallelGC和ParNew GC演算法使用多個GC執行緒來處理新生代GC,而老年代GC是單執行緒的。以ParallelGC回收器為例,可以在使用時新增以下引數:

-XX:+UseParallelOldGC

從Java 7開始,這個選項和-XX:+UseParallelGC預設被啟用。但是,即使你的系統是4核或8核,也不要期望效能可以提高2倍以上。通常的結果會比2被小一些。在某些例子中,比如上述例子中的8s,這種提高還是比較有效的。但在很多極端的例子中,這還遠遠不夠。解決方法是使用低延遲GC演算法。

下篇中會討論CMS(The Concurrent Mark and Sweep Collector)、幽靈般的碎片、G1(Garbage First)垃圾收集器和垃圾收集器的量化比較,最後給出總結。

為任務關鍵型Java應用優化垃圾回收(下)

相關文章