深入理解Java虛擬機器之JVM記憶體佈局篇

追風少年瀟歌發表於2021-10-12

記憶體佈局

​ JVM記憶體佈局規定了Java在執行過程中記憶體申請、分配、管理的策略,保證了JVM的穩定高效執行。不同的JVM對於記憶體的劃分方式和管理機制存在部分差異。結合JVM虛擬機器規範,一起來探討jVM的記憶體佈局。如下圖所示:

Heap 堆區

Heap堆區是Java發生OOM(Out Of Memory)故障的地方,堆中儲存著我們平時建立的例項物件,最終這些不再使用的物件會被垃圾收集器回收掉,而且堆是執行緒共享的。一般情況下,堆所佔用的記憶體空間是JVM記憶體區域中最大的,我們在平時編碼中,建立物件如果不加以剋制,記憶體空間也會被耗盡。堆的記憶體空間是可以自定義大小的,同時也支援在執行時動態修改,通過 -Xms-Xmx 這兩引數去改變堆的初始值最大值-X指的是JVM執行引數,ms 是memory start的簡稱,代表的是最小堆容量mx是memory max的簡稱,代表的是最大堆容量;如 -Xms256M代表堆的初始值是256M,-Xmx1024M代表堆的最大值是1024M。由於堆的記憶體空間是可以動態調整的,所以在伺服器執行的時候,請求流量的不確定性可能會導致我們堆的記憶體空間不斷調整,會增加伺服器的壓力,所以我們一般都會將JVM的XmsXmx的值設定成一樣,同樣也為了避免在GC(垃圾回收)之後調整堆大小時帶來的額外壓力。

​ 堆區分為兩大區:Young區和Old區,又稱新生代老年代。物件剛建立的時候,會被建立在新生代到一定階段之後會移送至老年代,如果建立了一個新生代無法容納的新物件,那麼這個新物件也可以建立到老年代。如上圖所示。新生代分為1個Eden區和2個S區,S代表Survivor。大部分的物件會在Eden區中生成,當Eden區沒有足夠的空間容納新物件時,會觸發Young Garbage Collection,即YGC。在Eden區進行垃圾清除時,它的策略是會把沒有引用的物件直接給回收掉,還有引用的物件會被移送到Survivor區。Survivor區有S0S1兩個記憶體空間,每次進行YGC的時候,會將存活的物件複製到未使用的那塊記憶體空間,然後將當前正在使用的空間完全清除掉,再交換兩個空間的使用狀況。如果YGC要移送的物件Survivor區無法容納,那麼就會將該物件直接移交給老年代。上面說了,到一定階段的物件會移送到老年區,這是什麼意思呢?每一個物件都有一個計數器,當每次進行YGC的時候,都會 +1。通過-XX:MAXTenuringThrehold引數可以配置當計數器的值到達某個閾值時,物件就會從新生代移送至老年代。該引數的預設值為15,也就是說物件在Survivor區中的S0和S1記憶體空間交換的次數累加到15次之後,就會移送至老年代。如果引數配置為1,那麼建立的物件就會直接移送至老年代。具體的物件分配即回收流程可觀看下圖所示。

如果Survivor區無法放下,或者建立了一個超大新物件,EdenOld區都無法存放,就會觸發Full Garbage Collection,即FGG,便再嘗試放在Old區,如果還是容納不了,就會丟擲OOM異常。在不同的JVM實現及不同的回收機制中,堆記憶體的劃分方式是不一樣的。

Metaspace 元空間

​ 在JDK8版本中,元空間的前身Pern區已經被淘汰。在JDK7及之前的版本中,Hotspot還有Pern區,翻譯為永久代,在啟動時就已經確定了大小,難以進行調優,並且只有FGC時會移動類元資訊。不同於之前版本的Pern(永久代),JDK8的元空間已經在本地記憶體中進行分配,並且,Pern區中的所有內容中字串常量移至堆記憶體,其他內容也包括了類元資訊欄位靜態屬性方法常量等等都移至元空間內。

