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

  • 2019 年 10 月 3 日
  • 筆記

  我們說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。