深入理解JVM02 - 垃圾收集器與記憶體分配策略
垃圾收集(Garbage Collection,GC)需要完成的3件事情:
- 哪些記憶體需要回收?
- 什麼時候回收?
- 如何回收?
JVM01介紹了Java記憶體執行時區域的各個部分,其中程式計數器、虛擬機器棧、本地方法棧3個區域隨著執行緒而生,隨執行緒而滅;而Java堆和方法區則不一樣,只有在程式處於執行時才能知道會建立哪些物件,這部分記憶體的建立和回收都是動態的,垃圾收集器所關注的是這部分記憶體。
to be or not to be
確定物件”存活“還是”死去“。
引用計數演算法
給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;
任何時刻計數器為0的物件就是不可能再被使用的,這時候變可通知GC收集器回收這些物件。

引用計數演算法.png
public class ReferenceCountingGC { public Object instance = null; public static void testGC(){ ReferenceCountingGC objA = new ReferenceCountingGC (); ReferenceCountingGC objB = new ReferenceCountingGC (); // 物件之間相互迴圈引用,物件objA和objB之間的引用計數永遠不可能為 0 objB.instance = objA; objA.instance = objB; objA = null; objB = null; System.gc(); } }
上述程式碼最後面兩句將objA和objB賦值為null,也就是說objA和objB指向的物件已經不可能再被訪問,但是由於它們互相引用對方,導致它們的引用計數器都不為 0,那麼垃圾收集器就永遠不會回收它們。
但是Java虛擬機器裡面沒有選用引用計數演算法來管理記憶體,其中最主要的原因是它很難解決物件之間相互迴圈引用的問題。
優點:簡單,高效,現在的objective-c用的就是這種演算法。
缺點:很難處理迴圈引用,相互引用的兩個物件則無法釋放。(需要開發者自己處理)
可達性分析演算法
Java虛擬機器中,是通過可達性分析(Reachability Analysis)來判定物件是否存活的。
這個演算法的基本思路是通過一系列稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈項鍊時,則證明此物件是不可用的。

可達性分析演算法.png
GC Roots的物件:
- 虛擬機器棧(棧幀中的區域性變量表)中的引用物件
- 方法區中類靜態屬性(靜態物件)引用的物件
- 方法區中常量(final 修飾的成員物件)引用的物件。
- 本地方法棧中JNI(Native)引用的物件。
引用
判定物件是否存活(需要回收)都與“引用”有關。Java對引用的概念擴充,將引用分為強引用(Strong Reference),軟引用(Soft Reference),弱引用(Weak Reference),虛引用(Phantom Refernce)。
- 強引用 : 類似Object obj=new Object()這類的引用,只要強引用還在,垃圾收集器永遠不會回收掉被引用的物件。
- 軟引用 : 軟引用是用來描述一些有用但非必須的物件,在系統將要發生記憶體溢位(OOM)異常之前,將會把這些物件列進回收範圍之中進行二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位(OOM)異常。JDK1.2之後提供了SoftReference類來實現軟引用。
- 弱引用 : 弱引用也是用來描述非必需物件的,但是它的引用比軟引用更弱一些,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。JDK1.2之後提供了SoftReference類來實現軟引用。
- 虛引用 : 虛引用也成為幽靈引用或者幻影引用,它是最弱的一種引用關係。無法通過虛引用來取得一個物件的例項。為一個物件設定虛引用的唯一目的就是能夠在這個物件被回收的時候收到一個系統通知。
垃圾收集演算法
標記清除演算法
標記-清除演算法分為標記和清除兩個階段。該演算法首先從根集合進行掃描,對存活的物件物件標記,標記完畢後,再掃描整個空間中未被標記的物件並進行回收,如下圖所示:

標記清除演算法.png
標記-清除演算法的主要不足有兩個:
- 效率問題 :標記和清除兩個過程的效率都不高;
- 空間問題 :標記-清除演算法不需要進行物件的移動,並且僅對不存活的物件進行處理,因此標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

15479928257361.jpg
複製演算法
複製演算法將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。
這種演算法適用於物件存活率低的場景,比如新生代。這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。該演算法示意圖如下所示:

15480247746757.jpg
事實上,現在商用的虛擬機器都採用這種演算法來回收新生代。因為研究發現,新生代中的物件每次回收都基本上只有10%左右的物件存活,所以需要複製的物件很少,效率還不錯。實踐中會將新生代記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活著的物件一次地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是 8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90% ( 80%+10% ),只有10% 的記憶體會被“浪費”。
標記整理演算法
複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。
標記整理演算法的標記過程類似標記清除演算法,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體,類似於磁碟整理的過程,該垃圾回收演算法適用於物件存活率高的場景(老年代),其作用原理如下圖所示。

標記整理演算法.png
標記整理演算法與標記清除演算法最顯著的區別是:標記清除演算法不進行物件的移動,並且僅對不存活的物件進行處理;而標記整理演算法會將所有的存活物件移動到一端,並對不存活物件進行處理,因此其不會產生記憶體碎片。標記整理演算法的作用示意圖如下:

15480252982828.jpg
分代收集演算法
對於一個大型的系統,當建立的物件和方法變數比較多時,堆記憶體中的物件也會比較多,如果逐一分析物件是否該回收,那麼勢必造成效率低下。
分代收集演算法是基於這樣一個事實:不同的物件的生命週期(存活情況)是不一樣的,而不同生命週期的物件位於堆中不同的區域,因此對堆記憶體不同區域採用不同的策略進行回收可以提高 JVM 的執行效率。
當代商用虛擬機器使用的都是分代收集演算法:新生代物件存活率低,就採用複製演算法;老年代存活率高,就用標記清除演算法或者標記整理演算法。Java堆記憶體一般可以分為新生代、老年代和永久代三個模組,如下圖所示:

15480256054339.jpg
GC演算法小結

GC演算法小結.png
由於物件進行了分代處理,因此垃圾回收區域、時間也不一樣。垃圾回收有兩種型別,Minor GC 和 Full GC。
- Minor GC:對新生代進行回收,不會影響到年老代。因為新生代的 Java 物件大多死亡頻繁,所以 Minor GC 非常頻繁,一般在這裡使用速度快、效率高的演算法,使垃圾回收能儘快完成。
- Full GC:也叫 Major GC,對整個堆進行回收,包括新生代和老年代。由於Full GC需要對整個堆進行回收,所以比Minor GC要慢,因此應該儘可能減少Full GC的次數,導致Full GC的原因包括:老年代被寫滿、永久代(Perm)被寫滿和System.gc()被顯式呼叫等
垃圾收集器
如果說垃圾收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。下圖展示了7種作用於不同分代的收集器,其中用於回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,還有用於回收整個Java堆的G1收集器。不同收集器之間的連線表示它們可以搭配使用。

15481131132194.jpg
- Serial收集器(複製演算法): 新生代單執行緒收集器,標記和清理都是單執行緒,優點是簡單高效;
- ParNew收集器 (複製演算法): 新生代收並行集器,實際上是Serial收集器的多執行緒版本,在多核CPU環境下有著比Serial更好的表現;
- Parallel Scavenge收集器 (複製演算法): 新生代並行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 使用者執行緒時間/(使用者執行緒時間+GC執行緒時間),高吞吐量可以高效率的利用CPU時間,儘快完成程式的運算任務,適合後臺應用等對互動相應要求不高的場景;
- Serial Old收集器 (標記-整理演算法): 老年代單執行緒收集器,Serial收集器的老年代版本;
- Parallel Old收集器 (標記-整理演算法): 老年代並行收集器,吞吐量優先,Parallel Scavenge收集器的老年代版本;
- CMS(Concurrent Mark Sweep)收集器(標記-清除演算法): 老年代並行收集器,以獲取最短回收停頓時間為目標的收集器,具有高併發、低停頓的特點,追求最短GC回收停頓時間。
- G1(Garbage First)收集器 (標記-整理演算法): Java堆並行收集器,G1收集器是JDK1.7提供的一個新收集器,G1收集器基於“標記-整理”演算法實現,也就是說不會產生記憶體碎片。此外,G1收集器不同於之前的收集器的一個重要特點是:G1回收的範圍是整個Java堆(包括新生代,老年代),而前六種收集器回收的範圍僅限於新生代或老年代。
GC日誌解讀
33.125:[GC [DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs] 100.667:[Full GC [Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
- 最前面的“33.125:”和“100.667:" 代表了GC發生時間(從java虛擬機器啟動以來經過的秒數)。
- 日誌開頭“[GC ”和“[Full GC”說明了這次垃圾收集的停頓型別。有"Full"說明這次GC是發生了Stop-The-World的。一般因為出現了分配擔保失敗之類的問題才會導致STW。如果呼叫System.gc()方法所觸發的收集,那麼這裡將顯示“[Full GC(System)”。
- “ [DefNew”、“[Tenured”、“[Perm”表示GC發生區域,這裡顯示區域名稱與使用的GC收集器密切相關,,例如上面樣例所使用的Serial收集器中的新生代名為“Default New Generation”,所以顯示的是“[DefNew”。如果是ParNew收集器,新生代名稱就會變為“[ParNew”,意為“Parallel New Generation”。如果採用Parallel Scavenge收集器,那它配套的新生代稱為“PSYoungGen”,老年代和永久代同理,名稱也是由收集器決定的。
- 後面方括號內部的“3324K->152K(3712K)”含義是“GC前該記憶體區域已使用容量->GC後該記憶體區域已使用容量(該記憶體區域總容量)”。而在方括號之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC後Java堆已使用容量(Java堆總容量)”。
- 再往後,“0.0025925 secs”表示該記憶體區域GC所佔用的時間,單位是秒。
- 有的收集器會給出更具體的時間資料,如“[Times:user=0.01 sys=0.00,real=0.02 secs]”,這裡面的user、sys和real與Linux的time命令所輸出的時間含義一致,分別代表使用者態消耗的CPU時間、核心態消耗的CPU時間和操作從開始到結束所經過的牆鍾時間(Wall Clock Time)。
- CPU時間與牆鍾時間的區別是,牆鍾時間包括各種非運算的等待耗時,例如等待磁碟I/O、等待執行緒阻塞,而CPU時間不包括這些耗時,但當系統有多CPU或者多核的話,多執行緒操作會疊加這些CPU時間,所以讀者看到user或sys時間超過real時間是完全正常的。

15481139383800.jpg
新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java物件大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略裡就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。
3.5.9垃圾收集器引數

15481142198157.jpg

15481142322830.jpg
記憶體分配與回收策略
新生代Minor GC
回顧下垃圾回收演算法,通常新生代按照8:1:1(eden space + survivor from space + survivor to space)進行記憶體劃分,新生產的物件會被放到eden space,當eden記憶體不足時,就會將存活物件移動到survivor區域,如果survivor空間也不夠時,就需要從老年代中進行分配擔保,將存活的物件移動老年代,這就是一次Minor GC的過程。

15481148676972.jpg
/** * VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails * -XX:SurvivorRatio=8 -XX:+UseSerialGC */ public class MinorGCTest { private static final int _1MB = 1024 * 1024; public static void testAllocation() { byte[] allocation1, allocation2, allocation3, allocation4; allocation1 = new byte[2 * _1MB]; allocation2 = new byte[2 * _1MB]; allocation3 = new byte[2 * _1MB]; allocation4 = new byte[4 * _1MB]; } public static void main(String[] agrs) { testAllocation(); } }
程式碼清單的testAllocation()方法中,嘗試分配3個2MB大小和1個4MB大小的物件,在執行時通過-Xms20M、-Xmx20M、-Xmn10M這3個引數限制了Java堆大小為20MB,不可擴充套件,其中10MB分配給新生代,剩下的10MB分配給老年代。-XX:SurvivorRatio=8決定了新生代中Eden區與一個Survivor區的空間比例是8:1,從輸出的結果也可以清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的資訊,新生代總可用空間為9216KB(Eden區+1個Survivor區的總容量)。
before MinorGC:

15481150728891.jpg
after MinorGC:

15481150979566.jpg
GC日誌:

15482534095722.jpg
解讀: [GC (Allocation Failure) [DefNew: 6815K->290K(9216K), 0.0054224 secs] 6815K->6434K(19456K), 0.0054619 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
- GC 表明進行了一次垃圾回收,前面沒有Full修飾,表明這是一次Minor GC ,注意它不表示只GC新生代
- Allocation Failure: 表明本次引起GC的原因是因為在年輕代中沒有足夠的空間能夠儲存新的資料了。
- DefNew: 使用的是Serial收集器,它的新生代名為“Default New Generation”。
- 6815K->290K(9216K): GC前該記憶體區域(這裡是年輕代)使用容量,GC後該記憶體區域使用容量,該記憶體區域總容量。
- 0.0054224 secs: 該記憶體區域GC耗時,單位是秒
- 6815K->6434K(19456K): 三個引數分別為:堆區垃圾回收前的大小,堆區垃圾回收後的大小,堆區總大小。
- 0.0054619 secs: 該記憶體區域GC耗時,單位是秒
- Times: user=0.00 sys=0.00, real=0.01 secs: 分別表示使用者態耗時,核心態耗時和總耗時
結論:
- eden space 8192K, 51% used ==> 新生代的Eden區總共大小為8MB,使用掉的4MB是用來存放allocation4物件
- tenured generation total 10240K, used 6144K ==> 老年代大小為10MB,使用掉的6MB是用來存放allocation1、allocation2和allocation3這3個物件
大物件直接進入老年代
建立了一個數組物件allocation,大小為4MB,已經超出PretenureSizeThreshold設定的範圍,該物件將直接被分配到老年代中。
/** * VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails * -XX:SurvivorRatio=8 -XX:+UseSerialGC * -XX:PretenureSizeThreshold=3145728 */ public class TestClass2 { private static final int _1MB = 1024 * 1024; public static void testPretenureSizeThreshold() { byte[] allocation; allocation = new byte[4 * _1MB]; } /** * @param args */ public static void main(String[] args) { // TODO Auto-generated method stub testPretenureSizeThreshold(); } }
VM引數說明:
-XX:PretenureSizeThreshold=3145728 表示 所佔用記憶體大於該值的物件直接分配到老年代,3145728為3MB

15482543601661.jpg
解讀: 上述log中未發生GC垃圾回收,同時tenured generation total 10240K, used 4096K,說明老年代大小為10MB,用掉的4MB用來存放allocation物件,即大物件直接進入老年代。
長期存活的物件進入老年代
建立了3個數組物件,當執行到"allocation3 = new byte[4 * _1MB]; "時,Eden已經被佔用了256KB + 4MB,而建立allocation3需要4MB,已經超過Eden的大小8MB,需要先發生一次MinorGC,才能保證有空間存放allocation3。
/** * VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails * -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1 */ public class TestClass3 { private static final int _1MB = 1024 * 1024; public static void testTenuringThreshold() { byte[] allocation1, allocation2, allocation3; allocation1 = new byte[_1MB / 4]; allocation2 = new byte[4 * _1MB]; allocation3 = new byte[4 * _1MB]; allocation3 = null; allocation3 = new byte[4 * _1MB]; } public static void main(String[] agrs) { testTenuringThreshold(); } }
VM引數說明:
-XX:MaxTenuringThreshold=1 表示 物件晉升為老年代的年齡閥值為1

15482546980342.jpg
說明:
該段程式碼建立了3個數組物件,當執行到"allocation3 = new byte[4 * _1MB]; "時,Eden已經被佔用了256KB + 4MB,而建立allocation3需要4MB,已經超過Eden的大小8MB,需要先發生一次MinorGC,才能保證有空間存放allocation3
解讀:
- 由GC日誌開頭的兩句"[GC [DefNew"可知,該段程式碼一共發生了2次GC,第一次是"allocation3 = new byte[4 * _1MB]; ",第二次是執行allocation3 = null時
- allocation1在經過第一次GC時,物件年齡變成了1,由於設定的MaxTenuringThreshold=1,當發生第二次GC時,allocation1的年齡已經超出了設定的閥值,allocation1進入到老年代,因此,新生代的from space使用空間為0,對應GC語句為from space 1024K, 0% used
若將MaxTenuringThreshold改成15(注: 設定下-XX:TargetSurvivorRatio=90),GC log為:

15482572739275.jpg
即 新生代的from space使用空間不為0,對應GC語句為from space 1024K, 52% used
參考
深入理解Java虛擬機器:JVM高階特性與最佳實踐(第2版)