- 深入理解JVM字節碼
- 張亞
- 2628字
- 2020-06-02 18:27:54
2.2 Java虛擬機棧和棧幀
虛擬機常見的實現方式有兩種:基于棧(Stack based)和基于寄存器(Register based)。典型的基于棧的虛擬機有Hotspot JVM、.net CLR,而典型的基于寄存器的虛擬機有Lua語言虛擬機LuaVM和Google開發的Android虛擬機DalvikVM。
兩者有什么不同呢?舉一個計算兩數相加的例子:c = a + b, Java源碼如下所示。
int my_add(int a, int b) { return a + b; }
使用javap查看對應的字節,如下所示。
0: iload_1 // 將a壓入操作數棧 1: iload_2 // 將b壓入操作數棧 2: iadd // 將棧頂兩個值出棧相加,然后將結果放回棧頂 3: ireturn // 將棧頂值返回
實現相同功能對應的lua代碼如下。
local function my_add(a, b) return a + b; end
使用luac -l -l -v -s test.lua命令查看lua的字節碼,如下所示。
[1] ADD R2 R0 R1 ; R2 := R0 + R1 [2] RETURN R2 2 ; return R2 [3] RETURN R0 1 ; return
第1行調用ADD指令將R0寄存器和R1寄存器中的值相加存儲到寄存器R2中。第2行返回R2寄存器的值。第3行是lua的一個特殊處理,為了防止有分支漏掉了return語句,lua始終在最后插入一行return語句。
以7 + 20為例,基于棧和基于寄存器的執行過程對比如圖2-1所示。

圖2-1 基于棧和基于寄存器的執行過程對比
基于棧和基于寄存器的指令集架構各有優缺點,具體如下所示。
? 基于棧的指令集架構的優點是移植性更好、指令更短、實現簡單,但是不能隨機訪問堆棧中的元素,完成相同功能所需的指令數一般比寄存器架構多,需要頻繁地入棧出棧,不利于代碼優化。
? 基于寄存器的指令集架構的優點是速度快,可以充分利用寄存器,有利于程序做運行速度優化,但操作數需要顯式指定,指令較長。
棧幀
在寫遞歸的程序時如果忘記寫遞歸退出的條件,則會報java.lang.StackOverflowError異常。比如計算斐波拉契數列,它的計算公式為f(n)= f(n-1)+ f(n-2),假設從0開始,它的序列如下所示。
0, 1, 1, 2, 3, 5, 8, 13, 21, ...
在沒有遞歸退出條件的情況下,很容易寫出下面的代碼。
public static int fibonacci(int n) { return fibonacci(n -1) + fibonacci(n -2); }
運行上面的代碼馬上會報java.lang.StackOverflowError異常。為什么會拋這個異常呢?這就要從棧幀(Stack Frame)講起。
Hotspot JVM是一個基于棧的虛擬機,每個線程都有一個虛擬機棧用來存儲棧幀,每次方法調用都伴隨著棧幀的創建、銷毀。Java虛擬機棧的釋義如圖2-2所示。

圖2-2 Java虛擬機棧
當線程請求分配的棧容量超過Java虛擬機棧允許的最大容量時,Java虛擬機將會拋出StackOverflowError異常,可以用JVM命令行參數 -Xss來指定線程棧的大小,比如 -Xss:256k用于將棧的大小設置為256KB。
每個線程都擁有自己的Java虛擬機棧,一個多線程的應用會擁有多個Java虛擬機棧,每個棧擁有自己的棧幀,如圖2-3所示。

圖2-3 棧幀的概念
棧幀是用于支持虛擬機進行方法調用和方法執行的數據結構,隨著方法調用而創建,隨著方法結束而銷毀。棧幀的存儲空間分配在Java虛擬機棧中,每個棧幀擁有自己的局部變量表(Local Variable)、操作數棧(Operand Stack)和指向常量池的引用,如圖2-4所示。

圖2-4 棧幀的組成
常量池在第1章已經有詳細的介紹,接下來重點介紹局部變量表和操作數棧的相關內容。
1.局部變量表
每個棧幀內部都包含一組稱為局部變量表的變量列表,局部變量表的大小在編譯期間就已經確定,對應class文件中方法Code屬性的max_locals字段,Java虛擬機會根據max_locals字段來分配方法執行過程中需要分配的最大的局部變量表容量。代碼示例如下。
public class MyJVMTest { public void foo(int id, String name) { String tmp = "A"; } }
使用javac -g MyJVMTest.java進行編譯,然后執行javap -c -v -l MyJVMTest查看字節碼,結果如下。
public void foo(int, java.lang.String); Code: stack=1, locals=4, args_size=3 0: ldc #2 // String A 2: astore_3 3: return LocalVariableTable: Start Length Slot Name Signature 0 4 0 this LMyJVMTest; 0 4 1 id I 0 4 2 name Ljava/lang/String; 3 1 3 tmp Ljava/lang/String;
可以看到foo方法只有兩個參數,args_size卻等于3。當一個實例方法(非靜態方法)被調用時,第0個局部變量是調用這個實例方法的對象的引用,也就是我們所說的this。調用方法foo(2019, "hello")實際上是調用foo(this, 2019, "hello")。
LocalVariableTable輸出顯示了局部變量表的4個槽(slot),布局如表2-1所示。
表2-1 局部變量表