JVM Stacks 虛擬機器棧

​ 棧(Stack)是一個先進後出的資料結構,先進後出怎麼理解?類似於我們平時打羽毛球時,裝羽毛球的球筒,第一個先放進去的往往最後一個才能拿出來,最後放進去的一個最先拿出來。

​ 相對於基於暫存器的執行環境來說,JVM是基於棧結構的執行環境。因為棧結構移植性更好,可控性更強。JVM的虛擬機器棧是描述Java方法執行的記憶體區域,並且是執行緒私有的。棧中的元素用於支援虛擬機器進行方法呼叫,每個方法從開始呼叫到執行完成的過程,就是棧幀從入幀到出幀的過程。在活動執行緒中,只有位於棧頂的幀才是有效的,稱為當前棧幀。正在執行的方法稱為當前方法,棧幀是方法執行的基本結構。在執行引擎執行時,所有指令都只能針對當前棧幀進行操作。而StackOverflowError表示請求的棧溢位,導致記憶體耗盡,通常出現在遞迴方法中。如果把JVM當做一個棋盤,虛擬機器棧就是棋盤上的將/帥,當前方法的棧幀就是棋子能走的區域,而操作棧就是每一個棋子。操作棧的壓棧和出棧如下圖所示:

​ 虛擬機器棧通過壓棧出棧的方式,對每個方法對應的活動棧幀進行運算處理,方法正常執行結束,肯定會跳轉到另外一個棧幀上。在執行的過程中,如果出現異常,會進行異常回溯,返回地址通過異常處理表確定。棧幀在整個JVM體系中的地位頗高,包括區域性變數表操作棧動態連線方法返回地址等。

​ 下面對棧幀的各個活動棧幀進行簡要的分析

​ (1)區域性變數表

​ 區域性變數表是存放方法引數區域性變數的區域。我們都知道,類屬性變數一共要經歷兩個階段,分為準備階段初始化階段,而區域性變數是沒有準備階段,只有初始化階段,而且必須是顯示的。如果是非靜態方法,則在index[0]位置上儲存的是方法所屬物件的例項引用,隨後儲存的是引數區域性變數。位元組碼指令中的STORE指令就是將操作棧中計算完成的區域性變數寫回區域性變數表的儲存空間內

​ (2)操作棧

​ 操作棧是一個初始狀態為空的桶式結構棧。在方法執行過程中,會有各種指令往棧中寫入和提取資訊。JVM的執行引擎是基於棧的執行引擎,其中的棧指的就是操作棧。位元組碼指令集的定義都是基於棧型別的,棧的深度在方法元資訊的stack屬性中,下面就通過一個例子來說明下操作棧與區域性變數表的互動:

public int add() {
    int x = 10;
    int y = 20;
    int z = x + y;
    
    return z;
}

位元組碼操作順序如下:

public int add();
  Code:
     0: bipush        10	//	常量 10 壓入操作棧
     2: istore_1		   //	並儲存到區域性變數表的 slot_1 中  (第 1 處)
     3: bipush        20	//	常量 20 壓入操作棧
     5: istore_2		   //	並儲存到區域性變數表的 slot_2 中 
     6: iload_1			   //	把區域性變數表的 slot_1 元素(int x)壓入操作棧
     7: iload_2			   //	把區域性變數表的 slot_2 元素(int y)壓入操作棧
     8: iadd			   //	把上方的兩個數都取出來,在 CPU 里加一下,並壓回操作棧的棧頂
     9: istore_3		   //	把棧頂的結果儲存到區域性變數表的 slot_3 中
    10: iload_3
    11: ireturn			   //	返回棧頂元素值

