1. 程式人生 > 實用技巧 >Java堆

Java堆

Java堆的基本概念

Java 堆是虛擬機器所管理的記憶體中最大的一塊,是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一作用就是存放物件例項,幾乎所有的物件例項都是在這裡分配的(不絕對,在虛擬機器的優化策略下,也會存在棧上分配、標量替換的情況)。Java 堆是 GC 回收的主要區域,因此很多時候也被稱為 GC 堆。從記憶體回收的角度看,Java 堆還可以被細分為新生代和老年代;再細一點新生代還可以被劃分為 Eden Space、From Survivor Space、To Survivor Space。從記憶體回收的角度看,執行緒共享的 Java 堆可能劃分出多個執行緒私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。「屬於執行緒共享的記憶體區域」

堆中儲存著所有引用型別的真實資訊,以方便執行器執行。堆在邏輯上分為三個區域:

Java7:

Java8:

可以看到,在Java7時代,堆分為新生區(新生區包含伊甸園區和倖存區,倖存區又包含倖存者0區和倖存者1區。此外,倖存者0區又稱為From區,倖存者1區又稱為To區,From區和To區並不是固定的,複製之後互動,誰空誰是To),養老區和永久代;在Java8中,永久代已經被移除,被一個稱為元空間的區域所取代。元空間的本質和永久代類似。

永久代:

元空間:

元空間與永久代之間最大的區別在於:永久代使用的JVM的堆記憶體,但是java8以後的元空間並不在虛擬機器中而是使用本機實體記憶體(所以在上圖中,我用虛線表示)。

堆之所以要分割槽是因為:Java程式中不同物件的生命週期不同,70%~99%物件都是臨時物件,這類物件在新生區“朝生夕死”。如果沒有分割槽,GC時蒐集垃圾需要對整個堆記憶體進行掃描;分割槽後,回收這些“朝生夕死”的物件,只需要在小範圍的區域中(新生區)蒐集垃圾。所以,分割槽的唯一理由就是為了優化GC效能。

堆空間物件分配過程

下面通過一個例子來講述這幾個區的互動邏輯:

1.幾乎任何新的物件都是在伊甸園區被new出來建立,剛開始的時候兩個倖存者區和養老區都是空的:

2.隨著物件的不斷建立,伊甸園區空間逐漸被填滿:

3.這時候將觸發一次Minor GC(Young GC),刪除未引用的物件,GC剩下來的還存在引用的物件將移動到倖存者0區,然後清空伊甸園區:

4.隨著物件的建立,伊甸園區空間又滿了,再一次觸發Minor GC,刪除未引用的物件,留下存在引用的物件。這次和上一次Minor GC有些不同,這輪GC留下的物件將被移動到倖存者1區,並且上一輪GC留下來的儲存在倖存者0區的物件年齡遞增並移動到倖存者1區。當所有幸存物件都移動到倖存者1區後,倖存者0區和伊甸園區空間清除:

5.隨著物件的建立伊甸園區空間再一次滿了,觸發了第三次Minor GC,這一次倖存區空間將發生互換,GC留下來的倖存者將移動到倖存者0區,倖存者1區的倖存物件年齡遞增後也移動到倖存者0區,然後伊甸園區和倖存者1區的空間被清除:

6.隨著Minor GC的不斷髮生,倖存物件在兩個倖存區不斷地交換儲存,年齡也不斷遞增。如此反反覆覆之後,當倖存物件的年齡達到指定的閾值(這個例子中是8,由JVM引數MaxTenuringThreshold決定)後,它們將被移動到養老區:

7.隨著上述過程的不斷出現,當養老區快滿時,將觸發Major GC(Full GC)進行養老區的記憶體清理。若養老區執行了GC之後發現依然無法進行物件的儲存,就會產生OOM異常。