javap輸出中的locals=4表示局部變量表的大小等于4。局部變量表的大小并不是方法中所有局部變量的數量之和,它與變量的類型和變量作用域有關。當一個局部作用域結束,它內部的局部變量占用的位置就可以被接下來的局部變量復用了,以下面的靜態foo方法為例。
public static void foo() { // locals=0, max_locals=0 if (true) { // locals=1, max_locals=1 String a = "a"; } // locals=0, max_locals=1 if (true) { // locals=1, max_locals=1 String b = "b"; } // locals=0, max_locals=1 }
foo方法對應的局部變量表的大小等于1,因為是靜態方法,局部變量表不用自動添加this為局部變量表的第一個元素,a和b共用同一個slot等于0的局部變量表位置。
當包含long和double類型的變量時,這些變量會占用兩個局部變量表的slot,以下面的代碼為例。
public void foo() { double a = 1L; int b = 10; }
對應的局部變量表如圖2-5所示。

圖2-5 包含double和long類型的局部變量表
2.操作數棧
每個棧幀內部都包含一個稱為操作數棧的后進先出(LIFO)棧,棧的大小同樣也是在編譯期間確定。Java虛擬機提供的很多字節碼指令用于從局部變量表或者對象實例的字段中復制常量或者變量到操作數棧,也有一些指令用于從操作數棧取走數據、操作數據和把操作結果重新入棧。在方法調用時,操作數棧也用于準備調用方法的參數和接收方法返回的結果。
比如iadd指令用于將兩個int型的數值相加,它要求執行之前操作數棧已經存在兩個int型數值,在iadd指令執行時,兩個int型數值從操作數棧中出棧,相加求和,然后將求和的結果重新入棧。1 + 2對應的指令執行過程,如圖2-6所示。

圖2-6 1+2指令執行過程
整個JVM指令執行的過程就是局部變量表與操作數棧之間不斷加載、存儲的過程,如圖2-7所示。

圖2-7 局部變量表和操作數棧的互操作
那么,如何計算操作數棧的最大值?操作數棧容量最大值對應方法Code屬性的max_stack,表示當前方法的操作數棧在執行過程中任何時間點的最大深度。調用一個成員方法會將this和所有參數入棧,調用完畢this和參數都會出棧。如果方法有返回值,會將返回值入棧。代碼示例如下。
public void foo() { bar(1, 1, 2); } public void bar(int a, int b, int c) { }
foo方法的max_stack等于4,因為調用bar方法會將this、1、1、2這四個變量壓棧到棧上,棧的深度為4,調用完后全部出棧。
如果bar方法后面再調用一個參數個數小于3的方法,比如下面代碼中的bar1, foo方法的max_stack還是等于4,因為方法調用過程中,操作數棧的最大深度還是調用bar方法產生的。
public void foo() { // stack=4, max_stack=4 bar(1, 1, 2); // stack=2, max_stack=4 bar1(1); } public void bar(int a, int b, int c) { } public void bar1(int a) { }
如果bar方法后面再調用一個參數個數大于3的方法,比如下面代碼中的bar2,會將this、1、2、3、4、5入棧,max_stack變為6。
public void foo() { // stack=4, max_stack=4 bar(1, 1, 2); // stack=2, max_stack=4 bar1(1); // stack=6, max_stack=6 bar2(1, 2, 3, 4, 5); } public void bar(int a, int b, int c) { } public void bar1(int a) { } public void bar2(int a, int b, int c , int d, int e) { }
計算stack的方式如下:遇到入棧的字節碼指令,stack+=1或者stack+=2(根據不同的指令類型),遇到出棧的字節碼指令,stack則相應減少,這個過程中stack的最大值就是max_stack,也就是javap輸出的stack的值,計算過程的偽代碼如下所示。
push(Type t) { stack = stack + width(t); if (stack > max_stack) max_stack = stack; } pop(Type t) { stack = stack - width(t); }
有了棧幀的概念,接下來理解字節碼指令就容易很多了,從下一節開始,我們將分類型講解字節碼指令。
- Learn ECMAScript(Second Edition)
- MySQL數據庫管理實戰
- Software Testing using Visual Studio 2012
- WSO2 Developer’s Guide
- Apache Hive Essentials
- Access 2016數據庫管
- Python算法詳解
- Advanced Express Web Application Development
- The Professional ScrumMaster’s Handbook
- Mastering C++ Multithreading
- App Inventor創意趣味編程進階
- 軟件項目管理實用教程
- 計算機應用基礎教程(Windows 7+Office 2010)
- Machine Learning With Go
- Sitecore Cookbook for Developers