1. 程式人生 > >Java虛擬機器詳解(六)------記憶體分配

Java虛擬機器詳解(六)------記憶體分配

  我們說Java是自動進行記憶體管理的,所謂自動化就是,不需要程式設計師操心,Java會自動進行記憶體分配和記憶體回收這兩方面。

  前面我們介紹過如何通過垃圾回收器來回收記憶體,那麼本篇部落格我們來聊聊如何進行分配記憶體。

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

  本篇部落格會介紹幾條最普遍的記憶體分配規則。通過增加 -XX:+UseParallelGC 引數,表示使用的垃圾收集器是 Parallel Scavenge + Serial Old ,通過這兩個垃圾收集器組合進行校驗。

1、Minor GC 、Major GC 和 Full GC

  下面會出現這幾個概念,所以這裡首先介紹一下。

  ①、Minor GC

  也叫Young GC,指的是新生代 GC,發生在新生代(Eden區和Survivor區)的垃圾回收。因為Java物件大多是朝生夕死的,所以 Minor GC 通常很頻繁,一般回收速度也很快。

  ②、Major GC

  也叫Old GC,指的是老年代的 GC,發生在老年代的垃圾回收,該區域的物件存活時間比較長,通常來講,發生 Major GC時,會伴隨著一次 Minor GC,而 Major GC 的速度一般會比 Minor GC 慢10倍。

  ②、Full GC

  指的是全區域(整個堆)的垃圾回收,通常來說和 Major GC 是等價的。  

1、物件優先在 Eden 上分配

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

package com.ys.algorithmproject.leetcode.demo.JVM;

/**
 * Create by YSOcean
 * 物件優先在Eden區上分配
 */
public class EdenTest {
    private static final int _1MB = 1024*1024;

    /**
     * 虛擬機器引數設定:-XX:+UseParallelGC -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
     * @param args
     */
    public static void main(String[] args) {
        byte[] a = new byte[2*_1MB];
        byte[] b = new byte[2*_1MB];
        byte[] c = new byte[2*_1MB];
        byte[] d = new byte[3*_1MB];
    }
}

  執行時的虛擬機器引數設定為:

-XX:+UseParallelGC -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8

  ①、 -XX:+UseParallelGC 引數,表示使用的垃圾收集器是 Parallel Scavenge + Serial Old ;

  ②、-XX:+PrintGCDetails 引數,表示列印詳細的GC日誌,便於我們檢視GC情況

  ③、-Xms20M -Xmx20M 這兩個引數分別表示設定最大堆,最小堆記憶體都是20M

  ④、-Xmn 引數表示設定新生代大小為 10M

  ⑤、-XX:SurvivorRatio=8 新生代中的 Eden 區和 Survivor 區的比值為8:1,注意 Survivor是有兩個的。

  執行列印的GC日誌為:

  我們首先分析設定的JVM引數,表示堆中記憶體為20M,新生代和老年代分別各佔一半為10M,並且新生代的Eden區為8M,剩下兩個 Survivor 各為 1M。

  在看程式碼,首先分配了三個大小都為2M的物件 a,b,c。這時候新生代物件的 Eden區已經被佔用了6M,這時候來了一個物件d,大小為3M,發現新生代Eden區已經不足以分配物件d了,於是發起一次Minor GC。GC期間虛擬機器又發現現在已有3個 2MB物件無法全部放入Survivor空間(Survivor空間只有1MB),所以只好通過分配擔保機制提前轉移到老年代中,然後將這個物件d分配到新生代Eden區中。

  我們檢視日誌,在eden區中,總共8192K的空間,被使用了38%,約等於3113K,大概就是物件d(3MB)的大小。其次在老年代中,總共10240K(10MB),被使用了6865K,大概也就是a,b,c這三個物件的大小(6MB)。

2、大物件直接進行老年代

  通常大物件是指需要大量連續記憶體空間的Java物件,比較典型的就是那種很長的字串以及陣列。

  系統中出現大量大物件是很影響效能的,這樣會導致還有不少空間時就提前觸發垃圾回收來放置這些物件。

package com.ys.algorithmproject.leetcode.demo.JVM;

/**
 * Create by YSOcean
 * 大物件直接在老年代上分配
 */
public class OldTest {
    private static final int _1MB = 1024*1024;

