內存是非常重要的系統資源,是硬盤和 CPU 的中間倉庫及橋梁,承載著操作系統和應用程序的實時運行。JVM 內存布局規定了 Java 在運行過程中內存申請、分配、管理的策略,保證了JVM 的高效穩定運行。不同的JVM 對于內存的劃分方式和管理機制存在著部分差異。結合JVM 虛擬機規范,來探討一下經典的JVM 內存布局,如下圖所示
1.Heap(堆區)
??Heap是OOM 故障最主要的發源地,它存儲著幾乎所有的實例對象,堆由垃圾收集器自動回收,堆區由各子線程共享使用。通常情況下,它占用的空間是所有內有區域中最大的,但如果無節制地創建大量對象,也容易消耗完所有的空間。堆的內存空間既可以固定大小,也可以在運行時動態地調整,通過如下參數設定初始值和最大值,比如-Xms256M-Xmx1024M,其中-X表示它是JVM運行參數,ms是memorystart的簡稱,mx是memory max 的簡稱,分別代表最小堆容量和最大堆容量。但是在通常情況下,服務器在運行過程中,堆空間不斷地擴容與回縮,勢必形成不必要的系統壓力,所以在線上生產環境中,JVM的Xms和Xmx設置成一樣大小,避免在GC 后調整堆大小時帶來的額外壓力。
??堆分成兩大塊:新生代和老年代。對象產生之初在新生代,步入暮年時進入老年代,但是老年代也接納在新生代無法容納的超大對象。新生代=1個 Eden 區 +2個Survivor 區。絕大部分對象在 Eden 區生成,當 Eden 區裝填滿的時候,會觸發 Young Garbage Collection,即YGC。垃圾回收的時候,在 Eden 區實現清除策略,沒有被引用的對象則直接回收。依然存活的對象會被移送到 Survivor 區,這個區真是名副其實的存在。Survivor 區分為 SO和 S1 兩塊內存空間,送到哪塊空間呢? 每次YGC的時候,它們將存活的對象復制到未使用的那塊空間,然后將當前正在使用的空間完全清除,交換兩塊空間的使用狀態。如果YGC 要移送的對象大于 Survivor 區容量的上限,則直接移交給老年代。假如一些沒有進取心的對象以為可以一直在新生代的Survivor 區交換來交換去,那就錯了。每個對象都有一個計數器,每次YGC都會加1。-XX:MaxTenuringThreshold 參數能配置計數器的值到達某個值的時候,對象從新生代晉升至老年代。如果該參數配置為 1,那么從新生代的 Eden 區直接移至老年代。默認值是 15,可以在 Survivor 區交換 14 次之后,晉升至老年代。與上圖 匹配的對象晉升流程圖如下圖 所示。
??上圖中,如果 Survivor區無法放下,或者超大對象的闕值超過上限,則嘗試在老年代中進行分配;如果老年代也無法放下,則會觸發Full Garbage Collection,即FGC。如果依然無法放下,則拋出OOM。堆內存出現OOM的概率是所有內存耗異常中最高的。出錯時的堆內信息對解決問題非常有幫助,所以給JVM設置運行參數XX:+HeapDumpOnOutOfMemoryError,讓JVM遇到OOM異常時能輸出堆內信息特別是對相隔數月才出現的OOM異常尤為重要。
??在不同的JVM 實現及不同的回收機制中,堆內存的劃分方式是不一樣的。
2.Metaspace(元數據區)
??本文章源碼解析和示例代碼基本采用JDK11版本,JVM 則為 Hotspot。早在JDK8版本中,元空間的前身Perm 區已經被淘汰。在JDK7及之前的版本中,只有Hotspo才有 Perm 區,譯為永久代,它在啟動時固定大小,很難進行調優,并且FGC 時會移動類元信息。在某些場景下,如果動態加載類過多,容易產生 Perm 區的OOM。比如某個實際 Web 工程中,因為功能點比較多,在運行過程中,要不斷動態加載很的類,經常出現致命錯誤:
'Exception in thread dubbo client x.x connector’java.lang.OutOfMemoryError: PermGenspace"
為了解決該問題,需要設定運行參數-XX:MaxPermSize=1280m,如果部署到新機器上,往往會因為JVM 參數沒有修改導致故障再現。不熟悉此應用的人排查問題時往往苦不堪言,除此之外,永久代在垃圾回收過程中還存在諸多問題。所以,JDK8使用元空間替換永久代。在JDK8及以上版本中,設定MaxPermSize參數,JVM 在啟動時并不會報錯,但是會提示: Java HotSpot 64Bit Server VM waming:ignoring option MaxPermSize-2560m; support was removed in 8.0.
??區別于永久代,元空間在本地內存中分配。在JDK8里,Perm 區中的所有內容中字符串常量移至堆內存,其他內容包括類元信息、字段、靜態屬性、方法、常量等都移動至元空間內,比如Object 類元信息、靜態屬性 System.out、整常量10000000等。在常量池中的String,其實際對象是被保存在堆內存中的。
3.JVM Stack ( 虛擬機棧)
??棧( Stack )是一個先進后出的數據結構,就像子彈的彈夾,最后壓入的子彈先發射壓在底部的子彈最后發射,撞針只能訪問位于頂部的那一顆子彈。
??相對于基于寄存器的運行環境來說,JVM 是基于棧結構的運行環境。棧結構移植性更好,可控性更強。JVM 中的虛擬機是描述 Java 方法執行的內存區域,它是線程私有的。棧中的元素用于支持虛擬機進行方法調用,每個方法從開始調用到執行完成的過程,就是棧幀從入棧到出棧的過程。在活動線程中,只有位于棧頂的賴才是有效的,稱為當前棧幀。正在執行的方法稱為當前方法,棧幀是方法運行的基本結構。在執行引擎運行時,所有指令都只能針對當前棧進行操作。而 StackOverflowError表示請求的棧溢出,導致內存耗盡,通常出現在遞歸方法中。JVM 能夠橫掃千軍,慮擬機棧就是它的心腹大將,當前方法的棧幀,都是正在戰斗的戰場,其中的操作棧是參與戰斗的士兵。操作棧的壓棧與出棧如下圖所示。
??虛擬機棧通過壓棧和出棧的方式,對每個方法對應的活動棧幀進行運算處理,方法正常執行結束,肯定會跳轉到另一個棧幀上。在執行的過程中,如果出現異常,進行異常回溯,返回地址通過異常處理表確定。棧幀在整個JVM 體系中的地位頗高包括局部變量表、操作棧、動態連接、方法返回地址等。
- 局部變量表
??局部變量表是存放方法參數和局部變量的區域。相對于類屬性變量的準備階段和初始化階段來說,局部變量沒有準備階段,必須顯式初始化。如果是非靜態方法,在index[0]位置上存儲的是方法所屬對象的實例引用,隨后存儲的是參數和局部變量。字節碼指令中的 STORE 指令就是將操作棧中計算完成的局部變量寫回局部變量表存儲空間內。 - 操作棧
??操作棧是一個初始狀態為空的桶式結構棧。在方法執行過程中,會有各種指令往棧中寫入和提取信息。JVM 的執行引擎是基于棧的執行引擎,其中的棧指的就是操作棧。字節碼指令集的定義都是基于棧類型的,棧的深度在方法元信息的 stack屬性中下面用一段簡單的代碼說明操作棧與局部變量表的交互:
public int simpleMethod() {
int x = 13;
int y = 14;
int z = x + y;
return z;
}
詳細的字節碼操作順序如下(Intellij IDEA 中查看字節碼 View -> Show bytecode):
// access flags 0x1
public simpleMethod()I
L0
LINENUMBER 25 L0
BIPUSH 13 // 常量13壓入操作棧
ISTORE 1 // 并保存到局部凌量表的 slot 1中 (第1處)
L1
LINENUMBER 26 L1
BIPUSH 14 //常量14壓入操作找,注意是BIPUSH
ISTORE 2 //并保存到局部變量表的sIot 2中
L2
LINENUMBER 27 L2
ILOAD 1 //把局部變量表的 slot 1尤素 (int x)壓入操作棧
ILOAD 2 // 把局部變量表的 slot 2 元素 (int y)壓入操作棧
IADD //把上方的兩個數都取出來,在 CPU 里加一下,并壓回操作棧的棧頂
ISTORE 3 //把棧頂的結果存儲到局部變量表的 slot 3 中
L3
LINENUMBER 28 L3
ILOAD 3
IRETURN //返回棧頂元素值
L4
LOCALVARIABLE this Lcom/linkmiao/iot/demo/test/d202311/TestWhoLoad; L0 L4 0
LOCALVARIABLE x I L1 L4 1
LOCALVARIABLE y I L2 L4 2
LOCALVARIABLE z I L3 L4 3
MAXSTACK = 2 // // 最大 深度為 2,局部變量個數為4
MAXLOCALS = 4
第1處說明:局部變量表就像一個中藥柜,里面有很多抽展,依次編號為 0,1,2.3,·,n,字節碼指令ISTORE1就是打開1號抽屜,把棧頂中的數13存進去。棧是一個很深的豎桶,任何時候只能對桶口元素進行操作,所以數據只能在棧頂進行存取。某些指令可以直接在抽屜里進行,比如 iinc 指令,直接對抽屜里的數值進行 +1操作。
- 動態連接
每個棧幀中包含一個在常量池中對當前方法的引用,目的是支持方法調用過程的動態連接。 - 方法返回地址
方法執行時有兩種退出情況:第一,正常退出,即正常執行到任何方法的返回字節碼指令,如RETURN、IRETURN、ARETURN等:第二,異常退出。無論何種退出情況,都將返回至方法當前被調用的位置。方法退出的過程相當于彈出當前棧幀,退出可能有三種方式
- 返回值壓入上層調用棧幀。
- 異常信息拋給能夠處理的棧幀。
- PC計數器指向方法調用后的下一條指令。
4.Native Method Slacks(本地方法棧)
??本地方法棧(Native Method Stack)在JVM 內存布局中,也是線程對象私有的,但是虛擬機棧“主內”,而本地方法棧“主外”。這個“內外”是針對JVM 來說的,本地方法棧為 Native方法服務。線程開始調用本地方法時,會進入一個不再受JVM約束的世界。本地方法可以通過JNI (Java Native Interface)來訪問虛擬機運行時的數據區,甚至可以調用寄存器,具有和JVM相同的能力和權限。當大量本地方法現時,勢必會削弱JVM 對系統的控制力,因為它的出錯信息都比較黑盒。對于內有不足的情況,本地方法棧還是會拋出native heap OutOfMemory。
??重點說一下JNI類本地方法,最著名的本地方法應該是Systen.currentTimeMillis(),JNI使 Java 深度使用操作系統的特性功能,復用非 Java代碼。但是在項自過程中,如果大量使用其他語言來實現JNI,就會喪失跨平臺特性,威脅到程序運行的穩定性。假如需要與本地代碼交互,就可以用中間標準框架進行解耦.這樣即使本地方法崩潰也不至于影響到JVM的穩定。當然,如果要求極高的執行效率偏底層的跨進程操作等,可以考慮設計為JNI調用方式。
5.Program Counter Register ( 程序計數寄存器)
??在程序計數寄存器(Program Counter Register,PC)中,Register 的命名源于CPU的寄存器,CPU只有把數據裝載到寄存器才能夠運行。寄存器存儲指令相關的現場信息,由于CPU時間片輪限制,眾多線程在并發執行過程中,任何一個確定的時刻,一個處理器或者多核處理器中的一個內核,只會執行某個線程中的一條指令。這樣必然導致經常中斷或恢復,如何保證分毫無差呢?每個線程在創建后,都會產生自己的程序計數器和棧幀,程序計數器用來存放執行指令的偏移量和行號指示器等,線程執行或恢復都要依賴程序計數器。程序計數器在各個線程之間互不影響,此區域也不會發生內存溢出異常。
??最后,從線程共享的角度來看,堆和元空間是所有線程共享的,而虛擬機棧、本地方法棧、程序計數器是線程內部私有的,從這個角度看一下Java內存結構,如下圖所示。