精美圖文帶你掌握 JVM 內存佈局

本 JVM 系列屬於本人學習過程當中總結的一些知識點,目的是想讓讀者更快地掌握 JVM 相關的知識要點,難免會有所側重,若想要更加系統更加詳細的學習 JVM 知識,還是需要去閱讀專業的書籍和文檔。

本文主題內容:

注:請區分 JVM 內存結構(內存佈局)和 JMM(Java 內存模型)這兩個不同的概念!

概念

內存是非常重要的系統資源,是硬盤和 CPU 的中間倉庫及橋樑,承載着操作系統和應用程序的實時運行。JVM 內存佈局規定了 Java 在運行過程中內存申請、分配、管理的策略,保證了 JVM 的高效穩定運行。

上圖描述了當前比較經典的 JVM 內存佈局。(堆區畫小了 2333,按理來說應該是最大的區域)

如果按照線程是否共享來分類的話,如下圖所示:

PS:線程是否共享這點,實際上理解了每塊區域的實際用處之後,就很自然而然的就記住了。不需要死記硬背。

下面讓我們來了解下各個區域。

Heap (堆區)

1. 堆區的介紹

我們先來說堆。堆是 OOM 故障最主要的發生區域。它是內存區域中最大的一塊區域,被所有線程共享,存儲着幾乎所有的實例對象、數組。所有的對象實例以及數組都要在堆上分配,但是隨着 JIT 編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的對象都分配在堆上也漸漸變得不是那麼 “絕對” 了。

延伸知識點:JIT 編譯優化中的一部分內容 - 逃逸分析。

推薦閱讀:深入理解 Java 中的逃逸分析

https://www.hollischuang.com/archives/2583

Java 堆是垃圾收集器管理的主要區域,因此很多時候也被稱做 “GC 堆”。從內存回收的角度來看,由於現在收集器基本都採用分代收集算法,所以 Java 堆中還可以細分爲:新生代和老年代。再細緻一點的有 Eden 空間、From Survivor 空間、To Survivor 空間等。從內存分配的角度來看,線程共享的 Java 堆中可能劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。不過無論如何劃分,都與存放內容無關,無論哪個區域,存儲的都仍然是對象實例,進一步劃分的目的是爲了更好地回收內存,或者更快地分配內存。

2. 堆區的調整

根據 Java 虛擬機規範的規定,Java 堆可以處於物理上不連續的內存空間中,只要邏輯上是連續的即可,就像我們的磁盤空間一樣。在實現時,既可以實現成固定大小的,也可以在運行時動態地調整。

如何調整呢?

通過設置如下參數,可以設定堆區的初始值和最大值,比如 -Xms256M -Xmx 1024M,其中 -X 這個字母代表它是 JVM 運行時參數,ms 是 memory start 的簡稱,中文意思就是內存初始值,mx 是 memory max 的簡稱,意思就是最大內存。

值得注意的是,在通常情況下,服務器在運行過程中,堆空間不斷地擴容與回縮,會形成不必要的系統壓力所以在線上生產環境中 JVM 的 Xms 和 Xmx 會設置成同樣大小,避免在 GC 後調整堆大小時帶來的額外壓力。

3. 堆的默認空間分配

另外,再強調一下堆空間內存分配的大體情況。

這裏可能就會有人來問了,你從哪裏知道的呢?如果我想配置這個比例,要怎麼修改呢?

我先來告訴你怎麼看虛擬機的默認配置。命令行上執行如下命令,就可以查看當前 JDK 版本所有默認的 JVM 參數。

java -XX:+PrintFlagsFinal -version

輸出

對應的輸出應該有幾百行,我們這裏去看和堆內存分配相關的兩個參數

>java -XX:+PrintFlagsFinal -version
[Global flags]
    ...
    uintx InitialSurvivorRatio                      = 8
    uintx NewRatio                                  = 2
    ...
