图文并茂,傻瓜都能看懂的 JVM 内存布局
本 JVM 系列屬于本人學習過程當中總結的一些知識點,目的是想讓讀者更快地掌握 JVM 相關的知識要點,難免會有所側重,若想要更加系統更加詳細的學習 JVM 知識,還是需要去閱讀專業的書籍和文檔。
本文主題內容:
-
JVM 內存區域概覽
-
堆區的空間分配是怎么樣?堆溢出的演示
-
創建一個新對象內存是怎么分配的?
-
方法區 到 Metaspace 元空間
-
棧幀是什么?棧幀里有什么?怎么理解?
-
本地方法棧
-
程序計數器
-
Code Cache 是什么?
注:請區分 JVM 內存結構(內存布局)和 JMM(Java 內存模型)這兩個不同的概念!
概念
內存是非常重要的系統資源,是硬盤和 CPU 的中間倉庫及橋梁,承載著操作系統和應用程序的實時運行。JVM 內存布局規定了 Java 在運行過程中內存申請、分配、管理的策略,保證了 JVM 的高效穩定運行。
上圖描述了當前比較經典的 JVM 內存布局。(堆區畫小了 2333,按理來說應該是最大的區域)
如果按照線程是否共享來分類的話,如下圖所示:
PS:線程是否共享這點,實際上理解了每塊區域的實際用處之后,就很自然而然的就記住了。不需要死記硬背。
下面讓我們來了解下各個區域。
Heap (堆區)
1.?堆區的介紹
我們先來說堆。堆是 OOM 故障最主要的發生區域。它是內存區域中最大的一塊區域,被所有線程共享,存儲著幾乎所有的實例對象、數組。所有的對象實例以及數組都要在堆上分配,但是隨著 JIT 編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的對象都分配在堆上也漸漸變得不是那么“絕對”了。
延伸知識點:JIT 編譯優化中的一部分內容 -?逃逸分析。
推薦閱讀:面試問我 Java 逃逸分析,瞬間被秒殺了。
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)
-XX:+HeapDumpOnOutOfMemoryError 可以讓 JVM 在遇到 OOM 異常時,輸出堆內信息,特別是對相隔數月才出現的 OOM 異常尤為重要。
創建一個新對象內存分配流程
看完上面對堆的介紹,我們趁熱打鐵再學習一下 JVM 創建一個新對象的內存分配流程。
絕大部分對象在?Eden?區生成,當?Eden?區裝填滿的時候,會觸發?Young Garbage Collection,即?YGC。垃圾回收的時候,在?Eden?區實現清除策略,沒有被引用的對象則直接回收。依然存活的對象會被移送到?Survivor?區。Survivor?區分為 so 和 s1 兩塊內存空間。每次?YGC?的時候,它們將存活的對象復制到未使用的那塊空間,然后將當前正在使用的空間完全清除,交換兩塊空間的使用狀態。如果 YGC 要移送的對象大于?Survivor?區容量的上限,則直接移交給老年代。一個對象也不可能永遠呆在新生代,就像人到了 18 歲就會成年一樣,在 JVM 中?-XX:MaxTenuringThreshold?參數就是來配置一個對象從新生代晉升到老年代的閾值。默認值是 15,可以在?Survivor?區交換 14 次之后,晉升至老年代。
上述涉及到一部分垃圾回收的名詞,不熟悉的讀者可以查閱資料或者看下本系列的垃圾回收章節。46張PPT弄懂JVM、GC算法和性能調優,分享給大家。
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 調參:
46張PPT弄懂JVM、GC算法和性能調優,分享給大家。
延伸閱讀:關于 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 的方式,對每個方法對應的活動棧幀進行運算處理,方法正常執行結束,肯定會跳轉到另一個棧幀上。在執行的過程中,如果出現了異常,會進行異?;厮?#xff0c;返回地址通過異常處理表確定。
可以看出棧幀在整個 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
3. 動態連接
每個棧幀中包含一個在常量池中對當前方法的引用, 目的是支持方法調用過程的動態連接。
4. 方法返回地址
方法執行時有兩種退出情況:
-
正常退出,即正常執行到任何方法的返回字節碼指令,如?RETURN、IRETURN、ARETURN?等
-
異常退出
無論何種退出情況,都將返回至方法當前被調用的位置。方法退出的過程相當于彈出當前棧幀,退出可能有三種方式:
-
返回值壓入上層調用棧幀
-
異常信息拋給能夠處理的棧幀
-
PC 計數器指向方法調用后的下一條指令
本地方法棧
本地方法棧(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。
診斷選項
?
參考:
《深入理解Java虛擬機》 - 周志明
《碼出高效》
Metaspace in Java 8
JVM機器指令集圖解:
Introduction to JVM Code Cache
《新程序員》:云原生和全面數字化實踐50位技術專家共同創作,文字、視頻、音頻交互閱讀總結
以上是生活随笔為你收集整理的图文并茂,傻瓜都能看懂的 JVM 内存布局的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 阿里P9架构师分享:通俗易懂Redis原
- 下一篇: 大白话 + 13 张图解 Kafka