JVM筆記-記憶體分配策略

  • 2020 年 3 月 18 日
  • 筆記

1. 概述

1.1 簡述

Java 技術體系的自動記憶體管理,最根本的目標就是解決兩個問題:「自動化」地給對象分配、回收記憶體空間。

記憶體回收策略主要就是前面介紹的各種垃圾回收機制;而對象記憶體分配的規則並不固定,JVM 規範並未規定新對象的創建和存儲細節,取決於使用哪種 JVM 以及參數設定。

本文主要以實驗手段驗證記憶體分配的幾條基本原則。

1.2 環境配置

本文實驗環境配置如下:

  • 作業系統:macOS Mojave 10.14.5
  • JDK 版本
$ java -version  java version "1.8.0_191"  Java(TM) SE Runtime Environment (build 1.8.0_191-b12)  Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

1.3 相關虛擬機參數

本文相關的虛擬機參數及說明如下:

2. 記憶體分配基本原則

2.1 對象優先在 Eden 分配

大多數情況下,對象在新生代 Eden 區分配記憶體,當 Eden 區沒有足夠空間分配時,虛擬機將發起一次 Minor GC。

  • JVM 參數
-XX:+UseSerialGC -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8

參數說明:堆空間為 20MB,新生代和老年代各占 10MB,新生代可用空間:Eden 區 + 1 個 Survivor 區(即總共 8 + 1 = 9MB)。

  • 測試程式碼
