[深入理解Java虛擬機器]第三章 記憶體分配與回收策略

Coding-lover發表於2015-10-05

Java技術體系中所提倡的自動記憶體管理最終可以歸結為自動化地解決了兩個問題:給物件分配記憶體以及回收分配給物件的記憶體。關於回收記憶體這一點,我們已經使用了大量篇幅去介紹虛擬機器中的垃圾收集器體系以及運作原理,現在我們再一起來探討一下給物件分配記憶體的那點事兒。

物件的記憶體分配,往大方向講,就是在堆上分配(但也可能經過JIT編譯後被拆散為標 量型別並間接地棧上分配) ,物件主要分配在新生代的Eden區上 ,如果啟動了本地執行緒分配緩衝 ,將按執行緒優先在TLAB上分配。少數情況下也可能會直接分配在老年代中,分配的規則並不是百分之百固定的,其細節取決於當前使用的是哪一種垃圾收集器組合,還有虛擬機器中與記憶體相關的引數的設定。

接下來我們將會講解幾條最普遍的記憶體分配規則,並通過程式碼去驗證這些規則。本節下面的程式碼在測試時使用Client模式虛擬機器執行,沒有手工指定收集器組合,換句話說,驗證的是在使用Serial/Serial Old收集器下(ParNew/Serial Old收集器組合的規則也基本一致)的 記憶體分配和回收的策略。讀者不妨根據自己專案中使用的收集器寫一些程式去驗證一下使用其他幾種收集器的記憶體分配策略。

物件優先在Eden分配

大多數情況下,物件在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機器將發起一次Minor GC。

虛擬機器提供了-XX : +PrintGCDetails這個收集器日誌引數,告訴虛擬機器在發生垃圾收集行為時列印記憶體回收日誌,並且在程式退出的時候輸出當前的記憶體各區域分配情況。在實際應用中 ,記憶體回收日誌一般是列印到檔案後通過日誌工具進行分析,不過本實驗的日誌並不多 ,直接閱讀就能看得很清楚。

程式碼清單3-5的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區的總容量)。

執行testAllocation()中分配allocation4物件的語句時會發生一次Minor GC , 這次GC的結果是新生代6651KB變為148KB , 而總記憶體佔用量則幾乎沒有減少(因為allocationl、 allocation、allocations三個物件都是存活的,虛擬機器幾乎沒有找到可回收的物件)。這次GC發生的原因是給allocation4分配記憶體的時候,發現Eden已經被佔用了6MB ,剩殺空間已不足以分配allocation所需的4MB記憶體,因此發生Minor GC。GC期間虛擬機器又發現已有的3個 2MB大小的物件全部無法放入Survivor空間 (Survivor空間只有1MB大小 ),所以只好通包分配擔保機制提前轉移到老年代去。

這次GC結束後,4MB的allocation物件順利分配在Eden中 ,因此程式執行完的結果是Eden佔用4MB(被allocation4佔用),Survivor空閒,老年代被佔用6MB ( 被allocation1、allocation2、allocation3佔用)。通過GC日誌可以證實這一點。

注意:作者多次提到的Minor GC和Full GC有什麼不一樣嗎?

  • 新生代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 新生代Minor GC

private static final int _1MB = 1024 * 1024;

/**
 * VM引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
  */
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];  // 出現一次Minor GC
 }

執行結果:


[GC[DefMew:6651K->148K(9216K),0.0070106 secs]6651K->6292K(19456K), 0.0070426 secs] [Times :user=0.00 sys=0.00,real=0.00 secs]
Heap
def new generation total 9216K,used 4326K[0x029d0000 ,0x033d0000 ,0x033d0000 ) eden space 8192K ,5Uused[0x029d0000 ,0x02de4828 ,0x031d0000 )
from space 1024K ,14Sused[0x032d0000 ,0x032f5370 ,0x033d0000 )
to space 1024K ,0%used[0x03ldO000 ,0x031d0000 ,0x032d0000 )
tenured generation total 1024OK,used 6144K[0x033d0000 ,0x03dd0000 ,0x03dd0000 ) 
the space 1024OK,60lused[0x033d0000,0x039d0030,0x039d0200,0x03dd0000) 
compacting perm gen total 12288K,used 2114K[0x03dd0000 ,0x049d0000 ,0x07dd0000 ) 
the space 12288K ,17lused[0x03dd0000 ,0x03fe0998 ,0x03fe0a00 ,0x049d0000 )
Mo shared spaces configured.

