內存布局
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的Xms
和Xmx
的值設置成一樣,同樣也為了避免在GC
(垃圾回收)之后調整堆大小時帶來的額外壓力。
堆區分為兩大區:Young
區和Old
區,又稱新生代
和老年代
。對象剛創建的時候,會被創建在新生代
,到一定階段之后會移送至老年代,如果創建了一個新生代無法容納的新對象,那么這個新對象也可以創建到老年代。如上圖所示。新生代
分為1個Eden
區和2個S區
,S代表Survivor
。大部分的對象會在Eden區
中生成,當Eden區沒有足夠的空間容納新對象時,會觸發Young Garbage Collection,即YGC
。在Eden區進行垃圾清除時,它的策略是會把沒有引用的對象直接給回收掉,還有引用的對象會被移送到Survivor區
。Survivor區有S0
和S1
兩個內存空間,每次進行YGC的時候,會將存活的對象復制到未使用的那塊內存空間,然后將當前正在使用的空間完全清除掉,再交換兩個空間的使用狀況
。如果YGC要移送的對象Survivor區無法容納,那么就會將該對象直接移交給老年代。上面說了,到一定階段
的對象會移送到老年區,這是什么意思呢?每一個對象都有一個計數器,當每次進行YGC的時候,都會 +1
。通過-XX:MAXTenuringThrehold
參數可以配置當計數器的值到達某個閾值時,對象就會從新生代移送至老年代。該參數的默認值為15,也就是說對象在Survivor區中的S0和S1內存空間交換的次數累加到15次之后,就會移送至老年代
。如果參數配置為1,那么創建的對象就會直接移送至老年代。具體的對象分配即回收流程可觀看下圖所示。
如果Survivor
區無法放下,或者創建了一個超大新對象,Eden
和Old
區都無法存放,就會觸發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)方法返回地址
方法執行時有兩種退出情況:第一,正常退出,即正常執行到任何方法的返回字節碼指令
,如 RETURN
、IRETURN
、ARETURN
等;第二,異常退出。無論何種退出情況,都將返回方法當前被調用的位置
。方法退出的過程相當於彈出當前棧幀,而退出可能有三種方式:
- 返回值壓入上層調用棧幀。
- 異常信息拋給能夠處理的棧幀。
- 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的內存結構圖:
參考自《碼出高效》