一個物件被放置到養老區除了它的年齡達到閾值外,以下幾種情況也會使得該物件直接被放置到養老區:

  1. 物件建立後,無法放置到伊甸園區(比如伊甸園區的大小為10m,新的物件大小為11m,伊甸園區不夠放,觸發YGC。YGC後伊甸園區被清空,但還是無法容下11m的“超大物件”,所以直接放置到養老區。當然如果養老區放置不下則會觸發FGC,FGC後還放不下則OOM);
  2. YGC後,物件無法放置到倖存者To區也會直接晉升到養老區;
  3. 如果倖存區中相同年齡的所有物件大小大於倖存區空間的一半,年齡大於或等於這些物件年齡的物件可以直接進入養老區,無需等到年齡閾值。

堆引數

以JDK1.8+HotSpot為例,常用的可調整的堆引數有:

引數 含義
-Xms,等價於-XX:InitialHeapSize 設定堆的初始記憶體大小,預設為實體記憶體的1/64
-Xmx,等價於-XX:MaxHeapSize 設定堆的最大記憶體大小,預設為實體記憶體的1/4
-XX:Newratio 設定新生區和養老區的比例,比如值為2(預設值),則養老區是新生區的2倍,即養老區佔據堆記憶體的2/3
-XX:Surviorratio 設定伊甸園區和一個倖存區的比例,比如值為8(預設值)則表示伊甸園區佔新生區的8/10(兩個倖存區是一樣大的)
-Xmn 設定堆新生區的記憶體大小(一般不使用)
-XX:MaxTenuringThreshold 設定轉入養老區的存活次數,預設值為15
-XX:+PrintFlagsInitial 檢視所有引數的預設初始值
-XX:+PrintFlagsFinal 檢視所有引數的最終值(被我們修改後的值不再是預設初始值)

剩下所有可用引數可以檢視oracle官方文件:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html。

生產環境中,推薦將-Xms和-Xmx設定為一樣大,因為這樣做的話在Java垃圾回收清理完堆區後不需要重新計算堆區大小,從而提高效能。此外,要在程式中輸出詳細的GC處理日誌,可以使用-XX:+PrintGCDetails

比如,我的電腦記憶體為32GB,所以堆的預設初始值大小為500MB左右,堆的最大值大約為8000MB左右:

public class Test {
    public static void main(String[] args) {
        long maxMemory = Runtime.getRuntime().maxMemory();
        long totalMemory = Runtime.getRuntime().totalMemory();

        System.out.println("堆記憶體的初始值" + totalMemory / 1024 / 1024 + "mb");
        System.out.println("堆記憶體的最大值" + maxMemory / 1024 / 1024 + "mb");
    }
}

程式輸出:

堆記憶體的初始值491mb
堆記憶體的最大值7282mb

可以通過IDEA調整堆的大小:

我們將堆記憶體的初始大小和最大值都設定為10mb,並且開啟GC日誌列印,重新執行下面這段程式:

public class Test {
    public static void main(String[] args) {
        long maxMemory = Runtime.getRuntime().maxMemory();
        long totalMemory = Runtime.getRuntime().totalMemory();

        System.out.println("堆記憶體的初始值" + totalMemory / 1024  + "kb");
        System.out.println("堆記憶體的最大值" + maxMemory / 1024  + "kb");
    }
}

輸出如下所示:

堆記憶體的初始值9728kb
堆記憶體的最大值9728kb
Heap
 PSYoungGen      total 2560K, used 1388K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 2048K, 67% used [0x00000007bfd00000,0x00000007bfe5b370,0x00000007bff00000)
  from space 512K, 0% used [0x00000007bff80000,0x00000007bff80000,0x00000007c0000000)
  to   space 512K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007bff80000)
 ParOldGen       total 7168K, used 0K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
  object space 7168K, 0% used [0x00000007bf600000,0x00000007bf600000,0x00000007bfd00000)
 Metaspace       used 2947K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 320K, capacity 388K, committed 512K, reserved 1048576K

可以看到,PSYoungGen(新生區)的總記憶體大小為2560k,ParOldGen(養老區)的總記憶體大小為7168k,總和剛好是9728K,這也說明了:Java8後的堆物理上只分為新生區和養老區,Metaspace(元空間)不佔用堆記憶體,而是直接使用實體記憶體。