大物件直接進入老年代

所謂的大物件是指,需要大量連續記憶體空間的Java物件 ,最典型的大物件就是那種很長的字串以及陣列(筆者列出的例子中的byte[]陣列就是典型的大物件)。大物件對虛擬機器的記憶體分配來說就是一個壞訊息(替Java虛擬機器抱怨一句,比遇到一個大物件更加壞的訊息就是遇到一群“朝生夕滅”的“短命大物件” ,寫程式的時候應當避免),經常出現大物件容易導致記憶體還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。

虛擬機器提供了一個-XX : PretenureSizeThreshold引數,令大於這個設定值的物件直接在老年代分配。這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的記憶體複製(複習 一 下 :新生代採用複製演算法收集記憶體)。

執行程式碼清單3-6中的testPretenureSizeThreshold()方法後,我們看到Eden空間幾乎沒有被使用,而老年代的10MB空間被使用了40% , 也就是4MB的allocation物件直接就分配在老年代中,這是因為PretenureSizeThreshold被設定為3MB (就是3145728,這個引數不能像-Xmx 之類的引數一樣直接寫3MB ) ,因此超過3MB的物件都會直接在老年代進行分配。注意 PretenureSizeThreshold引數只對Serial和ParNew兩款收集器有效,Parallel Scavenge收集器不認識這個引數,Parallel Scavenge收集器一般並不需要設定。如果遇到必須使用此引數的場 合 ,可以考慮ParNew加CMS的收集器組合。

程式碼清單3 - 6 大物件直接進入老年代

private static final int _1MB = 1024 * 1024;

/**
 * VM引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
 * -XX:PretenureSizeThreshold=3145728
 */
public static void testPretenureSizeThreshold() {
    byte[] allocation;
    allocation = new byte[4 * _1MB];  //直接分配在老年代中
}

執行結果:

長期存活的物件將進入老年代

既然虛擬機器採用了分代收集的思想來管理記憶體,那麼記憶體回收時就必須能識別哪些物件應放在新生代,哪些物件應放在老年代中。為了做到這點,虛擬機器給每個物件定義了一個物件年齡(Age)計數器。如果物件在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並且物件年齡設為1。物件在Survivor區中每“熬過”一次 Minor GC ,年齡就增加1歲 ,當它的年齡增加到一定程度(預設為15歲 ),就將會被晉升到老年代。物件晉升老年代的年齡閾值,可以通過引數-XX : MaxTenuringThreshold設定。

讀者可以試試分別以-XX : MaxTenuringThreshold=1和-XX : MaxTenuringThreshold=15兩 種設定來執行程式碼清單3-7中的testTenuringThreshold() 方法,此方法中的allocation1物件需要256KB記憶體,Survivor空間可以容納。當MaxTenuringThreshold=1時 ,allocation1物件在第二次GC發生時進入老年代,新生代已使用的記憶體GC後非常乾淨地變成0KB。而 MaxTenuringThreshold=15時 ,第二次GC發生後,allocation1物件則還留在新生代Survivor空間 ,這時新生代仍然有404KB被佔用。

程式碼清單3 - 7 長期存活的物件進入老年代

private static final int _1MB = 1024 * 1024;