    /**
     * 虛擬機器引數設定:-XX:+UseParallelGC -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
     * @param args
     */
    public static void main(String[] args) {
        byte[] a = new byte[8*_1MB];

    }
}

  執行時虛擬機器引數還和上面一樣,執行的GC日誌如下:

  

  可以看到老年代 ParOldGen直接被使用了 8192K,而新生代只被佔用了1820K。

  PS:可以通過設定-XX:PretenureSizeThreshold 引數,大於這個引數設定值的物件直接在老年代中分配,但是這個引數只對 Serial 和 ParNew 這兩款垃圾收集器有效,Parallel Scavenge 收集器不認識這個引數。

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

   我們知道Java虛擬機器是通過分代收集的思想來管理記憶體,新建立的物件通常放在新生代,除此之外,還有一些物件放在老年代。為了識別哪些物件放在新生代,哪些物件放在老年代,虛擬機器給每個物件定義了一個年齡計數器(Age),如果物件在新生代Eden建立,並經歷一次 Minor GC 後仍然存活,並且能夠被 Survivor 容納的話,虛擬機器會將該物件移動到 Survivor 區域,並將物件的年齡Age+1。

  新生代物件每熬過一次 Minor GC,年齡就增加1,當它的年齡增加到一定閾值時(預設是15歲),就會被晉升到老年代中。

  這個年齡閾值可以通過如下引數來設定(N表示晉升到老年代的閾值):

-XX:MaxTenuringThreshold=N

  驗證程式碼如下:

package com.ys.algorithmproject.leetcode.demo.JVM;

/**
 * Create by YSOcean
 * 新生代物件經過N次Minor GC後,晉升到老年代
 */
public class OldAgeTest {
    private static final int _1MB = 1024*1024;

    /**
     * 虛擬機器引數設定:-XX:MaxTenuringThreshold=1 -XX:+UseParallelGC -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
     * @param args
     */
    public static void main(String[] args) {
        byte[] a = new byte[_1MB];
        System.gc();

    }

}

  注意:這裡我們設定 -XX:MaxTenuringThreshold=1,也就是經歷一次gc,新生代物件就直接進入老年代了,然後手動呼叫了 System.gc() 方法,表示讓虛擬機器進行垃圾回收。列印的日誌如下:

  

  注意看,程式碼中我們只建立了一個 1MB大小的物件,但是老年代佔用了1999K的記憶體,而新生代確只有246K。

  接下來可以將 -XX:MaxTenuringThreshold 引數設定的更大一點,來對比列印的日誌,這裡讀者可以自己進行驗證。

4、新生代Survivor 區相同年齡所有物件之和大於 Survivor 所有物件之和的一半,大於等於該年齡的物件進入老年代

  Java虛擬機器並不會死板的根據上面第3點說的,設定-XX:MaxTenuringThreshold 的閾值,只有物件經歷該閾值次GC後,才會進入到老年代。而是會根據新生代物件的年齡來動態的決定哪些物件可以進入到老年代。

  也就是說,新生代經歷一次 Minor GC 後,Survivor 區域存活物件的所有相同年齡之和大於整個 Survivor 區域的所有物件之和,那麼該區域大於等於這個年齡的物件就會進入老年代,而無需等到 -XX:MaxTenuringThreshold 設定的閾值。

 

5、空間分配擔保原則

  在前面介紹 垃圾回收 時,我們介紹過現在Java虛擬機器採用的是分代回收演算法,新生代採用複製收集演算法,而老年代採用標記整理,或者標記清除演算法。

  

  新生代記憶體分為一塊 Eden區,和兩塊 Survivor 區域,當發生一次 Minor GC時,虛擬機器會將Eden和一塊Survivor區域的所有存活物件複製到另一塊Survivor區域,通常情況下,Java物件朝生夕死,一塊 Survivor 區域是能夠存放GC後剩餘的物件的,但是極端情況下,GC後仍然有大量存活的物件,那麼一塊 Survivor 區域就會存放不下這麼多的物件,那麼這時候就需要老年代進行分配擔保,讓無法放入 Survivor 區域的物件直接進入到老年代,當然前提是老年代還有空間能夠存放這些物件。但是實際情況是在完成GC之前,是不知道還有多少物件能夠存活下來的,所以老年代也無法確認是否能夠存放GC後新生代轉移過來的物件,那麼這該怎麼辦呢?

  前面我們介紹的都是Minor GC,那麼何時會發生 Full GC?

  在發生 Minor GC 時,虛擬機器會檢測之前每次晉升到老年代的平均大小是否大於老年代的剩餘空間,如果大於,則改為 Full GC。如果小於,則檢視 HandlePromotionFailure 設定是否允許擔保失敗,如果允許,那隻會進行一次 Minor GC,如果不允許,則也要進行一次 Full GC。

-XX:-HandlePromotionFailure

  回到第一個問題,老年代也無法確認是否能夠存放GC後新生代轉移過來的物件,那麼這該怎麼辦呢?

  也就是取之前每一次回收晉升到老年代物件容量的平均大小作為經驗值,然後與老年代剩餘空間進行比較,來決定是否進行 Full GC,從而讓老年代騰出更多的空間。

  通常情況下,我們會將 HandlePromotionFaile 設定為允許擔保失敗,這樣能夠避免頻繁的發生 Full GC。

&n