java version "1.8.0_131"
Java(TM) SE Runtime Environment (build 1.8.0_131-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)

參數解釋

因爲新生代是由 Eden + S0 + S1 組成的,所以按照上述默認比例,如果 eden 區內存大小是 40M,那麼兩個 survivor 區就是 5M,整個 young 區就是 50M,然後可以算出 Old 區內存大小是 100M,堆區總大小就是 150M。

4. 堆溢出演示

/**
 * VM Args:-Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
 * @author Richard_Yi
 */
public class HeapOOMTest {

    public static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        List<byte[]> byteList = new ArrayList<>(10);
        for (int i = 0; i < 10; i++) {
            byte[] bytes = new byte[2 * _1MB];
            byteList.add(bytes);
        }
    }
}

輸出

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid32372.hprof ...
Heap dump file created [7774077 bytes in 0.009 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at jvm.HeapOOMTest.main(HeapOOMTest.java:18)

看完上面對堆的介紹,我們趁熱打鐵再學習一下 JVM 創建一個新對象的內存分配流程。

絕大部分對象在 Eden 區生成,當 Eden 區裝填滿的時候,會觸發 Young Garbage Collection,即 YGC。垃圾回收的時候,在 Eden 區實現清除策略,沒有被引用的對象則直接回收。依然存活的對象會被移送到 Survivor 區。Survivor 區分爲 so 和 s1 兩塊內存空間。每次 YGC 的時候,它們將存活的對象複製到未使用的那塊空間,然後將當前正在使用的空間完全清除,交換兩塊空間的使用狀態。如果 YGC 要移送的對象大於 Survivor 區容量的上限,則直接移交給老年代。一個對象也不可能永遠呆在新生代,就像人到了 18 歲就會成年一樣,在 JVM 中 -XX:MaxTenuringThreshold 參數就是來配置一個對象從新生代晉升到老年代的閾值。默認值是 15,可以在 Survivor 區交換 14 次之後,晉升至老年代。

上述涉及到一部分垃圾回收的名詞,不熟悉的讀者可以查閱資料或者看下本系列的垃圾回收章節。

Metaspace 元空間

在 HotSpot JVM 中,永久代( ≈ 方法區)中用於存放類和方法的元數據以及常量池,比如 Class 和 Method。每當一個類初次被加載的時候,它的元數據都會放到永久代中。

永久代是有大小限制的,因此如果加載的類太多,很有可能導致永久代內存溢出,即萬惡的 java.lang.OutOfMemoryError: PermGen,爲此我們不得不對虛擬機做調優。

那麼,Java 8 中 PermGen 爲什麼被移出 HotSpot JVM 了?(詳見:JEP 122: Remove the Permanent Generation):

  1. 由於 PermGen 內存經常會溢出,引發惱人的 java.lang.OutOfMemoryError: PermGen,因此 JVM 的開發者希望這一塊內存可以更靈活地被管理,不要再經常出現這樣的 OOM

  2. 移除 PermGen 可以促進 HotSpot JVM 與 JRockit VM 的融合,因爲 JRockit 沒有永久代。

根據上面的各種原因,PermGen 最終被移除,方法區移至 Metaspace,字符串常量池移至堆區。

準確來說,Perm 區中的字符串常量池被移到了堆內存中是在 Java7 之後,Java 8 時,PermGen 被元空間代替,其他內容比如類元信息、字段、靜態屬性、方法、常量等都移動到元空間區。比如 java/lang/Object 類元信息、靜態屬性 System.out、整形常量 100000 等。

元空間的本質和永久代類似,都是對 JVM 規範中方法區的實現。不過元空間與永久代之間最大的區別在於:元空間並不在虛擬機中,而是使用本地內存。因此,默認情況下,元空間的大小僅受本地內存限制。(和後面提到的直接內存一樣,都是使用本地內存)

In JDK 8, classes metadata is now stored in the native heap and this space is called Metaspace.

對應的 JVM 調參:

延伸閱讀:關於 Metaspace 比較好的兩篇文章

Metaspace in Java 8

http://lovestblog.cn/blog/2016/10/29/metaspace/

Java 虛擬機棧

對於每一個線程,JVM 都會在線程被創建的時候,創建一個單獨的棧。也就是說虛擬機棧的生命週期和線程是一致,並且是線程私有的。除了 Native 方法以外,Java 方法都是通過 Java 虛擬機棧來實現調用和執行過程的(需要程序技術器、堆、元空間內數據的配合)。所以 Java 虛擬機棧是虛擬機執行引擎的核心之一。而 Java 虛擬機棧中出棧入棧的元素就稱爲「棧幀」。

棧幀 (Stack Frame) 是用於支持虛擬機進行方法調用和方法執行的數據結構。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每一個方法從調用至執行完成的過程,都對應着一個棧幀在虛擬機棧裏從入棧到出棧的過程。

棧對應線程,棧幀對應方法

在活動線程中, 只有位於棧頂的幀纔是有效的, 稱爲當前棧幀。正在執行的方法稱爲當前方法。在執行引擎運行時, 所有指令都只能針對當前棧幀進行操作。而 StackOverflowError 表示請求的棧溢出, 導致內存耗盡, 通常出現在遞歸方法中。

虛擬機棧通過 pop 和 push 的方式,對每個方法對應的活動棧幀進行運算處理,方法正常執行結束,肯定會跳轉到另一個棧幀上。在執行的過程中,如果出現了異常,會進行異常回溯,返回地址通過異常處理表確定。

可以看出棧幀在整個 JVM 體系中的地位頗高。下面也具體介紹一下棧幀中的存儲信息。

1. 局部變量表

局部變量表就是存放方法參數和方法內部定義的局部變量的區域。

局部變量表所需的內存空間在編譯期間完成分配,當進入一個方法時,這個方法需要在幀中分配多大的局部變量空間是完全確定的,在方法運行期間不會改變局部變量表的大小。

這裏直接上代碼,更好理解。

public int test(int a, int b) {
    Object obj = new Object();
    return a + b;
}

如果局部變量是 Java 的 8 種基本基本數據類型,則存在局部變量表中,如果是引用類型。如 new 出來的 String,局部變量表中存的是引用,而實例在堆中。

2. 操作棧

操作數棧(Operand Stack)看名字可以知道是一個棧結構。Java 虛擬機的解釋執行引擎稱爲 “基於棧的執行引擎”,其中所指的“棧” 就是操作數棧。當 JVM 爲方法創建棧幀的時候,在棧幀中爲方法創建一個操作數棧,保證方法內指令可以完成工作。

還是用實操理解一下。

/**
 * @author Richard_yyf
 */
public class OperandStackTest {

    public int sum(int a, int b) {
        return a + b;
    }
}

編譯生成 .class 文件之後,再反彙編查看彙編指令

> javac OperandStackTest.java
> javap -v OperandStackTest.class > 1.txt
 public int sum(int, int);
    descriptor: (II)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3 // 最大棧深度爲2 局部變量個數爲3
         0: iload_1 // 局部變量1 壓棧
         1: iload_2 // 局部變量2 壓棧
         2: iadd    // 棧頂兩個元素相加,計算結果壓棧
         3: ireturn
      LineNumberTable:
        line 10: 0

3. 動態連接

每個棧幀中包含一個在常量池中對當前方法的引用, 目的是支持方法調用過程的動態連接。

4. 方法返回地址

方法執行時有兩種退出情況:

無論何種退出情況,都將返回至方法當前被調用的位置。方法退出的過程相當於彈出當前棧幀,退出可能有三種方式:

本地方法棧

本地方法棧(Native Method Stack)與虛擬機棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機棧爲虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的 Native 方法服務。在虛擬機規範中對本地方法棧中方法使用的語言、使用方式與數據結構並沒有強制規定,因此具體的虛擬機可以自由實現它。甚至有的虛擬機(譬如 Sun HotSpot 虛擬機)直接就把本地方法棧和虛擬機棧合二爲一。與虛擬機棧一樣,本地方法棧區域也會拋出 StackOverflowError 和 OutOfMemoryError 異常。

程序計數器

程序計數器(Program Counter Register)是一塊較小的內存空間。是線程私有的。它可以看作是當前線程所執行的字節碼的行號指示器。什麼意思呢?

白話版本:因爲代碼是在線程中運行的,線程有可能被掛起。即 CPU 一會執行線程 A,線程 A 還沒有執行完被掛起了,接着執行線程 B,最後又來執行線程 A 了,CPU 得知道執行線程 A 的哪一部分指令,線程計數器會告訴 CPU。

由於 Java 虛擬機的多線程是通過線程輪流切換並分配處理器執行時間的方式來實現的,CPU 只有把數據裝載到寄存器才能夠運行。寄存器存儲指令相關的現場信息,由於 CPU 時間片輪限制,衆多線程在併發執行過程中,任何一個確定的時刻,一個處理器或者多核處理器中的一個內核,只會執行某個線程中的一條指令。

因此,爲了線程切換後能恢復到正確的執行位置,每條線程都需要有一個獨立的程序計數器,各條線程之間計數器互不影響,獨立存儲。每個線程在創建後,都會產生自己的程序計數器和棧幀,程序計數器用來存放執行指令的偏移量和行號指示器等,線程執行或恢復都要依賴程序計數器。此區域也不會發生內存溢出異常。

直接內存

直接內存(Direct Memory)並不是虛擬機運行時數據區的一部分,也不是 Java 虛擬機規範中定義的內存區域。但是這部分內存也被頻繁地使用,而且也可能導致 OutOfMemoryError 異常出現,所以我們放到這裏一起講解。

在 JDK 1.4 中新加入了 NIO(New Input/Output)類,引入了一種基於通道(Channel)與緩衝區(Buffer)的 I/O 方式,它可以使用 Native 函數庫直接分配堆外內存,然後通過一個存儲在 Java 堆中的 DirectByteBuffer 對象作爲這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因爲避免了在 Java 堆和 Native 堆中來回複製數據。

顯然,本機直接內存的分配不會受到 Java 堆大小的限制,但是,既然是內存,肯定還是會受到本機總內存(包括 RAM 以及 SWAP 區或者分頁文件)大小以及處理器尋址空間的限制。如果內存區域總和大於物理內存的限制,也會出現 OOM。

Code Cache

簡而言之, JVM 代碼緩存是 JVM 將其字節碼存儲爲本機代碼的區域 。我們將可執行本機代碼的每個塊稱爲 nmethod。該 nmethod 可能是一個完整的或內聯 Java 方法。

實時(JIT)編譯器是代碼緩存區域的最大消費者。這就是爲什麼一些開發人員將此內存稱爲 JIT 代碼緩存的原因。

這部分代碼所佔用的內存空間成爲 CodeCache 區域。一般情況下我們是不會關心這部分區域的且大部分開發人員對這塊區域也不熟悉。如果這塊區域 OOM 了,在日誌裏面就會看到:

java.lang.OutOfMemoryError code cache。

診斷選項

延伸閱讀:Introduction to JVM Code Cache

https://www.baeldung.com/jvm-code-cache

參考:

《深入理解 Java 虛擬機》 - 周志明

《碼出高效》

Metaspace in Java 8

JVM 機器指令集圖解:

Introduction to JVM Code Cache

作者:Richard_Yi

來源:https://ricstudio.top/archives/jvmmemorystructure

****關注架構師 (JiaGouX),添加 “星標”

獲取每天技術乾貨,一起成爲牛逼架構師

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/B2sjIi0ml1Uu3LWuY8HlqQ