/**
 * VM引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
    byte[] allocation1, allocation2, allocation3;
    allocation1 = new byte[_1MB / 4];  // 什麼時候進入老年代決定於XX:MaxTenuringThreshold設定
    allocation2 = new byte[4 * _1MB];
    allocation3 = new byte[4 * _1MB];
    allocation3 = null;
    allocation3 = new byte[4 * _1MB];
}

以MaxTenuringThreshold=1引數來執行的結果:

以MaxTenuringThreshold=15引數來執行的結果 :


動態物件年齡判定

為了能更好地適應不同程式的記憶體狀況,虛擬機器並不是永遠地要求物件的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無須等到MaxTenuringThreshold 中要求的年齡。

執行程式碼清單3-8中的testTenuringThreshold2()方法,並設定-XX : MaxTenuringThreshold=15 , 會發現執行結果中Survivor的空間佔用仍然為0% ,而老年代比預 期增加了6% , 也就是說,allocation1、allocation2物件都直接進入了老年代,而沒有等到15歲的臨界年齡。因為這兩個物件加起來已經到達了512KB,並且它們是同年的,滿足同年對 象達到Survivor空間的一半規則。我們只要註釋掉其中一個物件new操作,就會發現另外一個就不會晉升到老年代中去了。

程式碼清單3 - 8 動態物件年齡判定

private static final int _1MB = 1024 * 1024;

/**
 * VM引數:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
    byte[] allocation1, allocation2, allocation3, allocation4;
    allocation1 = new byte[_1MB / 4];   // allocation1+allocation2大於survivo空間一半
    allocation2 = new byte[_1MB / 4];  
    allocation3 = new byte[4 * _1MB];
    allocation4 = new byte[4 * _1MB];
    allocation4 = null;
    allocation4 = new byte[4 * _1MB];
}

執行結果:

空間分配擔保

在發生Minor GC之前,虛擬機器會先檢查老年代最大可用的連續空間是否大於新生代所有物件總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。如果不成立,則虛擬機器會檢視HandlePromotionFailure設值值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,將嘗試著進行—次Minor GC,儘管這次Minor GC是有風險的;如果小於,或者HandlePromotionFailure設定不允許冒險,那這時也要改為進行一次Full GC。

下面解釋一下“ 冒險” 是冒了什麼風險,前面提到過,新生代使用複製收集演算法,但為了記憶體利用率,只使用其中一個Survivor空間來作為輪換備份,因此當出現大量物件在Minor GC後仍然存活的情況(最極端的情況就是記憶體回收後新生代中所有物件都存活),就需要老年代進行分配擔保,把Survivor無法容納的物件直接進入老年代。與生活中的貸款擔保類似 ,老年代要進行這樣的擔保,前提是老年代本身還有容納這些物件的剩餘空間,一共有多少物件會活下來在實際完成記憶體回收之前是無法明確知道的,所以只好取之前每一次回收晉 升到老年代物件容量的平均大小值作為經驗值,與老年代的剩餘空間進行比較,決定是否進 行Full GC來讓老年代騰出更多空間。

取平均值進行比較其實仍然是一種動態概率的手段,也就是說,如果某次Minor GC存活後的物件突增,遠遠高於平均值的話,依然會導致擔保失敗(Handle Promotion Failure ) 。 如果出現了HandlePromotionFailure失敗,那就只好在失敗後M新發起一次Full GC。雖然追保 失敗時繞的圈子是最大的,但大部分情況下部還是會將HandlePromotionFailure開關開啟,避免Full GC過於頻繁,參見程式碼清單3-9 ,請讀者在JDK 6 Update 24之前的版本中執行測試。

程式碼清單3 - 9 空間分配擔保

private static final int _1MB = 1024 * 1024;

/**
 * VM引數:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure
 */
@SuppressWarnings("unused")
public static void testHandlePromotion() {
    byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;
    allocation1 = new byte[2 * _1MB];
    allocation2 = new byte[2 * _1MB];
    allocation3 = new byte[2 * _1MB];
    allocation1 = null;
    allocation4 = new byte[2 * _1MB];
    allocation5 = new byte[2 * _1MB];
    allocation6 = new byte[2 * _1MB];
    allocation4 = null;
    allocation5 = null;
    allocation6 = null;
    allocation7 = new byte[2 * _1MB];
}

以HandlePromotionFailure=false引數來執行的結果:

以HandlePromotionFailure=true引數來執行的結果:

在JDK 6 Update 24之後,這個測試結果會有差異,HandlePromotionFailure引數不會再影響到虛擬機器的空間分配擔保策略,觀察OpenJDK中的原始碼變化, 雖然源 碼中還定義了HandlePromotionFailure引數,但是在程式碼中已經不會再使用它。 JDK 6 Update 24之後的規則變為只要老年代的連續空間大於新生代物件總大小或者歷次晉升的平均大小就會進行Minor GC , 否則將進行Full GC。

相關文章