文章目錄
-
- 1. 概述
- 2. 程式計數器
- 3. Java 虛拟機棧
-
- 3.1. 棧深度
- 3.2. 棧幀
-
- 3.2.1. 局部變量表
- 3.2.2. 操作數棧
- 3.2.3. 動态連結
- 3.2.4. 方法正常結束
- 3.2.5. 方法異常結束
- 4. 堆
- 5. 方法區
-
- 5.1. 去永久代過程
- 6. 運作時常量池
- 7. 本地方法棧
- 8. 參考資料
1. 概述
JVM 把記憶體進行了劃分,不同的記憶體區域有不同的功能。有的記憶體區域是線程私有的,比如 Java 虛拟機棧、本地方法棧和程式計數器,每一條線程都有自己獨立的空間。有的記憶體區域是線程共享的,比如方法區和堆。
是以不同記憶體區域的功能、作用域和生命周期是不同的。本文做一個詳細的分析。
根據 JVM 虛拟機規範,記憶體結構如下:
JVM 虛拟機規範屬于概念模型,具體的實作各個廠商的會有所差異。比如方法區的設計,hotspot 在 1.7 之前使用永久代,1.7 後使用元空間。
本文主要分析 HotSpot 虛拟機的實作。
2. 程式計數器
JVM 支援多線程,采用時間片輪轉的方式實作多線程并發。一個核心每一刻隻能有一個線程執行,多線程下需要線程上下文切換。為了確定切換過程中,不同的線程指令和資料不會發生混亂,需要單獨開辟記憶體空間給每個線程,進行線程隔離。這些區域包含了程式計數器、虛拟機棧、本地方法棧。這些都是線程私有記憶體,生命周期和線程一緻。
如果執行的不是本地方法,程式計數器記錄目前線程執行的指令位址,位元組碼解釋器通過改變該計數器的值,來決定選取下一個要執行的指令。如果執行的是本地方法,值為空(undefined)。
程式計數器的記憶體空間非常小,是 JVM 規定的唯一不會發生記憶體溢出(Out Of Memory)的區域。
3. Java 虛拟機棧
Java 虛拟機棧由棧幀組成,Java 虛拟機棧和其他正常語言的棧類似,存儲本地變量或部分計算結果,處理方法的調用和傳回。虛拟機棧内容不能進行直接操作,隻能用來進行棧幀的入棧和出棧。方法的調用到執行完成對應的就是棧幀的入棧和出棧過程。
Java 虛拟機棧的生命周期和線程對應,線上程建立的同時建立,和程式計數器一樣都是線程私有記憶體區域。
Java 虛拟機規範對虛拟機棧大小有這樣的描述:
- 可以使用固定大小或者動态擴充和收縮。如果是固定大小,空間大小在棧建立的時候就會确定下來。
- 可以配置 Java 虛拟機棧的初始大小。
- 如果棧空間可以動态擴充或者收縮,可以配置棧的最大值和最小值。
HotSpot 虛拟機棧的配置:
- -Xss,設定虛拟機棧大小,JDK1.5 之後預設為 1M。棧深度受到這個堆棧大小的限制。在固定實體記憶體下減小 Java 虛拟機棧大小可以産生更多線程,但是一個程序的線程數量有限制,不能無限增加。
Java 虛拟機棧可能會發生的異常有:
- 如果線程請求需要的棧深度大于 JVM 限定的,會發生
異常。StackOverflowError
- 如果 JVM 大小可以動态擴充,在擴充的時候記憶體不足,或者在建立新線程時記憶體不夠建立虛拟機棧,均會發生
異常。OutOfMemoryError
3.1. 棧深度
方法的從調用到執行完成,對應了虛拟機棧的入棧到出棧的過程。
在編譯期就可以确認局部變量表的大小和操作數棧的深度,并且寫入到方法表的 code 屬性中,運作期間不會發生改變。是以在編譯器每個棧幀的需要大小就可以确定了。棧深度由運作期決定。
具體的棧深度受虛拟機棧大小和棧幀大小的影響,要看使用了多少棧幀,棧幀大小多少。每個棧幀的大小不一定一樣,取決于各棧幀對應方法的局部變量表和操作數棧大小等。
假設我們的虛拟機棧大小固定,棧幀數量達到最大值,也就是達到最大深度,深度大小和棧幀大小的示意圖如下:
上面的示意圖可以看出,在 Java 虛拟機棧大小固定的情況下,如果每個棧幀都很大,最大可用深度就會變小。
上面隻是一個示意圖,實際上虛拟機棧深度沒這麼小。預設情況下 Java 虛拟機棧有 1M,平時開發時的棧幀也不會很大。
當線程請求的棧深度大于虛拟機的所允許的棧深度會發生
StackOverflowError
異常。畢竟如果一個線程不斷地往虛拟機棧中加入棧幀,會消耗掉大量的記憶體,影響到其他線程的執行。
比如寫了一個遞歸方法,沒有設定退出條件,當要超過該線程的虛拟機棧達到最大深度會發生異常。
3.2. 棧幀
棧幀用來存儲方法執行需要用到的資料。同時還可以執行動态連結,傳回值給方法,分發異常。是以一個棧幀一般會劃分成以下幾個區域:局部變量表、操作數棧、動态連結、方法出口。
棧幀的生命周期和方法對應,在方法調用的時候就會建立新的棧幀,當方法執行結束時棧幀銷毀棧幀。即使是因為未捕獲異常退出方法,棧幀也會被銷毀。棧幀的記憶體由 JVM 虛拟機棧配置設定。每個棧幀有自己獨立的局部變量表、操作數棧、指向運作時常量池的引用。
棧幀的内容可擴充,比如加入調試資訊。
在編譯期就可以根據棧幀對應的方法代碼,确定局部變量表和操作數棧的大小。棧幀的具體大小依賴于 JVM 虛拟機的實作。編譯期決定了大小,方法被調用時配置設定記憶體。
線程在同一時刻隻會處理一個棧幀,被稱為目前幀,位于 Java 虛拟棧的棧頂。該幀對應的方法被稱為目前方法,定義該方法的類被稱為目前類。方法的執行會操作目前幀的局部變量表和操作數棧。
調用新方法時,目前幀暫停,新的棧幀加入到虛拟機棧的棧頂并成為新的目前幀,開始處理新方法。當方法結束調用,目前幀出棧,傳回處理結果,回到上一個棧幀,上一個棧幀成為目前幀,繼續操作局部變量表和操作數棧。
棧幀屬于目前線程私有,不會被其他線程引用到。
3.2.1. 局部變量表
每一個棧幀都會有一個局部變量表,大小在編譯期就決定,用來記錄方法執行需要用到的請求參數、局部變量,如果不是靜态方法的話,還會存儲
this
指針來表示目前對象執行個體。
局部變量的存儲基本機關為 變量槽(Variable Slot)。單個 Slot 可以存儲 boolean,byte,char,short,int,float,reference 或者 returnAddress。兩個 Slot 可以存儲 long 和 double。虛拟機規範沒有對 Slot 的實體記憶體大小做出明确規定,可以随着處理器、作業系統和虛拟機的不同而變化。但因為 int、float 等都可以用 32 位的實體記憶體存放,是以一個 Slot 的實體記憶體必須大于 32 位。
局部變量表采用 索引 進行尋址。第一個局部變量的索引為 0。在執行個體方法中,始終使用局部變量 0 用來表示目前對象執行個體,在 Java 中就是 this 指針。是以執行個體方法的局部變量的索引總是從 1 開始。
long 和 double 比較特殊,需要使用兩個連續的 Slot 存儲。這樣會占用兩個索引,取值小的那個。比如一個 double 存入局部變量表,它的索引值是 n,其實占用了 n 和 n+1 兩個索引,而 n+1索引是無法加載的。下一個局部變量的索引為 n+2。虛拟機規範并沒有要求 n 一定是偶數,是以在在局部變量表中 long 和 double 并不一定是要 64 位對齊的。不同 JVM 的實作,可以選擇合适的方式實作兩個局部變量存儲 long 和 double。
這裡做個實驗,建立一個空方法,請求參數包含所有基礎資料類型和一個 String 引用類型,方法内有一個 String 局部變量。
public void show(boolean a, byte b, char c, short d, int e, long f, float h, double i, String j) {
String str = "str";
}
使用
javap -v
檢視
show
方法在 class 檔案中的局部變量表。
public void show(boolean, byte, char, short, int, long, float, double, java.lang.String);
descriptor: (ZBCSIJFDLjava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=1, locals=13, args_size=10
0: ldc #2 // String str
2: astore 12
4: return
LineNumberTable:
line 14: 0
line 15: 4
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Loblee/demo/jvm/stack/SimpleObject;
0 5 1 a Z
0 5 2 b B
0 5 3 c C
0 5 4 d S
0 5 5 e I
0 5 6 f J
0 5 8 h F
0 5 9 i D
0 5 11 j Ljava/lang/String;
4 1 12 str Ljava/lang/String;
這個方法的為局部變量表
LocalVariableTable
,類加載後會作為方法的中繼資料存儲到方法區,然後方法被調用的時候載入到新建立的棧幀中。
可以看到編譯期已經确認了表中每個局部變量的索引和大小。局部變量表的大小已經寫入到
Code
屬性:
locals=13
。
這 13 個基本機關是如何計算出來的?我們上面的案例,所有方法參數一共需要的基本機關數
1 + 1 + 1 + 1 + 1 + 2 + 1 + 2 + 1 = 11
,一個局部變量 str 占用 1 個 Slot,有 12 個基本機關了。還有一個 Slot 呢?
這個是執行個體方法,加入了 this 指針用來表示目前對象執行個體的引用,在 Slot 0 中:
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Loblee/demo/jvm/stack/SimpleObject;
this 指針占用 1 個 Slot,是以局部變量表總體大小為 13 個 Slot。
因為 this 指針是通過參數預設傳遞給方法的,應該歸到方法參數中,是以實際該方法有 10 個參數,也寫入到了 code 屬性:
args_size=10
。
從反編譯的局部變量表還可以看到索引的設計,
show
中參數 f 為 long 類型,索引到 Slot 6,因為占用兩個 Slot,下一個變量 h 索引到 Slot 8。
JVM 對局部變量表進行了優化,變量槽 Slot 是可以複用的。
如果是靜态方法的話就不存在 this 引用了。比如我們建立一個靜态方法
staticShow
:
public static void staticShow(boolean a, byte b, char c) {
String str = "str";
}
使用
javap -v
檢視局部變量表如下:
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 a Z
0 8 1 b B
0 8 2 c C
3 5 3 str1 Ljava/lang/String;
7 1 4 str2 Ljava/lang/String;
3.2.2. 操作數棧
每一個棧幀都有一個後進先出(LIFO)的操作數棧。操作數棧應用于位元組碼執行引擎中,JVM 描述位元組碼執行引擎是基于 “棧” 的,指的就是操作數棧。
操作數棧的每個條目可以儲存 JVM 任何類型的值,long 和 double 占據深度的兩個機關,其他類型占據一個機關。操作數棧的最大深度由編譯期通過方法要執行的位元組碼計算出來,并記錄在 Code 屬性中。
棧幀剛建立時,操作數棧為空。JVM 提供了一系列位元組碼指令,将資料從局部變量表加載到操作數棧中。還有一些指令,從操作數棧中讀取操作數,進行處理,然後把結果入棧。操作數棧還可以用來準備參數傳遞給方法,或者接收方法傳回結果。比如,指令
iadd
用來對兩個 int 值進行相加。之前的指令已經将兩個 int 值壓入到操作數棧中了,
iadd
将兩個 int 值出棧,相加後将和入棧。
操作數棧中的資料,必須用合适的類型的位元組碼指令進行操作。比如入棧兩個 int 值,不能當做 long 處理。入棧 float 不能使用
iadd
指令進行相加。有少量的 JVM 指令不關心值的類型,這些指令無法修改值。在類加載流程中,類檔案的校驗階段,會強制實施。
設計了一個
calculate
方法來做一些加減法計算:
public int calculate(int a, int b) {
int c = a + b;
int d = a - b;
int e = c + d;
return e;
}
反編譯得到:
public int calculate(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=6, args_size=3
0: iload_1
1: iload_2
2: iadd
3: istore_3
4: iload_1
5: iload_2
6: isub
7: istore 4
9: iload_3
10: iload 4
12: iadd
13: istore 5
15: iload 5
17: ireturn
可以看到操作數棧深度最大為 2,本地變量表大小 6 個 Slot(索引 0 - 5)。這些位元組碼的解讀如下:
0: iload_1 加載 Slot 1(從局部變量表加載,1 表示索引)。實際為從局部變量表加載 a。
1: iload_2 加載 Slot 2。實際為從局部變量表加載 a。
2: iadd 執行加法。實際為 a + b。
3: istore_3 存儲計算結果到 Slot 3。實際為存儲 c 到局部變量表。
4: iload_1 加載 Slot 1。實際為從局部變量表加載 a。
5: iload_2 加載 Slot 2。實際為從局部變量表加載 b。
6: isub 執行減法。實際為 a - b。
7: istore 4 存儲計算結果到 Slot 4。實際為存儲 d 到局部變量表。
9: iload_3 加載 Slot 3。實際為從局部變量表加載 c。
10: iload 4 加載 Slot 4。實際為從局部變量表加載 d。
12: iadd 執行加法。實際為 c + d。
13: istore 5 存儲計算結果到 Slot 5。實際為存儲 e 到局部變量表。
15: iload 5 加載 Slot 5 的資料。實際為從局部變量表加載 e。
17: ireturn 傳回計算結果
我們傳入
a = 1, b = 2
進行計算
calculate(1, 2)
,第一個加法操作操作數棧的變化如下:
這裡的代碼是可以優化的,因為局部變量 e 沒有做其他計算,可以直接傳回。如果直接傳回結果會有什麼效果?代碼如下:
public int calculate(int a, int b) {
int c = a + b;
int d = a - b;
return c + d;
}
檢視位元組碼如下:
public int calculate(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=5, args_size=3
0: iload_1
1: iload_2
2: iadd
3: istore_3
4: iload_1
5: iload_2
6: isub
7: istore 4
9: iload_3
10: iload 4
12: iadd
13: ireturn
局部變量表少了一個 Slot,也就是原本 e 的存儲空間。要執行的位元組碼指令也少了 3 條。是以平時開發過程中要注意優化,可以提高性能。
3.2.3. 動态連結
每一個幀都包含了一個指向運作時常量池的引用,用來實作位元組碼中的 動态連結(Dynamic Linking)。類檔案中包含了一些字段和方法的符号引用。動态連結會将這些符号引用轉換成直接引用,比如在記憶體中的具體偏移位址。
如果對應的類還沒有被加載,會觸發該類的加載流程。
符号引用記錄在類常量池中,是一個由字面量組成的字元串,和具體位址無關。比如所有對象的類構造方法的符号引用為
java/lang/Object."<init>":()V
。編譯并不知道運作時的位址,是以用符号引用代替。
動态連結又稱動态綁定。除了該方式,還有種發生在類檔案加載過程中,這個這個階段就把符号引用轉換為直接引用,這樣的方式為饑餓方式或者靜态綁定。
靜态綁定和動态綁定都可以歸為是類加載機制中的 解析(Resolution) 的一部分。
可以看出類加載機制中的環節是有可能交叉進行的。比如解析可能發生在準備階段後,靜态綁定。也可能延遲到初始化後,在棧幀建立後進行動态綁定。
綁定隻發生一次,綁定後不再更改。
3.2.4. 方法正常結束
方法調用結束,沒有發生異常。這裡指直接傳回結果或者是顯式調用 throw 抛出異常。
被調用方法的結果需要傳遞給調用者方法。被調用的方法會執行和方法傳回相關的指令,這些指令和傳回值的類型對應。
目前棧會被複原為調用者方法的執行狀态,包括局部變量表和操作數棧的資料,程式計數器會跳過剛剛調用方法的指令指向下一條。被調用方法的傳回值被加入到操作數棧中,程式繼續運作。
3.2.5. 方法異常結束
方法内部發生了異常,而且沒有被捕獲,方法會被終止,并且沒有傳回值給調用者。
4. 堆
堆由 JVM 所有的線程共享,一般情況下是 JVM 記憶體區域中最大的一塊。按照 JVM 虛拟機規範,堆是一個用來存儲類對象執行個體或者數組的運作時資料區。
在 HopSpot 上,類對象執行個體不一定就是放在堆中,應用了 JIT(Just-In-Time) 技術,進行逃逸分析(Escape Analysis)和标量替換(Scalar Replacement)。符合條件的對象執行個體會在棧上配置設定。
JVM 啟動的時候堆就會建立。堆内對象執行個體不會顯式釋放,由自動記憶體管理系統,也就是垃圾收集器進行回收,是垃圾收集器主要管理區域。JVM 規範沒有說明垃圾收集器應該是怎樣的,具體由實作由 JVM 廠商來提供。
比如 HotSpot 虛拟機中,垃圾回收器采用分代回收算法,會将堆進行進一步細分,分為新生代和老生代。新生代還可細分為 Eden 、From Survivor 和 To Survivor。這實際上是為了能夠更好地服務于垃圾回收。HotSpot 在 JDK 1.7 中堆還有一個永久代,其實是 JVM 規範中方法區的實作,在 JDK1.8 移除。
HotSpot 的 JDK 1.7 堆圖示:
HopSpot 的 JDK 1.8 堆圖示,永久代(PermGen)被移除,使用元空間(Metaspace)存儲類資訊。
新生代和老年代的記憶體配置設定流程:
- 優先 Eden 配置設定,Eden 空間不足會觸發 Minor GC。
- Minor GC 後,Eden + S0 還存活的對象移動到 S1 中,清空 S0。
- S1 放不下,存活次數達到要求的對象移動到老年代。
- 大對象直接配置設定到老年代。
- 老年代記憶體不足會發生 Major GC
- 進行垃圾回收後,Eden 仍然沒有足夠的空間,抛出
異常。OutOfMemory
Java 虛拟機規範對堆大小有這樣的描述:
- 可以是固定大小,也可以動态的擴充和收縮。
- 堆的記憶體不一定要連續。(邏輯上連續)
- 可以配置本地方法棧初始大小,如果可動态擴充和收縮,可配置最大值和最小值。
主流虛拟機都是采用可動态擴充和收縮的方式實作的。堆記憶體實體上可以不連續,但是邏輯上需要連續。
HotPot 虛拟機的堆記憶體配置:
- -Xms,初始大小,預設實體記憶體的 1/64。
- -Xmx,最大記憶體,預設實體記憶體的 1/4。
- -Xmn,新生代大小,因為持久代的大小一般預設為 64M,在整個堆固定的情況下,增大新生代會相應地減少老年代的大小。官方推薦
- -XX:NewSize,新生代最小空間大小。
- -XX:MaxNewSize,新生代最大空間大小。
- -XX:NewRatio,新生代和老年代的比例,新生代和老年代的預設比例為 1:2。
- -XX:SurvivorRatio,Eden 和 Survivor 的比例,預設為 Eden:S0:S1 = 8:1:1,即 survivor = 1/10 新生代大小。
HotSpot 采用的就是動态擴充和收縮的方式,根據堆的空閑情況,當空閑大于 70%,會減少至 -Xms;空閑小于 40%,會增大到 -Xmx。是以伺服器如果配置 -Xms = -Xmx,可以避免堆自動擴充。
堆會發生的異常:
- 如果程式請求的堆記憶體大于 JVM 記憶體管理系統能提供的最大值,會抛出
異常。OutOfMemoryError
5. 方法區
方法區由 JVM 所有線程共享。方法區類似一個用來存儲編譯後的代碼的區域。主要用來存儲加載的類資訊,運作時常量池,類和方法的資料,即時編譯後的代碼等。
JVM 啟動的時候方法區就會建立。
根據 JVM 虛拟機規範,方法區邏輯上是堆的一部分,實作上可以選擇不進行垃圾回收,并且沒有要求方法區的位置等。是以在方法區的具體實作各個虛拟機又不同的方式。雖然 JVM 虛拟機規範把方法區邏輯上劃給了堆,為了和實際堆進行了區分,方法區還叫做 “非堆”。
Java 虛拟機規範對方法區大小的描述:
- 可以是固定大小,也可以動态的擴充和收縮。
- 方法區的記憶體不一定要連續。
- 使用者或者開發者能夠配置方法區初始大小,如果方法區可以動态擴充或收縮,需要提供方法區的最大值和最小值。
HotSpot 在 JDK1.7 中方法區記憶體大小配置:
- -XX:PermSize,最小可配置設定空間,初始配置設定空間。
- -XX:MaxPermSize,最大可配置設定空間,預設大小為 64M(64 位 JVM 預設為 85M)
在 JDK1.8 使用了元空間後,方法區的大小配置:
- -XX:MetaspaceSize,初始空間大小。
- -XX:MaxMetaspaceSize,最大空間大小,預設是沒有限制的。
方法區可能發生的異常:
- 如果方法區請求的記憶體無法被滿足,抛出
異常。OutOfMemoryError
5.1. 去永久代過程
HotSpot 虛拟機在 JDK1.7 采用永久代,在堆中配置設定記憶體。在 JDK1.8 後使用元空間,使用本地記憶體。
從 JDK1.7 開始 “去永久代”,JDK 1.7 将靜态變量、字元串常量池移動到堆記憶體中,JDK1.8 去掉永久代,将類資訊、即時編譯後的代碼等移動到了元空間。
之是以要進行去永久代,主要還是該方案存在很多問題,留下很多 bug。主要有:
- 字元串存在永久代,容易發生記憶體溢出。
- 類資訊比較難确定大小,永久代的大小難以指定,太小永久代容易 OOM,太大老年代容易 OOM。
- 永久代 GC 回收複雜,效率低。
6. 運作時常量池
運作時常量池是 class 檔案的常量池在運作時的表示。主要有字面量和符号引用。
要了解運作時常量池,我們得先了解 class 的常量池。
建立類 ObjectA 和 Object B,其中 ObjectA 如下:
public class ObjectA {
private ObjectB b;
public void setB(ObjectB b) {
this.b = b;
}
public ObjectB getB() {
return b;
}
}
編譯後使用
javap -v
檢視 class 檔案中的常量池如下。
運作時,在進行類加載時,類常量池會被載入到 JVM 方法區。
JVM 虛拟機規範沒有限制運作時常量池隻能放編譯期的常量,虛拟機的實作可以自行支援。比如 HotSpot 虛拟機, Java 調用
String.intern()
方法,可以在運作期把常量加入池中。
在 HotSpot JDK 1.7 之後,對常量池進行了優化:字元串常量池被放在了 JVM 堆中,運作時常量池的字面量也存在 JVM 堆中,而符号引用被移動到了本地記憶體。
以下的異常可能會發生:
- 當建立一個 class 或者 interface 時,如果運作時常量池構造需要的記憶體超過 JVM 所能提供的,抛出
異常。OutOfMemoryError
7. 本地方法棧
JVM 的實作可能需要使用 “C 棧” 去支援本地方法調用。有可能使用 C 之類的語言,實作 JVM 指令的解釋器,也會使用到本地方法棧。本地方法棧和 Java 虛拟機棧類似,隻是這裡提供的是本地方法服務。虛拟機規範沒有明确指出本地方法棧使用什麼語言、資料結構等,不同廠商的虛拟機又不同的實作。比如 HotSpot 虛拟機把本地方法棧和 Java 虛拟機棧合并了。
本地方法棧的生命周期線程對應,線程建立的時候建立。如果 JVM 不需要調用本地方法,可以不需要本地方法棧。
JVM 規範對本地方法棧大小的描述
- 可以使用固定大小,或者動态擴充和收縮。如果是固定大小,當棧被建立的時候能夠獨立選擇。
- 可以配置本地方法棧初始大小,如果可動态擴充和收縮,可配置最大值和最小值。
以下異常可能發生:
- 如果線程請求的棧深度大于系統規定的,報
。StackOverflowError
- 如果本地方法棧可以動态擴充,沒有足夠的記憶體擴充。或者建立新的線程沒有足夠的記憶體建立本地方法棧,抛出
異常。OutOfMemoryError
8. 參考資料
- Java Language and Virtual Machine Specifications
- 深入了解 Java 虛拟機(周志明)