1. 程式人生 > >《深入理解Java虛擬機器》——棧幀結構

《深入理解Java虛擬機器》——棧幀結構

這部分的內容是虛擬機器位元組碼執行引擎方面的,大致分為3塊:棧幀結構、方法呼叫、位元組碼的執行,而本篇主要是針對棧幀結構的總結。

在開始棧幀結構之前我們還是先了解一下執行引擎相關的內容:

物理機的執行引擎是直接建立在處理器、硬體、指令集和作業系統層面上的。

虛擬機器的執行引擎則是由自己實現的,因此可以自行指定指令集與執行引擎的結構體系並且能夠執行那些不被硬體直接支援的指令集格式。

大家都知道Java虛擬機器有很多種實現姿勢,這些實現都在一定程度上受限於虛擬機器位元組碼執行引擎的概念模型,這個模型成為各種虛擬機器執行引擎的統一外觀。雖外觀受限,但內在是有一定的發揮空間的,在不同的虛擬機器實現裡面,執行引擎在執行Java程式碼的時候可能會有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生代碼執行)兩種選擇,也可能兩種兼備,甚至還可能會包含幾個不同級別的編譯器執行引擎。

在瞭解了執行引擎之後,我們來了解本篇文章的重點——棧幀結構。

棧幀在前面的文章中簡單地提及過,這裡做下詳細的介紹。

棧幀(Stack Frame)是用於支援虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中的虛擬機器棧的棧元素。棧幀儲存了方法的區域性變量表、運算元棧、動態連線和方法返回地址等資訊。每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。

每一個棧幀都包括了局部變量表、運算元棧、動態連線、方法返回地址和一些額外的附加資訊。在編譯程式程式碼的時候,棧幀中需要多大的區域性變量表,多深的運算元棧都已經完全確定了,並且寫入到方法表的Code屬性之中,因此一個棧幀需要分配多少記憶體,不會受到程式執行期變數資料的影響,而僅僅取決於具體的虛擬機器實現。

一個執行緒中的方法呼叫鏈可能會很長,很多方法都同時處於執行狀態。對於執行引擎來說,在活動執行緒中,只有位於棧頂的棧幀才是有效的,稱為當前棧幀,與這個棧幀相關聯的方法稱為當前方法。執行引擎執行的所有位元組碼指令都只針對當前棧幀進行操作,在概念模型上,典型的棧幀結構如下圖:


這裡需要思考的是虛擬機器棧、棧幀、運算元棧,執行緒之間的棧幀可以共享嗎?

虛擬機器棧就是存放棧幀的,一個方法對應一個棧幀,運算元棧是棧幀結構一部分,棧幀是執行緒私有的,執行緒之間不能共享。

區域性變量表(Local Variable Table)是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。在Java程式編譯為Class檔案時,就在方法的Code屬性的max_locals資料項中確定了該方法所需要分配的區域性變量表的最大容量。

區域性變量表的容量以變數槽(Variable Slot,下稱Slot)為最小單位,虛擬機器規範中並沒有明確指明一個Slot應占用的記憶體空間大小,只是很有導向性地說到每個Slot都應該能存放一個boolean、byte、char、short、int、float、reference或returnAddress型別的資料,這8種資料型別,都可以使用32位或更小的實體記憶體來存放,但這種描述與明確指出"每個Slot佔用32位長度的記憶體空間"是有一些差別的。

這裡說一下reference型別:reference型別表示對一個物件例項的引用,虛擬機器實現至少能根據這個引用做到兩點:一是從此引用中直接或間接地查詢到物件在Java堆中的資料存放的起始地址索引,二是此引用中直接或間接地查詢到物件所屬資料型別在方法區中的儲存的型別資訊。

虛擬機器通過索引定位的方式使用區域性變量表,索引值的範圍是從0開始至區域性變量表最大的Slot數量。區域性變量表中的Slot是可以重用的,方法體中定義的變數,其作用域並不一定會覆蓋整個方法體,如果當前位元組碼PC計數器的值已經超出了某個變數的作用域,那這個變數對應的Slot就可以交給其他變數使用。不過Slot重用這樣的設計除了節省棧幀空間以外,還會伴隨一些額外的副作用,在某些情況下會直接影響到系統垃圾收集行為。

我們先來一起看一下Slot的複用,測試程式碼如下:

package com.general.class_structure;
public class TestFSLocalVariable {
	/**locals=3*/
	public void testLocal(){
		int a=1;
		System.out.println(a);
		int b=1;
	}
	/**locals=2*/
	public void testLocal1(){
		{
			int a=1;
			System.out.println(a);
		}
		int b=1;
	}
	/**locals=2*/
	public void testLoad2(){
		{
		int a=1;
		System.out.println(a);
		}
		{
			int b=1;
			System.out.println(b);
		}
		int c=1;
	}
}


