Java虛擬機器05——物件分配與回收策略

llldddbbb發表於2019-04-09

物件的記憶體分配基本規律有以下幾條:

  • 大多數情況下就是在堆上分配(但也可能經過JIT編譯後被拆散為標量型別並間接地棧上分配)。
  • 物件主要分配在新生代的Eden區上。
  • 如果啟動了本地執行緒分配緩衝,將按執行緒優先在TLAB上分配。
  • 少數情況下也可能會直接分配在老年代中。

物件的分配規則不是百分百固定的,其細節取決於當前使用的是哪一種垃圾收集組合,還有虛擬機器中與記憶體相關的引數設定

物件優先在Eden分配

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

Minor GC指發生在新生代的垃圾收集動作

使用以下程式碼進行測試:

public class ObjMemoryTest {
    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[] args) throws IOException {
        ObjMemoryTest.testAllocation();
    }
}
複製程式碼

其中,需要設定引數

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8

上述引數解釋如下:

  • -verbose:gc -XX:+PrintGCDetails:列印GC詳細日誌資訊。
  • -XX:+UseSerialGC:使用Serial收集器。
  • -Xms20m -Xmx20m:限制Java堆大小為20MB。
  • -Xmn10m:新生代大小為10MB。
  • -XX:SurvivorRatio=8:設定新生代中Eden區與一個Survivor區的空間比例是8:1

設定完後,Java堆共20M,新生代10M,老年代10M。其中新生代裡的Eden 8M,兩個Survivor各1M。程式碼執行日誌如下:

image.png

解釋:執行後新生代進行了GC回收,從8188K->714K。這次回收是給allocation4分配記憶體的時候,發現Edon區已經佔用了6M,剩餘空間已經不足分配allocation4的4M。所以執行了Minor GC,GC期間發現1M大小的Survivor無法放入allocaiton1~3,所以只好通過分配擔保機制提前轉移到老年代去。

GC結束後,從GC日誌上可以看到:4MB的allocation4被分配到Eden區,allocation1~3被分配到老年代中

大物件直接進入老年代

所謂的大物件是指需要大量連續記憶體空間的Java物件。虛擬機器提供了一個-XX:PretenureSizeThreshold引數,大於這個設定值的物件將直接在老年代分配,從而避免在Eden區及兩個Survivor區之間發生大量的記憶體複製(新生代主要採用複製演算法收集記憶體)。有以下的測試程式碼: 其中,需要設定引數

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728

    public static void testPretenureSizeThreshold() {
        byte[] allocation;
        allocation = new byte[4*_1MB];
    }
複製程式碼

執行結果:

image.png

從結果上看老年代被使用了4M,而新生代幾乎沒有使用,這是因為PretenureSizeThreshold被設定成3MB(也就是3145728),因此超過3MB的物件會直接在老年代進行分配

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

虛擬機器給每個物件定義了一個物件年齡(Age)計數器,如果物件在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,物件年齡為1,物件在Survivor區中熬過一次Minor GC,年齡增加1。當它的年齡增加到一定程度(預設15),就會被晉升到老年代中。晉升的閾值可以通過引數-XX:MaxTenuringThreshold設定。例項程式碼如下: 其中,需要設定引數

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8

    public static void testMaxTenuredThreshold() {
        byte[] allocation1;
        byte[] allocation2;
        byte[] allocation3;
        byte[] allocation4;

        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation4 = new byte[4 * _1MB];
    }
複製程式碼

alocation1為256kb記憶體,Survivor空間可以容納,而allocation2、allocation3和allocation4需要4MB的空間,並不能被Survivor區容納。

當設定MaxTenuringThreshold = 1時,記憶體資訊如下

image.png

由於Eden區域的總大小是8MB,因此在分配allocation3時會因為Eden區空閒大小不夠而發生一次Minor GC操作,這時allocation1會被移入到Survivor區中,allocation2因Survivor區並不能容納會被提前提升到老年代。接下來在分配allocation3後分配allocation4還會觸發第二次Minor GC操作,這次操作由於allocation1達到了晉升年齡,會被晉升到老年代,而allocation3會被回收,所以第二次Minor GC後新生代的已使用大小會變為0K,最後allocation4會被分配到Eden區,因此得到的最終記憶體空間的分配是Eden區使用51%(4MB+,用於存放allocation4),Survivor區域已使用全為0,老年代已使用5059K(4MB+,用於存放allocation1和allocation2)。

而設定-XX:MaxTenuringThreshold=15後,將會得到以下的結果:

image.png

注:如果在某些版本的JDK中不生效,可以設定-XX:TargetSurvivorRatio=95引數調大Survivor區域的使用率

可以看到Survivor區不為空,這是由於allocation1還沒有被判定為長期存活的物件,還存在與Survivor區導致的。

動態年齡判定

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

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

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:TargetSurvivorRatio=95

public static void testMaxTenuredThreshold2() {
        byte[] allocation1;
        byte[] allocation2;
        byte[] allocation3;
        byte[] allocation4;

        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB];
        allocation4 = null;
        allocation4 = new byte[4 * _1MB];
    }
複製程式碼

image.png

空間分配擔保

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

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

取平均值進行比較其實仍然是一種動態概率的手段,也就是說,如果某次Minor GC存活後的物件突增,遠遠高於平均值的話,依然會導致擔保失敗(HandlePromotion Failure)。如果出現了HandlePromotionFailure失敗,那就只好在失敗後重新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure開關開啟,避免Full GC過於頻繁。

但是JDK 6 Update 24之後程式碼中已經不再使用HandlePromotionFailure,JDK 6 Update 24之後的規則變為只要老年代的連續空間大於新生代物件總大小或者歷次晉升的平均大小就會進行Minor GC,否則將進行Full GC。

相關文章