特點
- Java 虛擬機棧(Java Virtual Machine Stacks)是線程私有的,生命周期隨著線程,線程啟動而產生,線程結束而消亡。
- Java 虛擬機棧描述的是 Java 方法執行的內存模型,用于存儲棧幀。線程啟動時會創建虛擬機棧,每個方法在執行時會在虛擬機棧中創建一個棧幀,用于存儲局部變量表、操作數棧、動態連接、方法返回地址、附加信息等信息。每個方法從調用到執行完成的過程,就對應著一個棧幀在虛擬機棧中的入棧(壓棧)到出棧(彈棧)的過程。
- Java 虛擬機棧使用的內存不需要保證是連續的。
- Java 虛擬機規范即允許 Java 虛擬機棧被實現成固定大小(
-Xss
),也允許通過計算結果動態來擴容和收縮大小。如果采用固定大小的 Java 虛擬機棧,那每個線程的 Java 虛擬機棧容量可以在線程創建的時候就已經確定。
Java 虛擬機棧會出現的異常
- 如果線程請求分配的棧容量超過了 Java 虛擬機棧允許的最大容量,Java 虛擬機將會拋出 StackOverflowError 異常。
- 如果 Java 虛擬機棧可以動態擴展,并且在嘗試擴展的時候無法申請到足夠的內存,或者在創建新的線程時沒有足夠的內存去創建對應的虛擬機棧,那 Java 虛擬機將拋出一個 OutOfMemoryError 異常。
Java 虛擬機棧執行過程
可以參考一下這篇文章:https://blog.csdn.net/azhegps/article/details/54092466
棧幀(Stack Frame)
- 棧幀存在于 Java 虛擬機棧中,是 Java 虛擬機棧中的單位元素,每個線程中調用同一個方法或者不同的方法,都會創建不同的棧幀(可以簡單理解為,一個線程調用一個方法創建一個棧幀),所以,調用的方法鏈越多,創建的棧幀越多(代表作:遞歸)。在 Running 的線程,只有當前棧幀有效(Java 虛擬機棧中棧頂的棧幀),與當前棧幀相關聯的方法稱為當前方法。每調用一個新的方法,被調用方法對應的棧幀就會被放到棧頂(入棧),也就是成為新的當前棧幀。當一個方法執行完成退出的時候,此方法對應的棧幀也相應銷毀(出棧)。
棧幀結構如圖:
棧幀結構
局部變量表(Local Variable Table)
- 每個棧幀中都包含一組稱為局部變量表的變量列表,用于存放方法參數和方法內部定義的局部變量。在 Java 程序編譯成 Class 文件時,在 Class 文件格式屬性表中 Code 屬性的 max_locals(局部變量表所需的存儲空間,單位是 Slot) 數據項中確定了需要分配的局部變量表的最大容量。
- 局部變量表的容量以變量槽(Variable Slot)為最小單位,不過 Java 虛擬機規范中并沒有明確規定每個 Slot 所占據的內存空間大小,只是有導向性地說明每個 Slot 都應該存放的8種類型: byte、short、int、float、char、boolean、reference(對象引用就是存到這個棧幀中的局部變量表里的,這里的引用指的是局部變量的對象引用,而不是成員變量的引用。成員變量的對象引用是存儲在 Java 堆(Heap)中)、returnAddress(虛擬機數據類型,Sun JDK 1.4.2版本之前使用
jsr/ret
指令用于進行異常處理,后續版本已廢棄這種實現方式,目前使用異常處理器表代替)類型的數據,這8種類型的數據,都可以使用32位或者更小的空間去存儲。Java 虛擬機規范允許 Slot 的長度可以隨著處理器、操作系統或者虛擬機的不同而發生變化。對于64位的數據類型,虛擬機會以高位在前的方式為其分配兩個連續的 Slot 空間。即 long 和 double 兩種類型。做法是將 long 和 double 類型速寫分割為32位讀寫的做法。不過由于局部變量表建立在線程的堆棧上,是線程的私有數據,無論讀寫兩個連續的 Slot 是否是原子操作,都不會引起數據安全問題。 - Java 虛擬機通過索引定位的方式使用局部變量表,索引值的范圍是從0開始到局部變量表最大的 Slot 數量。如果是32位數據類型的數據,索引 n 就表示使用第 n 個 Slot,如果是64位數據類型的變量,則說明要使用第 n 和第 n+1 兩個 Slot。
- 在方法執行過程中,Java 虛擬機是使用局部變量表完成參數值到參數變量列表的傳遞過程。如果是實例方法(非
static
方法),那么局部變量表中的第0位索引的 Slot 默認是用來傳遞方法所屬對象實例的引用,在方法中可以通過關鍵字this
來訪問這個隱含的參數。其余參數按照參數表的順序來排列,占用從1開始的局部變量 Slot,參數表分配完畢后,再根據方法體內部定義的變量順序和作用域分配其余的 Slot。 - 局部變量表中的 Slot 是可重用的,方法體中定義的變量,其作用域并不一定會覆蓋整個方法體,如果當前字節碼程序計數器的值已經超過了某個變量的作用域,那么這個變量相應的 Slot 就可以交給其他變量去使用,節省棧空間,但也有可能會影響到系統的垃圾收集行為。
- 局部變量無初始值(實例變量和類變量都會被賦予初始值),類變量有兩次賦初始值的過程,一次在準備階段,賦予系統初始值;另外一次在初始化階段,賦予開發者定義的值。因此即使在初始化階段開發者沒有為類變量賦值也沒有關系,類變量仍然具有一個確定的默認值。但局部變量就不一樣了,如果一個局部變量定義了但沒有賦初始值是不能使用的。
使用一段代碼說明一下局部變量表:
// java 代碼
public int test() {
int x = 0;
int y = 1;
return x + y;
}
// javac 編譯后的字節碼,使用 javap -v 查看
public int test();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: iconst_0
1: istore_1
2: iconst_1
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: ireturn
LineNumberTable:
line 7: 0
line 8: 2
line 9: 4
LocalVariableTable:
Start Length Slot Name Signature
0 8 0 this Lcom/alibaba/uc/TestClass;
2 6 1 x I
4 4 2 y I
對應上面的解釋說明,通過 LocalVariableTable 也可以看出來:
Code 屬性:
stack(int x(1個棧深度)+ int y(1個棧深度))=2, locals(this(1 Slot)+ int x(1 Slot)+ int y(1 Slot))=3, args_size(非 static 方法,this 隱含參數)=1
驗證 Slot 復用,運行以下代碼時,在 VM 參數中添加 -verbose:gc
:
public void test() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0; // 當這段代碼注釋掉時,System.gc() 執行后,也并不會回收這64MB內存。當這段代碼執行時,內存被回收了
System.gc();
}
局部變量表中的 Slot 是否還存在關于 placeholder 數組對象的引用。當 int a = 0;
不執行時,代碼雖然已經離開了 placeholder 的作用域,但是后續并沒有任何對局部變量表的讀寫操作,placeholder 原本所占用的 Slot 還沒有被其他變量所復用,所以 placeholder 作為 GC Roots(所有 Java 線程當前活躍的棧幀里指向 Java 堆里的對象的引用) 仍然是可達對象。當 int a = 0;
執行時,placeholder 的 Slot 被變量 a 復用,所以 GC 觸發時,placeholder 變成了不可達對象,即可被 GC 回收。
操作數棧(Operand Stack)
- 操作數棧是一個后入先出(Last In First Out)棧,方法的執行操作在操作數棧中完成,每一個字節碼指令往操作數棧進行寫入和提取的過程,就是入棧和出棧的過程。
- 同局部變量表一樣,操作數棧的最大深度也是Java 程序編譯成 Class 文件時被寫入到 Class 文件格式屬性表的 Code 屬性的 max_stacks 數據項中。
- 操作數棧的每一個元素可以是任意的 Java 數據類型,32位數據類型所占的棧容量為1,64位數據類型所占的棧容量為2,在方法執行的任何時候,操作數棧的深度都不會超過在 max_stacks 數據項中設定的最大值(指的是進入操作數棧的 “同一批操作” 的數據類型的棧容量的和)。
- 當一個方法剛剛執行的時候,這個方法的操作數棧是空的,在方法執行的過程中,通過一些字節碼指令從局部變量表或者對象實例字段中復制常量或者變量值到操作數棧中,也提供一些指令向操作數棧中寫入和提取值,及結果入棧,也用于存放調用方法需要的參數及接受方法返回的結果。例如,整數加法的字節碼指令
iadd
(使用iadd
指令時,相加的兩個元素也必須是 int 型) 在運行的時候將操作數棧中最接近棧頂的兩個 int 數值元素出棧相加,然后將相加結果入棧。
以下代碼會以什么形式進入操作數棧?
// java 代碼
public void test() {
byte a = 1;
short b = 1;
int c = 1;
long d = 1L;
float e = 1F;
double f = 1D;
char g = 'a';
boolean h = true;
}
// 字節碼指令
0: iconst_1 // 把 a 壓入操作數棧棧頂
1: istore_1 // 將棧頂的 a 存入局部變量表索引為1的 Slot
2: iconst_1 // 把 b 壓入操作數棧棧頂
3: istore_2 // 將棧頂的 b 存入局部變量表索引為2的 Slot
4: iconst_1 // 把 c 壓入操作數棧棧頂
5: istore_3 // 將棧頂的 c 存入局部變量表索引為3的 Slot
6: lconst_1 // 把 d 壓入操作數棧棧頂
7: lstore 4 // 將棧頂的 d 存入局部變量表索引為4的 Slot,由于 long 是64位,所以占2個 Slot
9: fconst_1 // 把 e 壓入操作數棧棧頂
10: fstore 6 // 將棧頂的 e 存入局部變量表索引為6的 Slot
12: dconst_1 // 把 f 壓入操作數棧棧頂
13: dstore 7 // 將棧頂的 f 存入局部變量表索引為4的 Slot,由于 double 是64位,所以占2個 Slot
15: bipush 97 // 把 g 壓入操作數棧棧頂
17: istore 9 // 將棧頂的 g 存入局部變量表索引為9的 Slot
19: iconst_1 // 把 h 壓入操作數棧棧頂
20: istore 10 // 將棧頂的 h 存入局部變量表索引為10的 Slot
從上面字節碼指令可以看出來,除了 long、double、float 類型使用的字節碼指令不是 iconst
和 istore
,其他類型都是使用這兩個字節碼指令操作,說明 byte、short、char、boolean 進入操作數棧時,都會被轉化成 int 型。
-
在概念模型中,兩個棧幀作為虛擬機棧的元素,是完全相互獨立的。但在大多虛擬機實現會做一些優化,令兩個棧幀出現一部分重疊。讓下面的棧幀的部分操作數棧與上面棧幀的部分局部變量表重疊在一起,這樣在進行方法調用時就可以共用一部分數據,無需進行額外的參數復制傳遞。
棧幀共享 Java 虛擬機的解釋執行引擎稱為 “基于棧的執行引擎”,其中所指的 “棧” 就是操作數棧。
動態連接(Dynamic Linking)
- 每個棧幀都包含一個指向運行時常量池(JVM 運行時數據區域)中該棧幀所屬性方法的引用,持有這個引用是為了支持方法調用過程中的動態連接。
- 在 Class 文件格式的常量池(存儲字面量和符號引用)中存有大量的符號引用(1.類的全限定名,2.字段名和屬性,3.方法名和屬性),字節碼中的方法調用指令就以常量池中指向方法的符號引用為參數。這些符號引用一部分會在類加載過程的解析階段的時候轉化為直接引用(指向目標的指針、相對偏移量或者是一個能夠直接定位到目標的句柄),這種轉化稱為靜態解析。另外一部分將在每一次的運行期期間轉化為直接引用,這部分稱為動態連接。
看看以下代碼的 Class 文件格式的常量池:
// java 代碼
public Test test() {
return new Test();
}
// 字節碼指令
Constant pool:
#1 = Methodref #4.#19 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#20 // com/alibaba/uc/Test.i:I
#3 = Class #21 // com/alibaba/uc/Test
#4 = Class #22 // java/lang/Object
#5 = Utf8 i
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/alibaba/uc/Test;
#14 = Utf8 test
#15 = Utf8 ()I
#16 = Utf8 <clinit>
#17 = Utf8 SourceFile
#18 = Utf8 Test.java
#19 = NameAndType #7:#8 // "<init>":()V
#20 = NameAndType #5:#6 // i:I
#21 = Utf8 com/alibaba/uc/Test
#22 = Utf8 java/lang/Object
public int test();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: getstatic #2 // Field i:I
3: areturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Lcom/alibaba/uc/Test;
從上面字節碼指令看出 0: getstatic #2 // Field i:I
這行字節碼指令指向 Constant pool 中的 #2,而 #2 中指向了 #3 和 #20 為符號引用,在類加載過程的解析階段會被轉化為直接引用(指向方法區的指針)。
方法返回地址
- 當一個方法開始執行后,只有兩種方式可以退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的字節碼指令(例如:
areturn
),這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱為調用者),是否有返回值和返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)。 - 另外一種退出方式是,在方法執行過程中遇到了異常,并且這個異常沒有在方法體內得到處理,無論是Java虛擬機內部產生的異常,還是代碼中使用
athrow
字節碼指令產生的異常,只要在本方法的異常處理器表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者產生任何返回值的。 - 無論采用何種退出方式,在方法退出之后,都需要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,調用者的程序計數器的值可以作為返回地址,棧幀中很可能會保存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會保存這部分信息。
- 方法退出的過程實際上就等同于把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值(如果有的話)壓入調用者棧幀的操作數棧中,調整程序計數器的值以指向方法調用指令后面的一條指令等。
簡述:
虛擬機會使用針對每種返回類型的操作來返回,返回值將從操作數棧出棧并且入棧到調用方法的方法棧幀中,當前棧幀出棧,被調用方法的棧幀變成當前棧幀,程序計數器將重置為調用這個方法的指令的下一條指令。
附加信息
虛擬機規范允許具體的虛擬機實現增加一些規范里沒有描述的信息到棧幀中,例如與調試相關的信息,這部分信息完全取決于具體的虛擬機實現。在實際開發中,一般會把動態連接,方法返回地址與其它附加信息全部歸為一類,稱為棧幀信息。