那為什麼我們設定的堆記憶體大小是10m(10240kb),控制檯輸出卻只有9728kb呢?從上面的例子我們知道,倖存者區分為0區和1區,根據複製演算法的特點,這兩個區同一時刻總有一個區是空的,所以控制檯輸出的記憶體計算方式為:2048K(eden space)+512K(from space or to space)+7168K(ParOldGen)=9728K。9728K再加一個倖存區的大小512K剛好是10240K。

再舉個OOM的例子,使用剛剛-Xms10m -Xmx10m -XX:+PrintGCDetails的設定,執行下面這段程式:

public class Test {
    public static void main(String[] args) {
        String value = "hello";
        while (true) {
            value += value + new Random().nextInt(1000000000) + new Random().nextInt(1000000000);
        }
    }
}

輸出如下:

[GC (Allocation Failure) [PSYoungGen: 1893K->491K(2560K)] 1893K->597K(9728K), 0.0007246 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2207K->496K(2560K)] 2313K->1153K(9728K), 0.0008383 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2007K->496K(2560K)] 2664K->1897K(9728K), 0.0009456 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 2021K->496K(2560K)] 4894K->4113K(9728K), 0.0010814 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 1359K->496K(2560K)] 6448K->5600K(9728K), 0.0015792 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 496K->496K(1536K)] 5600K->5600K(8704K), 0.0006416 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 496K->0K(1536K)] [ParOldGen: 5104K->2585K(7168K)] 5600K->2585K(8704K), [Metaspace: 2982K->2982K(1056768K)], 0.0044783 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 61K->192K(2048K)] 7061K->7192K(9216K), 0.0012566 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 192K->0K(2048K)] [ParOldGen: 7000K->1840K(7168K)] 7192K->1840K(9216K), [Metaspace: 3042K->3042K(1056768K)], 0.0072023 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 65K->160K(2048K)] 6321K->6416K(9216K), 0.0022603 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 160K->0K(2048K)] [ParOldGen: 6256K->4785K(7168K)] 6416K->4785K(9216K), [Metaspace: 3076K->3076K(1056768K)], 0.0056740 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] 4785K->4785K(9216K), 0.0003871 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2048K)] [ParOldGen: 4785K->4765K(7168K)] 4785K->4765K(9216K), [Metaspace: 3076K->3076K(1056768K)], 0.0049903 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 2048K, used 59K [0x00000007bfd00000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 1024K, 5% used [0x00000007bfd00000,0x00000007bfd0efb8,0x00000007bfe00000)
  from space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
  to   space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
 ParOldGen       total 7168K, used 4765K [0x00000007bf600000, 0x00000007bfd00000, 0x00000007bfd00000)
  object space 7168K, 66% used [0x00000007bf600000,0x00000007bfaa77b8,0x00000007bfd00000)
 Metaspace       used 3113K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 339K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3332)
    at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
    at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:674)
    at java.lang.StringBuilder.append(StringBuilder.java:208)
    at cc.mrbird.Test.main(Test.java:19)

可以看到,經過數次的GC和Full GC後,堆記憶體還是無法騰出空間,最終丟擲OOM錯誤。日誌的含義如下圖所示:

Young GC(Minor GC):

Full GC(Major GC):

TLAB

JVM對伊甸園區繼續進行劃分,為每個執行緒分配了一個私有快取區域,這塊區域就是TLAB(Thread Local Allocation Buffer)。多執行緒同時分配記憶體時,使用TLAB可以避免一系列非執行緒安全問題,同時還能夠提升記憶體分配的吞吐量。儘管不是所有的物件例項都能夠在TLAB中成功分配記憶體,但JVM確實是將TLAB作為記憶體分配的首選:

我們可以使用-XX:UseTLAB設定是否開啟TLAB,舉個例子:

public class Test {

    public static void main(String[] args) throws InterruptedException {
        TimeUnit.SECONDS.sleep(100);
    }
}

執行main方法:

可以看到TLAB預設是開啟的。

TLAB空間的記憶體非常小,僅佔整個伊甸園區的1%,可以通過-XX:TLABWasteTargetPercent設定TLAB空間所佔用伊甸園區空間的百分比。

有了TLAB的概念後,我們就不能說堆空間一定是執行緒共享的了。

本文參考:https://mrbird.cc/JVM-Learn.html