前言:
在之前的文章:Java虛擬機—堆、棧、運行時數據區 中,我們整體介紹了JVM在運行時的一些數據區域如堆、方法區、程序計數器、虛擬機棧、本地方法棧。本篇文章,我們圍繞其中的一個區域展開——虛擬機棧中的棧元素棧幀
所以,本文的主要分為兩部分:
1.Java虛擬機運行時棧幀介紹 2.一個關于字節碼指令以及操作數出棧/入棧過程的小實例
其中,運行時棧幀介紹主要包括:
- 0.棧幀的概念
- 1.局部變量表
- 2.操作數棧
- 3.動態鏈接
- 4.方法返回
- 5.附加信息
Java虛擬機棧和運行時棧幀結構
Java虛擬機是基于「棧」架構的,如圖所示:
為什么要深入研究虛擬機棧呢?因為它hin重要。除了一些native方法是基于本地方法棧實現的,所有的Java方法幾乎都是通Java虛擬機棧來實現方法的調用和執行過程(當然,需要程序計數器、堆、方法區的配合),所以Java虛擬機棧是虛擬機執行引擎的核心之一。而Java虛擬機棧中出棧入棧的元素就稱為「棧幀」。
0.棧幀的概念
棧幀(Stack Frame)是用于支持虛擬機進行方法調用和方法執行的數據結構。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每一個方法從調用至執行完成的過程,都對應著一個棧幀在虛擬機棧里從入棧到出棧的過程。
一個線程中方法的調用鏈可能會很長,很多方法都同時處于執行狀態。對于JVM執行引擎來說,在在活動線程中,只有位于JVM虛擬機棧棧頂的元素才是有效的,即稱為當前棧幀,與這個棧幀相關連的方法稱為當前方法,定義這個方法的類叫做當前類。
執行引擎運行的所有字節碼指令都只針對當前棧幀進行操作。如果當前方法調用了其他方法,或者當前方法執行結束,那這個方法的棧幀就不再是當前棧幀了。
調用新的方法時,新的棧幀也會隨之創建。并且隨著程序控制權轉移到新方法,新的棧幀成為了當前棧幀。方法返回之際,原棧幀會返回方法的執行結果給之前的棧幀(返回給方法調用者),隨后虛擬機將會丟棄此棧幀。
棧幀是線程本地的私有數據,不可能在一個棧幀中引用另外一個線程的棧幀。
在概念模型上,典型的棧幀結構如下:
關于「棧幀」,我們在看看《Java虛擬機規范》中的描述:
棧幀是用來存儲數據和部分過程結果的數據結構,同時也用來處理動態連接、方法返回值和異常分派。
棧幀隨著方法調用而創建,隨著方法結束而銷毀——無論方法正常完成還是異常完成都算作方法結束。
棧幀的存儲空間由創建它的線程分配在Java虛擬機棧之中,每一個棧幀都有自己的本地變量表(局部變量表)、操作數棧和指向當前方法所屬的類的運行時常量池的引用。
接下來,詳細講解一下棧幀中的局部變量表、操作數棧、動態連接、方法返回地址等各個部分的數據結構和作用。
1.局部變量表
局部變量表(Local Variable Table)是一組變量值存儲空間,用于存放方法參數和方法內定義的局部變量。局部變量表的容量以變量槽(Variable Slot)為最小單位,Java虛擬機規范并沒有定義一個槽所應該占用內存空間的大小,但是規定了一個槽應該可以存放一個32位以內的數據類型。
在Java程序編譯為Class文件時,就在方法的Code屬性中的max_locals數據項中確定了該方法所需分配的局部變量表的最大容量。(最大Slot數量)
一個局部變量可以保存一個類型為boolean、byte、char、short、int、float、reference和returnAddress類型的數據。reference類型表示對一個對象實例的引用。returnAddress類型是為jsr、jsr_w和ret指令服務的,目前已經很少使用了。
虛擬機通過索引定位的方法查找相應的局部變量,索引的范圍是從0~局部變量表最大容量。如果Slot是32位的,則遇到一個64位數據類型的變量(如long或double型),則會連續使用兩個連續的Slot來存儲。
2.操作數棧
操作數棧(Operand Stack)也常稱為操作棧,它是一個后入先出棧(LIFO)。同局部變量表一樣,操作數棧的最大深度也在編譯的時候寫入到方法的Code屬性的max_stacks數據項中。
操作數棧的每一個元素可以是任意Java數據類型,32位的數據類型占一個棧容量,64位的數據類型占2個棧容量,且在方法執行的任意時刻,操作數棧的深度都不會超過max_stacks中設置的最大值。
當一個方法剛剛開始執行時,其操作數棧是空的,隨著方法執行和字節碼指令的執行,會從局部變量表或對象實例的字段中復制常量或變量寫入到操作數棧,再隨著計算的進行將棧中元素出棧到局部變量表或者返回給方法調用者,也就是出棧/入棧操作。一個完整的方法執行期間往往包含多個這樣出棧/入棧的過程。
3.動態連接
在一個class文件中,一個方法要調用其他方法,需要將這些方法的符號引用轉化為其在內存地址中的直接引用,而符號引用存在于方法區中的運行時常量池。
Java虛擬機棧中,每個棧幀都包含一個指向運行時常量池中該棧所屬方法的符號引用,持有這個引用的目的是為了支持方法調用過程中的動態連接(Dynamic Linking)。
這些符號引用一部分會在類加載階段或者第一次使用時就直接轉化為直接引用,這類轉化稱為靜態解析。另一部分將在每次運行期間轉化為直接引用,這類轉化稱為動態連接。
4.方法返回
當一個方法開始執行時,可能有兩種方式退出該方法:
- 正常完成出口
- 異常完成出口
正常完成出口是指方法正常完成并退出,沒有拋出任何異常(包括Java虛擬機異常以及執行時通過throw語句顯示拋出的異常)。如果當前方法正常完成,則根據當前方法返回的字節碼指令,這時有可能會有返回值傳遞給方法調用者(調用它的方法),或者無返回值。具體是否有返回值以及返回值的數據類型將根據該方法返回的字節碼指令確定。
異常完成出口是指方法執行過程中遇到異常,并且這個異常在方法體內部沒有得到處理,導致方法退出。
無論是Java虛擬機拋出的異常還是代碼中使用athrow指令產生的異常,只要在本方法的異常表中沒有搜索到相應的異常處理器,就會導致方法退出。
無論方法采用何種方式退出,在方法退出后都需要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能需要在當前棧幀中保存一些信息,用來幫他恢復它的上層方法執行狀態。
方法退出過程實際上就等同于把當前棧幀出棧,因此退出可以執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值(如果有的話)壓如調用者的操作數棧中,調整PC計數器的值以指向方法調用指令后的下一條指令。
一般來說,方法正常退出時,調用者的PC計數值可以作為返回地址,棧幀中可能保存此計數值。而方法異常退出時,返回地址是通過異常處理器表確定的,棧幀中一般不會保存此部分信息。
5.附加信息
虛擬機規范允許具體的虛擬機實現增加一些規范中沒有描述的信息到棧幀之中,例如和調試相關的信息,這部分信息完全取決于不同的虛擬機實現。在實際開發中,一般會把動態連接,方法返回地址與其他附加信息一起歸為一類,稱為棧幀信息。
一個字節碼指令以及操作數出棧/入棧過程的小實例
這個小例子是我之前在牛客網上做題碰到的,改了一點內容拿出來,還是挺有意思的~順便介紹下字節碼指令,以及對著實例講解棧幀中操作數出棧入棧的整個過程。
問題:這個程序運行后會輸出哪三個數字?(以及test1和test2函數中return和finally的執行情況?)
答案:
System.out.println(test1(num)) ---- 60
System.out.println(b); ---- 60
System.out.println(test2(num)); -----30
執行情況,且看下面講解
學Java時我們都知道:
1.執行完try中的語句后,無論是否有異常被catch到,finally中的語句都會被執行(除了exit以及其它異常外),所以finally中通常用于關閉流關閉連接等操作。
2.finally中如果有return語句,則會用finally中的語句覆蓋掉try/catch中的return。
public static int test1(int a){
try{
a+=20;
return a;
} finally {
a+=30;
return a;
}
}
于是,在test1中,try塊中return時a的值為30,經過finally塊+30后,值變為60,再return就是返回了finally中的a,即60。于是第一個輸出為60,這個很簡單。
public static int test2(int b){
try{
b+=20;
return b;
}finally {
b+=30;
System.out.println(b);
}
}
在test2中,try塊中經過計算后return的b值為30,finally中沒有返回語句,故return的b值以try中的b=30為準(即第三個輸出為30)。
try語句塊中return b=30這個比較好理解,但是接下來finally中又對b進行了+30的操作,那此時第二個輸出System.out.println(b);輸出的值是60???,還是30? !(好像都能說得通)。
答案是60,為什么?讓我們用javap -c FinallyTest.class看一下其字節碼,順便學習下字節碼指令和class文件中的各個屬性。
首先,第一個輸出——System.out.println(test1(num)) 值是60,這個比較簡單,就不細說了,我們主要講解一下test2方法和第二個、第三個輸出:System.out.println(b); System.out.println(test2(num));**
對照之前的文章:Java虛擬機—Class文件結構 我們首先解析一下字節碼文件中test2()方法及各個屬性的作用,然后再對照字節碼指令來詳細看一下整個過程。
1.test2()方法字節碼中的各個屬性:
public static int test2(int);
descriptor: (I)I
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: iinc 0, 20
3: iload_0
4: istore_1
5: iinc 0, 30
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: iload_0
12: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
15: iload_1
16: ireturn
17: astore_2
18: iinc 0, 30
21: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
24: iload_0
25: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
28: aload_2
29: athrow
Exception table:
from to target type
0 5 17 any
LineNumberTable:
line 17: 0
line 18: 3
line 20: 5
line 21: 8
line 18: 15
line 20: 17
line 21: 21
line 22: 28
LocalVariableTable:
Start Length Slot Name Signature
0 30 0 b I
StackMapTable: number_of_entries = 1
frame_type = 81 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
方法描述符descriptor為:(I)I;
test2()方法的訪問標志flags:ACC_PUBLIC和ACC_STATIC表示此方法的修飾符有public和static。
屬性表attribute_info中的“Code”屬性:即為屬性表集合,包括了:代碼轉換后字節碼指令+Exceptiontable+LineNumberTable+LocalVariableTable+StackMapTable
Exceptiontable是異常表用于處理異常后的程序出口。
LineNumberTable:行號表,用于指示Java源碼行號和字節碼指令的對應關系
LocalVariableTable:局部變量表,用于存放運行期間和操作數棧交互(出棧/入棧)的局部變量
StackMapTable:JDK1.6后新增的屬性,供新的類型檢測器檢查和處理目標方法的局部變量和操作數棧所需的類型是否匹配
2.test2()方法中的字節碼執行過程
看完了上面的屬性,下面讓我們來一行行地看一遍字節碼指令的部分,關于指令描述可以參考之前的文章——Java虛擬機—字節碼指令初探 。我們
Code:
stack=2, locals=3, args_size=1 局部變量表 操作數棧
0: iinc 0, 20 //自增指令,位于局部變量表中0號位置的int型數值+20 30 null
3: iload_0 //從局部變量表0號位置加載一個int型值到操作數棧 30 30
4: istore_1 //從操作數棧頂出棧一個int型值存到局部變量表1號位置 30,30 null
5: iinc 0, 30 //局部變量表中0號位置的int型數值加30 60,30 null
8: getstatic #2 //訪問類的靜態字段值。#2表示靜態字段位于運行時 60,30 null
11: iload_0 //從局部變量表0號位置加載一個int型值到操作數棧 60,30 60
12: invokevirtual #3 //調用PrintStream類的實例方法——println輸出60 60,30 null
15: iload_1 //從局部變量表1號位置加載一個int型值到操作數棧 60,30 30
16: ireturn //返回一個int型數值(從棧頂) 60, 30 null
17: astore_2
18: iinc 0, 30
21: getstatic #2
24: iload_0
25: invokevirtual #3
28: aload_2
29: athrow //拋出異常,程序跳轉到異常處理器中,(Exception table)
Exception table:
from to target type
0 5 17 any
如上,代碼中序號0的指令——0: iinc對應test2源碼中try塊中:b += 20。此處test2()方法是在主函數main()中被調用的,在main()方法棧幀中操作數出棧一個int型值10,作為test2()方法調用的參數。test2()方法調用時,會新構建test2方法的棧幀(從而成為當前棧幀),10作為參數就存到了當前棧幀的局部變量表0號位置。所以在0: iinc 0, 20執行時,test2()方法棧幀中局部變量表0號位置已經是有了10這個值的。然后,指令一行行地執行過程如上述注釋↑。
有幾點需要注意:
- getstatic
- invokevirtual
- ireturn
getstatic指令用于訪問類的靜態字段值
以第一個getstatic指令為例,其后的參數#2,表示會在FinallyTest類的運行時常量池中2號位置查找此字段值。
Constant pool:
#1 = Methodref #7.#30 // java/lang/Object."<init>":()V
#2 = Fieldref #31.#32 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #33.#34 // java/io/PrintStream.println:(I)V
#4 = Methodref #6.#35 // JustCoding/Practise/FinallyTest.test1:(I)I
#5 = Methodref #6.#36 // JustCoding/Practise/FinallyTest.test2:(I)I
#6 = Class #37 // JustCoding/Practise/FinallyTest
#7 = Class #38 // java/lang/Object
.....
#30 = NameAndType #8:#9 // "<init>":()V
#31 = Class #40 // java/lang/System
#32 = NameAndType #41:#42 // out:Ljava/io/PrintStream;
#33 = Class #43 // java/io/PrintStream
#34 = NameAndType #44:#45 // println:(I)V
#35 = NameAndType #15:#16 // test1:(I)I
#36 = NameAndType #21:#16 // test2:(I)I
.....
常量池中2號位置的字段值是符號引用,該引用指向的是常量池中31號和32號位置,即java/lang/System類和java/io/PrintStream類中println的方法描述。如果該靜態字段(#2號)所指向的類或接口沒有被初始化,則指令執行過程將觸發其初始化過程。
invokevirtual指令用于調用實例方法
此處調用java.io.PrintStream類中的println方法后,會自動從test2的操作數棧中出棧相應的參數(即60)。然后方法執行來到了println方法中,于是新建此方法的棧幀,將當前棧幀切換到println棧幀上,將參數60入棧,在完成println方法后(輸出60)再切換回到test2的棧幀中。
所以,第二個System.out.println(b);輸出的是60.**
iteturn指令用于從操作數棧頂出棧一個int型的值給方法調用者
看一下test2方法的Java代碼:
public static int test2(int b){
try{
b += 20;
return b;
}finally {
b += 30;
System.out.println(b);
}
}
按道理應該try塊中執行完b += 20后應該緊跟著就return了,不過由于finally塊的存在,還需要執行后面的內容,那么在其字節碼指令中是如何實現的呢?
b += 20對應0: iinc、3: iload_0、4: istore_1三條指令,之后執行了finally塊中的內容:
5: iinc .....直到16: ireturn才正式執行return操作。此時return的是局部變量表中1號位置暫存的值30,所以第三個System.out.println(test2(num));輸出的是30.**
最后,ireturn指令到throw之間的為什么會有一段指令?這個和finally塊的設計相關稍微有點復雜,暫時不講。
以下是完整的,javap -v FinallyTest.class后的字節碼:
接下來的文章中,還是會沿著《深入理解Java虛擬機》,講解更多精彩的內容,包括:
垃圾收集器和算法、Java中方法調用(重載、重寫)的底層實現、new一個對象和常量池方法區的關系、Java對象內存布局等等~敬請期待,喜歡請點個贊唄??