private static final int _1M = 1024 * 1024;    private static void testAllocation() {    // 分配三個 2MB 大小的對象(a1, a2, a3)和一個 4MB 大小的對象(a4)    byte[] a1, a2, a3, a4;    a1 = new byte[2 * _1M];    a2 = new byte[2 * _1M];    a3 = new byte[2 * _1M];    a4 = new byte[4 * _1M]; // 觸發一次 Minor GC  }

該方法執行過程中,對象的記憶體空間分配流程大致如下:

  1. a1, a2, a3 分配在 Eden 區;
  2. 當給 a4 分配空間時,由於 Eden 區剩餘空間不足(無法容納 a4),觸發一次 Minor GC:
    1. 將 Eden 存活的對象複製到 Survivor 區(1 MB),由於 Survivor 無法容納 a1, a2, a3,因此直接將它們轉移到老年代;
    2. 回收 Eden 區,並將 a4 分配到 Eden 區。

因此,這幾行程式碼執行完的結果是:a1, a2, a3 位於老年代(共 10MB,佔用 6MB),a4 位於新生代 Eden 區(共 8MB,佔用 4MB)。

下面查看和分析 GC 日誌進行驗證。

  • GC 日誌

可以看到,Eden 共 8MB(8192K),使用 51%,老年代共 10MB(10204K),使用 60%,符合上述分析結果。

2.2 大對象直接進入老年代

  • 大對象:需要大量連續記憶體空間的 Java 對象。
  • 典型例子:很長的字元串,或者元素量非常大的數組。

JVM 需要盡量避免大對象的主要原因:

  1. 分配空間時,記憶體還有不少空間,就提前觸發垃圾收集,以獲取足夠的空間給它們。
  2. 複製對象時,記憶體開銷更高。
  • JVM 參數
-XX:+UseSerialGC -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8  -XX:PretenureSizeThreshold=3145728
  • 示例程式碼
private static void testPretenureSizeThreshold() {    byte[] a;    a = new byte[4 * _1M];  }

對象 a 所需的記憶體空間(4MB)大於設定的閾值 PretenureSizeThreshold,直接分配在老年代。

  • GC 日誌

可以看到,老年代總記憶體為 10MB(10240K),使用 40%,符合上述分析結果。

2.3 長期存活的對象將進入老年代

HotSpot 多數收集器採用了分代收集,這個分代是根據什麼分的呢?

JVM 給每個對象定義了一個年齡(Age)計數器(存儲在對象頭),用於記錄對象的年齡。 對象通常在 Eden 區誕生,若經歷一次 Minor GC 後仍存活,則將其年齡增加 1;此後在 Survivor 區每經過一次 Minor GC,年齡都會遞增 1,當年齡達到一定程度(默認 15),就會晉陞到老年代中。

2.3.1 場景一
  • 虛擬機參數
-XX:+UseSerialGC -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8  -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
  • 示例程式碼
private static void testTenuringThreshold() {    byte[] a1, a2, a3;    a1 = new byte[_1M / 4];      a2 = new byte[4 * _1M];    a3 = new byte[4 * _1M]; // 第一次 Minor GC    a3 = null;    a3 = new byte[4 * _1M]; // 第二次 Minor GC  }

該方法執行過程中,對象的記憶體空間分配流程大致如下:

  1. a1, a2 分配在 Eden 區(年齡 age=0);
  2. a3 初次在 Eden 區分配空間時,Eden 區沒有足夠空間,會觸發一次 Minor GC:
    1. a1, a2 年齡增加 1 (age=1),並將其複製到 Survivor (to) 區;
    2. 由於 Survivor 空間(1 MB)只能容納 a1,因此將 a1 複製到 Survivor (to) 區,a2 進入老年代;
    3. 回收 Eden 區,並將 a3 分配在 Eden 區;
  3. 執行 a3 = null 時,沒有 GC 動作(此時 a3 佔用的空間還未回收);
  4. 再次為 a3 分配空間時,Eden 空間不足,再次觸發 Minor GC:
    1. a1 年齡增加 1(age=2),大於設定閾值(MaxTenuringThreshold),將其移入老年代;
    2. 回收 Eden 區,再次將 a3 分配到 Eden 區。

到這裡,記憶體分配結果為:a1、a2 位於老年代,a3 位於新生代 Eden 區。下面分析 GC 日誌進行驗證。

  • GC 日誌

可以看到,新生代 Eden 區空間(總 8 MB)佔用 51%,老年代(總 10 MB)空間佔用 46%,符合上述分析結果。

2.3.2 場景二
  • 上述程式碼不變,將參數 MaxTenuringThreshold的值修改為 15 再進行測試。

該方法執行過程中,對象的記憶體空間分配流程大致如下:

第二次 Minor GC 之前,流程與場景一相同,下面從第二次 Minor GC 開始(執行最後一行程式碼時)時分析:

  1. 再次為 a3 分配空間時,Eden 空間不足,再次觸發 Minor GC:
    1. a1 年齡加 1(age=2),小於設定閾值(MaxTenuringThreshold),將其複製到 Survivor (from) 區;
    2. 回收 Eden 區空間,再次將 a3 分配到 Eden 區。

到這裡,記憶體分配結果應為:a1 位於 Survivor (from) 區,a2 位於老年代,a3 位於新生代 Eden 區。

下面分析 GC 日誌進行驗證。

  • GC 日誌

可以看到,新生代 Eden 區佔用 51%,兩個 Survivor 區都是 0%,老年代為 46%,與上述分析結果並不一致。這是為什麼呢?

查看日誌可以看到,第一次 GC 發生時:

new threshold 1 (max 15)

意思是晉陞的閾值變成了 1,而非設定的 15!

為什麼 MaxTenuringThreshold 設定是 15,但第一次 GC 時為 1 呢?

在一段 JVM 源碼中可以得到答案:

int ageTable::compute_tenuring_threshold(size_t survivor_capacity) {    // TargetSurvivorRatio默認為50    // desired_survivor_size = survivor的空間 * 50%    size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);    size_t total = 0;    // 計算得出的對象年齡    int age = 1;    assert(sizes[0] == 0, "no objects with age zero should be recorded");    while (age < table_size) {      // 循環遍歷所有年齡代的對象累加得到一個大小      total += sizes[age];      // 如果該大小大於desired_survivor_size,即survivor的空間 * 50%,那麼退出循環【注意這裡】      if (total > desired_survivor_size) break;      age++;    }    // 如果算出來的age大於MaxTenuringThreshold則使用MaxTenuringThreshold,否則使用計算出來的age    int result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;      if (PrintTenuringDistribution || UsePerfData) {      if (PrintTenuringDistribution) {        gclog_or_tty->cr();        // 這裡就是線上出現的那個日誌所在的地方        gclog_or_tty->print_cr("Desired survivor size %ld bytes, new threshold %d (max %d)",          desired_survivor_size*oopSize, result, MaxTenuringThreshold);      }    //....    }    // 返回計算的年齡    return result;  }

參考鏈接:https://blog.csdn.net/u013160932/article/details/84894969

從這段程式碼可以看出:對象實際的年齡是計算出來的,而這個年齡是 age 和 MaxTenuringThreshold 中較小的一個,參見如下程式碼:

int result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;

而這個 age 如何計算呢?從上述程式碼可以看出:

  1. age 初始值為 1;
  2. 按年齡從小到大循環遍歷 Survivor 區的所有對象(累加),當它們所佔空間總和大於 Survivor 一半(desired_survivor_size)的時候,跳出循環,當前 age 即為所得結果。

對於這個循環,舉例說明:

  • 若 Survivor 區當前 age=1 的對象所佔空間已經超過一半,則該 age 就是 1(實際晉陞年齡就是 1);
  • 若遍歷到 age=3 時,age 為 1、2、3 的對象所佔空間總和超過 Survivor 一半,則 age=3(實際晉陞年齡就是 3)。

根據上述 GC 日誌第一次 GC 時 age=1,推測此時 Survivor 區 age=1 的對象已經超過了一半。

對上述程式碼稍作修改進行驗證:

  • 測試程式碼
private static void testTenuringThreshold() {    byte[] a1, a2, a3;    a1 = new byte[_1M / 4];      a2 = new byte[4 * _1M];    a3 = new byte[4 * _1M]; // 第一次 Minor GC    a3 = null;  //  a3 = new byte[4 * _1M]; // 第二次 Minor GC  }

這裡將第二次觸發 GC 的程式碼注釋掉,此時該方法只發生一次 GC,日誌如下:

可以看到,Survivor (from) 區已經使用 66%,超過了一半!說明推測是正確的。

2.3.3 場景三

上述 Survivor 區空間在該程式碼運行前已超過一半,說明在此之前已有其他對象分配了。為了進一步驗證,在執行 testTenuringThreshold 方法前,先運行下面程式碼:

System.gc();

進行一次 Full GC,然後再執行 testTenuringThreshold 方法,此時的 GC 日誌如下:

這時是符合場景一分析結果的。

註: 從官方文檔 https://www.oracle.com/technetwork/java/vmoptions-jsp-140102.html 可以看到,其實參數 MaxTenuringThreshold 設置的是一個"最大"值,而非一個實際的晉陞閾值。 PS: 名字的 Max 也有點這個意思。

2.4 動態對象年齡判定

實際上,HotSpot 並非要求對象年齡必須達到 -XX:MaxTenuringThreshold 才能晉陞老年代,若在 Survivor 空間中年齡相同的所有對象大小總和大於 Survivor 空間的一半,年齡大於或等於該年齡的對象就能直接進入老年代。

  • JVM 參數
-XX:+UseSerialGC -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8  -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
  • 示例程式碼
private static void testTenuringThreshold2() {    byte[] a1, a2, a3, a4;    a1 = new byte[_1M / 4];    a2 = new byte[_1M / 4];      a3 = new byte[4 * _1M];    a4 = new byte[4 * _1M]; // 第一次 Minor GC    a4 = null;    a4 = new byte[4 * _1M]; // 第二次 Minor GC  }

該方法執行中的記憶體分配流程大致如下:

  1. a1, a2, a3 分配在 Eden 區(age=0);
  2. 為 a4 分配記憶體時,Eden 區空間不足,觸發一次 Minor GC:
    1. a1, a2 年齡增加 1(age=1),並複製到 Survivor (to) 區,a3 進入老年代;
    2. 回收 Eden 區,在 Eden 區為 a4 分配空間;
  3. a4 = null 未觸發 GC;
  4. 為 a4 再次分配空間時,Eden 區空間不足,再次觸發 Minor GC:
    1. a1, a2 年齡增加 1(age=2),雖然年齡並未到達閾值 15,但二者記憶體加起來超過 Survivor 空間一半,因此 a1 和 a2 都進入老年代;
    2. 回收 Eden 區,並在 Eden 區為 a4 再次分配空間。

結果:a1, a2, a3 都位於老年代,a4 位於新生代 Eden 區。

下面查看 GC 日誌進行驗證。

  • GC 日誌

可以看到與分析結果大體相當。

2.5 空間分配擔保

由於發生 Minor GC 時,可能會有一部分對象進入老年代。最極端的情況就是:Minor GC 時新生代所有對象全都存活,需要老年代進行分配擔保。

因此,在發進行 Minor GC 之前,JVM 會先檢查老年代的空間,流程如下:

若 Minor GC 發生時,老年代沒有足夠的空間進行分配擔保,就會觸發一次停頓更久的 Full GC。

注意:上述流程是 JDK 6 Update 24 之前的邏輯。 在此之後,規則變為:只要老年代的連續空間大於新生代對象總大小或者歷次晉陞的平均大小,就會進行 Minor GC,否則將進行 Full GC。