​ 第 1 處說明:區域性變數表就像一個快遞櫃,有著很多的櫃子,依次編號為1,2,3,...,n,位元組碼指令 istore_1 就代表開啟了 1 號櫃子,再把棧頂中的值 10 存進去。棧就好如一個桶,任何時候只能對桶口的元素進行操作,所以資料只能在棧頂進行存取。部分指令可以直接在櫃子裡面直接進行,比如 iinc指令,直接對抽屜裡的數值進行 +1操作。我們經常遇到的 i++ 和 ++i,通過位元組碼對比起來,答案一下子就一目瞭然了。如下表格所示:

​ 左列中,iload_1 從區域性變數表的第1號櫃子取出一個數,壓入棧頂,下一步直接在櫃子裡實現 + 1的操作,而這個操作時對棧頂元素的值沒有任何影響,所以 istore_2 只是把棧頂元素賦值給 a,而右列,它是先在櫃子裡面進行 +1的操作,然後再通過 iload_1 把第1號櫃子裡的數壓入棧頂,所以istore_2賦給a的值是 +1 之後的值。擴充套件下,i++ 並非是原子操作。即使通過volatile關鍵字來修飾,多執行緒情況下,還是會出現資料互相覆蓋的情況。

​ (3)動態連線

​ 每個棧幀中包含一個在常量池中對當前方法的引用,目的是支援方法呼叫過程的動態連線

​ (4)方法返回地址

​ 方法執行時有兩種退出情況:第一,正常退出,即正常執行到任何方法的返回位元組碼指令,如 RETURNIRETURNARETURN等;第二,異常退出。無論何種退出情況,都將返回方法當前被呼叫的位置。方法退出的過程相當於彈出當前棧幀,而退出可能有三種方式:

  • 返回值壓入上層呼叫棧幀。
  • 異常資訊拋給能夠處理的棧幀。
  • PC 計數器指向方法呼叫後的下一條指令。

Native Method Stacks(本地方法棧)

​ 本地方法棧(Native Method Stack)在JVM記憶體佈局中,也是執行緒物件私有的,但是虛擬機器棧“主內”,而本地方法棧“主外”。這個“內外”是針對JVM來說的,本地方法棧為Native方法服務。執行緒開始呼叫本地方法時,會進入一個不再受JVM約束的世界。本地方法可以通過JVNI(Java Native Interface)來訪問虛擬機器執行時的資料區,甚至可以呼叫暫存器,具有和JVM相同的能力和許可權。當大量本地方法出現時,勢必會削弱JVM對系統的控制力,因為它的出錯資訊都比較黑盒,難以捉摸。對於記憶體不足的情況,本地方法棧還是會丟擲 native heap OutOfMemory

​ 重點說下JNI類本地方法,最常用的本地方法應該是System.currentTimeMills()JNI使Java深度使用作業系統的特性功能,複用非Java程式碼。但是在專案過程中,如果大量使用其他語言來實現JNI,就會喪失跨平臺特性,威脅到程式執行的穩定性。假如需要與原生程式碼互動,就可以用中間標準框架來進行解耦,這樣即使本地方法崩潰也不至於影響到JVM的穩定。

Program Counter Register (程式計數暫存器)

​ 在程式計數暫存器(Program Counter Register,PC)中,Register的命名源於CPU的暫存器,CPU只有把資料裝載到暫存器才能夠執行。暫存器儲存指令相關的現場資訊,由於CPU時間片輪限制,眾多執行緒在併發執行過程中,任何一個確定的時刻,一個處理器或者多核處理器中的一個核心,只會執行某個執行緒中的一個指令。這樣必然會導致經常中斷或恢復,如何才能保證分毫無差呢?每個執行緒在建立之後,都會產生自己的程式計數器棧幀程式計數器用來存放執行指令的偏移量和行號指示器等,執行緒執行或恢復都要依賴程式計數器程式計數器在各個執行緒之間互不影響,此區域也不會發生記憶體溢位異常

小結

最後,從執行緒的角度來看,堆和元空間是所有執行緒共享的,而虛擬機器棧、本地方法棧、程式計數器是執行緒內部私有的,我們以執行緒的角度再來看看Java的記憶體結構圖:

參考自《碼出高效》

相關文章