使用javap得到的相關方法的Code屬性如下:

 public void testLocal();
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
................................................................
 public void testLocal1();
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=1
..............................................................
 public void testLoad2();
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=1


在上面的測試程式碼中,我們可以看到,方法testLocal1和testLoad2中的區域性變量表中的Slot槽均發生了複用。剛剛說到slot的複用會對垃圾收集有影響,我們繼續用程式碼來說明,請看下圖:


關於區域性變量表還有一點需要注意的是:如果一個區域性變數定義了但沒有賦初始值是不能使用的,不要認為Java中任何情況下都存在諸如整型變數預設為0,布林型變數預設為false等這樣的預設值。

這裡再次強調檢視一個方法的locals的值的操作姿勢,javap命令或者直接把class檔案直接拖到eclipse中。

區域性變量表介紹完了,下面我們來看棧幀結構中的另一個東西:運算元棧。

運算元棧(Operand Stack)也常稱為操作棧,它是一個後入先出棧。同區域性變量表一樣,運算元棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks資料項中。運算元棧的每一個元素可以是任意的Java資料 型別,包括long和double。32位資料型別所佔的棧容量為1,64位資料型別所佔的棧容量為2。在方法執行的任何時候,運算元棧的深度都不會超過在max_stacks資料項中設定的最大值。

當一個方法剛剛開始執行的時候,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令往運算元棧中寫入和提取內容,也就是出棧/入棧操作。

運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,在編譯程式程式碼的時候,編譯器要嚴格保證這一點,在類校驗階段的資料流分析中還要再次驗證這一點。在前文中的類載入中當時就介紹了位元組碼驗證,其中有一點就是:保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作。

這裡還需要知道關於運算元棧的2點內容:(1)在概念模型中,兩個棧幀作為虛擬機器棧的元素,是完全相互獨立的。但在大多虛擬機器的實現裡都會做一些優化處理,令兩個棧幀出現一部分重疊。(2)Java虛擬機器的解釋執行引擎稱為“基於棧的執行引擎”,其中所指的"棧"就是運算元棧。

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線。

前面文章類檔案結構中我們知道Class檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用作為引數。這些符號引用一部分會在類載入階段或者第一次使用的時候就轉化為直接引用,這種轉化稱為靜態解析。

來來,修改一下測試程式碼,舉個例子:

public class TestFSLocalVariable {
	/**locals=3*/
	public void testLocal(){
		int a=1;
		System.out.println(a);
		int b=1;
		testLocal1();
	}
	/**locals=2*/
	public void testLocal1(){
		{
			int a=1;
			System.out.println(a);
		}
		int b=1;
		testLoad2();
	}
	/**locals=2*/
	public void testLoad2(){
		{
		int a=1;
		System.out.println(a);
		}
		{
			int b=1;
			System.out.println(b);
		}
		int c=1;
	}


來看下testLocal1方法的位元組碼版本:

public void testLocal1();
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=1
         0: iconst_1      
         1: istore_1      
         2: getstatic     #15                 // Field java/lang/System.out:Ljava/io/PrintStream;
         5: iload_1       
         6: invokevirtual #21                 // Method java/io/PrintStream.println:(I)V
         9: iconst_1      
        10: istore_1      
        11: aload_0       
        12: invokevirtual #33                 // Method testLoad2:()V
        15: return        
      LineNumberTable:
        line 15: 0
        line 16: 2
        line 18: 9
        line 19: 11
        line 20: 15
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0      16     0  this   Lcom/general/class_structure/TestFSLocalVariable;
               2       7     1     a   I
              11       5     1     b   I


來看下invokevirtual #33,#33就是testLoad2的符號引用。invokevirtual是方法呼叫指令,#33是其引數。

有些方法的符號引用是在每一次執行期間轉化為直接引用的,比如方法的過載,這部分稱為動態連線,這部分內容將在方法分派的文章中展開。

方法返回地址

當一個方法開始執行後,只有兩種方式可以退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法呼叫者,是否有返回值和返回值的型別將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為正常完成出口。

另一種退出方式是:在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,無論是Java虛擬機器內部產生的異常,還是程式碼中使用athrow位元組碼指令產生的異常,只要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口。

一個方法使用異常完成出口的方式退出,是不會給它的上層呼叫者產生任何返回值的。

無論採用何種退出方式,在方法退出之後,都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來恢復它的上層方法的執行狀態。

方法退出的過程實際上就等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的區域性變量表和運算元棧,把返回值(如果有的話)壓人呼叫者棧幀的運算元棧中,調整PC計數器的值以指向方法呼叫指令後面的一條指令等。

棧幀資訊:虛擬機器規範允許具體的虛擬機器實現增加一些規範中沒有描述的資訊到棧幀之中,在實際開發中,一般會把動態連線、方法返回地址與其他附件資訊全部歸為一類,稱為棧幀資訊,也即開頭代表棧幀結構的圖,可以轉換為下圖: