前段時間在一個項目的性能測試中又發生了一次OOM(Out of swap sapce),情形和以前網店版的那次差不多,比上次更奇怪的是,此次搞了幾天之后啥都沒調整系統就自動好了,死活沒法再重現之前的OOM了!問題雖然蹊蹺,但也趁此機會再次對JVM堆模型、GC垃圾算法等進行了一次系統梳理;
基本概念
堆/Heap
JVM管理的內存叫堆;在32Bit操作系統上有4G的限制,一般來說Windows下為2G,而Linux下為3G;64Bit的就沒有這個限制。
JVM初始分配的內存由-Xms指定,默認是物理內存的1/64但小于1G。
JVM最大分配的內存由-Xmx指定,默認是物理內存的1/4但小于1G。
默認空余堆內存小于40%時,JVM就會增大堆直到-Xmx的最大限制,可以由-XX:MinHeapFreeRatio=指定。
默認空余堆內存大于70%時,JVM會減少堆直到-Xms的最小限制,可以由-XX:MaxHeapFreeRatio=指定。
服務器一般設置-Xms、-Xmx相等以避免在每次GC后調整堆的大小,所以上面的兩個參數沒啥用。
分代/堆模型
分代是Java垃圾收集的一大亮點,根據對象的生命周期長短,把堆分為3個代:Young,Old和Permanent,根據不同代的特點采用不同的收集算法,可以揚長避短。可參考如下的模型圖:
Young(Nursery):年輕代
研究表明大部分對象都是朝生暮死,隨生隨滅的。所以對于年輕代在GC時都采取復制收集算法,具體算法參考下面的描述;
Young的默認值為4M,隨堆內存增大,約為1/15,JVM會根據情況動態管理其大小變化。
Young里面又分為3個區域,一個Eden,所有新建對象都會存在于該區,兩個Survivor區,用來實施復制算法。
-XX:NewRatio= 參數可以設置Young與Old的大小比例,-server時默認為1:2,但實際上young啟動時遠低于這個比率?如果信不過JVM,也可以用-Xmn硬性規定其大小,有文檔推薦設為Heap總大小的1/4。
-XX:SurvivorRatio= 參數可以設置Eden與Survivor的比例,默認為32。Survivio大了會浪費,小了的話,會使一些年輕對象潛逃到老人區,引起老人區的不安,但這個參數對性能并不太重要。
Old(Tenured):年老代
年輕代的對象如果能夠挺過數次收集,就會進入老人區。老人區使用標記整理算法。因為老人區的對象都沒那么容易死的,采用復制算法就要反復的復制對象,很不合算,只好采用標記清理算法,但標記清理算法其實也不輕松,每次都要遍歷區域內所有對象,所以還是沒有免費的午餐啊。
-XX:MaxTenuringThreshold= 設置熬過年輕代多少次收集后移入老人區,CMS中默認為0,熬過第一次GC就轉入,可以用-XX:+PrintTenuringDistribution查看。
Permanent:持久代
裝載Class信息等基礎數據,默認64M,如果是類很多很多的服務程序,需要加大其設置-XX:MaxPermSize=,否則它滿了之后會引起fullgc()或Out of Memory。 注意Spring,Hibernate這類喜歡AOP動態生成類的框架需要更多的持久代內存。一般情況下,持久代是不會進行GC的,除非通過-XX:+CMSClassUnloadingEnabled -XX:+CMSPermGenSweepingEnabled進行強制設置。
GC的類型
當每個代滿了之后都會自動促發collection,各收集器觸發的條件不一樣,當然也可以通過一些參數進行強制設定。主要分為兩種類型:
- Minor Collection:GC用較高的頻率對young進行掃描和回收,采用復制算法。
- Major Collection:同時對Young和Old進行內存收集,也叫Full GC;因為成本關系對Old的檢查回收頻率要比Young低很多,采用標記清除/標記整理算法。可以通過調用代碼System.gc()引發major collection,使用-XX:+DisableExplicitGC禁止它,或設為CMS并發-XX:+ExplicitGCInvokesConcurrent。
更為具體的闡述如下:
由于年輕代進進出出的人多而頻繁,所以年輕代的GC也就頻繁一點,但涉及范圍也就年輕代這點彈丸之地內的對象,其特點就是少量,多次,但快速,稱之為Minor Collection。當年輕代的內存使用達到一定的閥值時,Minor Collection就被觸發,Eden及某一Survior space(from space)之內存活的的對象被移到另一個空的Survior space(to space)中,然后from space和to space角色對調。當一個對象在兩個survivor space之間移動過一定次數(達到預設的閥值)時,它就足夠old了,夠資格呆在年老代了。當然,如果survivor
space比較小不足以容下所有live objects時,部分live objects也會直接晉升到年老代。
Survior spaces可以看作是Eden和年老代之間的緩沖,通過該緩沖可以檢驗一個對象生命周期是否足夠的長,因為某些對象雖然逃過了一次Minor Collection,并不能說明其生命周期足夠長,說不定在下一次Minor Collection之前就掛了。這樣一定程度上確保了進入年老代的對象是貨真價實的,減少了年老代空間使用的增長速度,也就降低年老代GC的頻率。
當年老代或者永久代的內存使用達到一定閥值時,一次基于所有代的GC就觸發了,其特定是涉及范圍廣(量大),耗費的時間相對較長(較慢),但是頻率比較低(次數少),稱之為Major Collection(Full Collection)。通常,首先使用針對年輕代的GC算法進行年輕代的GC,然后使用針對年老代的GC算法對年老代和永久代進行GC。
基本GC收集算法
- 復制(copying):將堆內分成兩個相同空間,從根(ThreadLocal的對象,靜態對象)開始訪問每一個關聯的活躍對象,將空間A的活躍對象全部復制到空間B,然后一次性回收整個空間A。
因為只訪問活躍對象,將所有活動對象復制走之后就清空整個空間,不用去訪問死對象,所以遍歷空間的成本較小,但需要巨大的復制成本和較多的內存。可參考如下的示例圖:

- 標記清除(mark-sweep):收集器先從根開始訪問所有活躍對象,標記為活躍對象。然后再遍歷一次整個內存區域,把所有沒有標記活躍的對象進行回收處理。該算法遍歷整個空間的成本較大暫停時間隨空間大小線性增大,而且整理后堆里的碎片很多。可參考如下的示例圖:

- 標記整理(mark-sweep-compact):綜合了上述兩者的做法和優點,先標記活躍對象,然后將其合并成較大的內存塊。可參考如下的示例圖:

GC收集器類型
- 古老的串行收集器(Serial Collector)
-XX:+UseSerialGC:策略為年輕代串行復制,年老代串行標記整理。可參考如下的示例圖:
- 吞吐量優先的并行收集器(Throughput Collector)
-XX:+UseParallelGC:這是JDK5 -server的默認值。策略為:
年輕代:暫停應用程序,多個垃圾收集線程并行的復制收集,線程數默認為CPU個數,CPU很多時,可用-XX:ParallelGCThreads= 設定線程數。
年老代:暫停應用程序,與串行收集器一樣,單垃圾收集線程標記整理。
如上可知該收集器需要2+的CPU時才會優于串行收集器,適用于后臺處理,科學計算。
可以使用-XX:MaxGCPauseMillis= 和 -XX:GCTimeRatio 來調整GC的時間。可參考如下的示例圖:
- 暫停時間優先的并發收集器(Concurrent Low Pause Collector-CMS)
-XX:+UseConcMarkSweepGC:這是以上兩種策略的升級版,策略為:
年輕代:同樣是暫停應用程序,多個垃圾收集線程并行的復制收集。
年老代:則只有兩次短暫停,其他時間應用程序與收集線程并發的清除。
若要采用標記整理算法,則可以通過設置參數實現;可參考如下的示例圖:
- 增量并發收集器(Incremental Concurrent-Mark-Sweep/i-CMS):雖然CMS收集算法在最為耗時的內存區域遍歷時采用多線程并發操作,但對于服務器CPU資源不夠的情況下,其實對性能是沒有提升的,反而會導致系統吞吐量的下降,為了盡量避免這種情況的出現,就有了增量CMS收集算法,就是在并發標記、清理的時候讓GC線程、用戶線程交叉運行,盡量減少GC線程的全程獨占式執行;可參考如下的示例圖:

對于以上的GC收集器的詳細設置參數,可以參考JVM選項的超完整收集《A Collection of JVM Options》,這里就不一一詳述了。
并行、并發的區別
并行(Parallel)與并發(Concurrent)僅一字之差,但體現的意思卻完全不同,這可能也是很多同學非常困惑的地方,要想深刻體會這其中的差別,可以多揣摩下上面關于GC收集器的示例圖;
- 并行:指多條垃圾收集線程并行,此時用戶線程是沒有運行的;
- 并發:指用戶線程與垃圾收集線程并發執行,程序在繼續運行,而垃圾收集程序運行于另一個個CPU上。
并發收集一開始會很短暫的停止一次所有線程來開始初始標記根對象,然后標記線程與應用線程一起并發運行,最后又很短的暫停一次,多線程并行的重新標記之前可能因為并發而漏掉的對象,然后就開始與應用程序并發的清除過程。可見,最長的兩個遍歷過程都是與應用程序并發執行的,比以前的串行算法改進太多太多了!!!
串行標記清除是等年老代滿了再開始收集的,而并發收集因為要與應用程序一起運行,如果滿了才收集,應用程序就無內存可用,所以系統默認68%滿的時候就開始收集。內存已設得較大,吃內存又沒有這么快的時候,可以用-XX:CMSInitiatingOccupancyFraction=恰當增大該比率。
年輕代的痛
由于對年輕代的復制收集,依然必須停止所有應用程序線程,原理如此,只能靠多CPU,多收集線程并發來提高收集速度,但除非你的Server獨占整臺服務器,否則如果服務器上本身還有很多其他線程時,切換起來速度就..... 所以,搞到最后,暫停時間的瓶頸就落在了年輕代的復制算法上。
因此Young的大小設置挺重要的,大點就不用頻繁GC,而且增大GC的間隔后,可以讓多點對象自己死掉而不用復制了。但Young增大時,GC造成的停頓時間攀升得非常恐怖,據某人的測試結果顯示:默認8M的Young,只需要幾毫秒的時間,64M就升到90毫秒,而升到256M時,就要到300毫秒了,峰值還會攀到恐怖的800ms。誰叫復制算法,要等Young滿了才開始收集,開始收集就要停止所有線程呢。
參考資料
主要參考:
官方指南:Tuning Garbage Collection with the 5.0 Java Virtual Machine