[深入理解Java虛擬機器]第八章 位元組碼執行引擎-執行時棧幀結構

Coding-lover發表於2015-10-25

概述

執行引擎是Java虛擬機器最核心的組成部分之一。“虛擬機器”是一個相對於“物理機”的概念 ,這兩種機器都有程式碼執行能力,其區別是物理機的執行引擎是直接建立在處理器、硬體、指令集和作業系統層面上的,而虛擬機器的執行引擎則是由自己實現的,因此可以自行制定指令集與執行引擎的結構體系,並且能夠執行那些不被硬體直接支援的指令集格式。

在Java虛擬機器規範中制定了虛擬機器位元組碼執行引擎的概念模型,這個概念模型成為各種虛擬機器執行引擎的統一外觀(Facade )。在不同的虛擬機器實現裡面,執行引擎在執行Java程式碼的時候可能會有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生程式碼執行)兩種選擇 , 也可能兩者兼備,甚至還可能會包含幾個不同級別的編譯器執行引擎。 但從外觀上看起來,所有的Java虛擬機器的執行引擎都是一致的:輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果,本章將主要從概念模型的角度來講解虛擬機器的方法呼叫和位元組碼執行。

有一些虛擬機器(如Sun Classic VM ) 的內部只存在直譯器,只能解釋執行,而另外一些虛擬機器(如BEA JRockit) 的內部只存在即時編譯器,只能編譯執行。

執行時棧幀結構

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

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

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

接下來詳細講解一下棧幀的區域性變數表、運算元棧、動態連線、方法返回地址等各個部分的作用和資料結構。

區域性變數表

區域性變數表(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位長度的記憶體空間”是有一些差別的,它允許Slot的長度可以隨著處理器、作業系統或虛擬機器的不同而發生變化。只要保證即使在64位虛擬機器中使用了64位的實體記憶體空間去實現一個Slot, 虛擬機器仍要使用對齊和補白的手段讓Slot在外觀上看起來與32位虛擬機器中的一致。

既然前面提到了Java虛擬機器的資料型別,在此再簡單介紹一下它們。一個Slot可以存放一個32位以內的資料型別,Java中佔用32位以內的資料型別有boolean、byte、char、short、int、float、reference和returnAddress 8種型別。前面6種不需要多加解釋,讀者可以按照Java 語言中對應資料型別的概念去理解它們(僅是這樣理解而已,Java語言與Java虛擬機器中的基本資料型別是存在本質差別的),而第7種reference型別表示對一個物件例項的引用,虛擬機器規範既沒有說明它的長度,也沒有明確指出這種引用應有怎樣的結構。但一般來說,虛擬機器實現至少都應當能通過這個引用做到兩點,一是從此引用中直接或間接地查詢到物件在Java 堆中的資料存放的起始地址索引,二是此引用中直接或間接地查詢到物件所屬資料型別在方法區中的儲存的型別資訊,否則無法實現Java語言規範中定義的語法約束約束 。第8種即returnAddress型別目前已經很少見了,它是為位元組碼指令jsr、 jsr_w和ret服務的,指向了一條位元組碼指令的地址,很古老的Java虛擬機器曾經使用這幾條指令來實現異常處理,現在已經由異常表代替。

對於64位的資料型別,虛擬機器會以高位對齊的方式為其分配兩個連續的Slot空間。 Java語言中明確的(reference型別則可能是32位也可能是64位 )64位的資料型別只有long和double兩種。值得一提的是 ,這裡把long和double資料矣型分割儲存的做法與“long和double的非原子性協定” 中把一次long和double資料型別讀寫分割為兩次32位讀寫的做法有些類似,讀者閱讀到Java記憶體模型時可以互相對比一下。不過,由於區域性變數表建立線上程的堆疊上,是執行緒私有的資料,無論讀寫兩個連續的Slot是否為原子操作,都不會引起資料安全問題。

虛擬機器通過索引定位的方式使用區域性變數表,索引值的範圍是從0開始至區域性變數表最大的Slot數量。如果訪問的是32位資料型別的變數,索引n就代表了使用第n個Slot,如果是64 位資料型別的變數,則說明會同時使用n和n+1兩個Slot。對於兩個相鄰的共同存放一個64位資料的兩個Slot,不允許採用任何方式單獨訪問其中的某一個,Java虛擬機器規範中明確要求瞭如果遇到進行這種操作的位元組碼序列,虛擬機器應該在類載入的校驗階段丟擲異常。

在方法執行時,虛擬機器是使用區域性變數表完成引數值到引數變數列表的傳遞過程的,如果執行的是例項方法(非static的方法),那區域性變數表中第0位索引的Slot預設是用於傳遞方法所屬物件例項的引用,在方法中可以通過關鍵字“this”來訪問到這個隱含的引數。其餘引數則按照參數列順序排列,佔用從1開始的區域性變數Slot,參數列分配完畢後,再根據方法體內部定義的變數順序和作用域分配其餘的Slot。

為了儘可能節省棧幀空間,區域性變數表中的Slot是可以重用的,方法體中定義的變數, 其作用域並不一定會覆蓋整個方法體,如果當前位元組碼PC計數器的值已經超出了某個變數的作用域 ,那這個變數對應的Slot就可以交給其他變數使用。不過 ,這樣的設計除了節省棧幀空間以外,還會伴隨一些額外的副作用,例如 ,在某些情況下,Slot的複用會直接影響到系統的垃圾收集行為,請看程式碼清單8-1〜程式碼清單8-3的3個演示。

程式碼清單8 - 1 區域性變數表Slot複用對垃圾收集的影響之一

public static void main(String[] args)() {
    byte[] placeholder = new byte[64 * 1024 * 1024];
    System.gc();
}

程式碼清單8-1中的程式碼很簡單,即向記憶體填充了64MB的資料 ,然後通知虛擬機器進行垃圾收集。我們在虛擬機器執行引數中加上“-verbose : gc”來看看垃圾收集的過程,發現在 System.gc() 執行後並沒有回收這64MB的記憶體,下面是執行的結果:


[GC 66846K->65824K (125632K ) ,0.0032678 secs] [Full GC 65824K-> 65746K (125632K) ,0.0064131 secs]

沒有回收placeholder所佔的記憶體能說得過去,因為在執行Systemgc() 時 ,變數 placeholder還處於作用域之內,虛擬機器自然不敢回收placeholder的記憶體。那我們把程式碼修改一下 ,變成程式碼清單8-2中的樣子。

程式碼清單8 - 2 區域性變數表Slot複用對垃圾收集的影響之二

public static void main(String[] args)() {
    {
        byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    System.gc();
}

加入了花括號之後,placeholder的作用域被限制在花括號之內,從程式碼邏輯上講,在執行System.gc() 的時候,placeholder已經不可能再被訪問了,但執行一下這段程式,會發現執行結果如下,還是有64MB的記憶體沒有被回收,這又是為什麼呢?

在解釋為什麼之前,我們先對這段程式碼進行第二次修改,在呼叫System.gc() 之前加入 —行“int a=0;” , 變成程式碼清單8-3的樣子。

程式碼清單8 - 3 區域性變數表Slot複用對垃圾收集的影響之三

public static void main(String[] args)() {
    {
        byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    int a = 0;
    System.gc();
}

這個修改看起來莫名其妙,但執行一下程式,卻發現這次記憶體真的被正確回收了。

[GC 66401K-> 65778K (125632K ) ,0.0035471 secs] [Full GC 65778K->218K (125632K) ,0.0140596 secs]

在程式碼清單8-1〜程式碼清單8-3中 ,placeholder能否被回收的根本原因是:區域性變數表中的Slot是否還存有關於placeholder陣列物件的引用。第一次修改中,程式碼雖然已經離開了placeholder的作用域,但在此之後,沒有任何對區域性變數表的讀寫操作,placeholder原本所佔用的Slot還沒有被其他變數所複用,所以作為GC Roots —部分的區域性變數表仍然保持著對它的關聯。這種關聯沒有被及時打斷,在絕大部分情況下影響都很輕微。但如果遇到一個方法 ,其後面的程式碼有一些耗時很長的操作,而前面又定義了佔用了大量記憶體、實際上已經不會再使用的變數,手動將其設定為null值(用來代替那句int a=0, 把變數對應的區域性變數表 Slot清 空 )便不見得是一個絕對無意義的操作,這種操作可以作為一種在極特殊情形(物件佔用記憶體大、此方法的棧幀長時間不能被回收、方法呼叫次數達不到JIT的編譯條件)下的“奇技”來使用。Java語言的一本非常著名的書籍《 Practical Java》中把“不使用的物件應手動賦值為null”作為一條推薦的編碼規則,但是並沒有解釋具體的原因,很長時間之內都有讀者對這條規則感到疑惑。

雖然程式碼清單8-1〜程式碼清單8-3的程式碼示例說明了賦null值的操作在某些情況下確實是有用的 ,但筆者的觀點是不應當對賦null值的操作有過多的依賴,更沒有必要把它當做一個普遍的編碼規則來推廣。原因有兩點,從編碼角度講,以恰當的變數作用域來控制變數回收時間才是最優雅的解決方法,如程式碼清單8-3那樣的場景並不多見。更關鍵的是,從執行角度講 ,使用賦null值的操作來優化記憶體回收是建立在對位元組碼執行引擎概念模型的理解之上的 ,在第6章介紹完位元組碼後,筆者專門增加了一個6.5節“公有設計、私有實現”來強調概念模型與實際執行過程是外部看起來等效,內部看上去則可以完全不同。在虛擬機器使用直譯器執行時 ,通常與概念模型還比較接近,但經過JIT編譯器後 ,才是虛擬機器執行程式碼的主要方式 ,賦null值的操作在經過JIT編譯優化後就會被消除掉,這時候將變數設定為null就是沒有意義的。位元組碼被編譯為原生程式碼後,對GC Roots的列舉也與解釋執行時期有巨大差別,以前面例子來看,程式碼清單8-2在經過JIT編譯後, System.gc() 執行時就可以正確地回收掉記憶體 ,無須寫成程式碼清單8-3的樣子。

關於區域性變數表,還有一點可能會對實際開發產生影響,就是區域性變數不像前面介紹的類變數那樣存在“準備階段”。通過第7章的講解,我們已經知道類變數有兩次賦初始值的過程 ,一次在準備階段,賦予系統初始值;另外一次在初始化階段,賦予程式設計師定義的初始值。因此 ,即使在初始化階段程式設計師沒有為類變數賦值也沒有關係,類變數仍然具有一個確定的初始值。但區域性變數就不一樣,如果一個區域性變數定義了但沒有賦初始值是不能使用的,不要認為Java中任何情況下都存在諸如整型變數預設為0 ,布林型變數預設為false等這樣的預設值。如程式碼清單8-4所 示 ,這段程式碼其實並不能執行,還好編譯器能在編譯期間就檢查到並提示這一點,即便編譯能通過或者手動生成位元組碼的方式製造出下面程式碼的效果,位元組碼校驗的時候也會被虛擬機器發現而導致類載入失敗。

程式碼清單8 - 4 未賦值的區域性變數

public static void main(String[] args) {
    int a;
    System.out.println(a);
}

注:Java虛擬機器規範中沒有明確規定reference型別的長度,它的長度與實際使用32還是64位虛 擬機有關,如果是64位虛擬機器,還與是否開啟某些物件指標壓縮的優化有關,這裡暫且只取32位虛擬機器的reference長度。

運算元棧

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

當一個方法剛剛開始執行的時候,這個方法的運算元棧是空的,在方法的執行過程中, 會有各種位元組碼指令往運算元棧中寫入和提取內容,也就是出棧/ 入棧操作。例如 ,在做算術運算的時候是通過運算元棧來進行的,又或者在呼叫其他方法的時候是通過運算元棧來進行引數傳遞。

舉個例子,整數加法的位元組碼指令iadd在執行的時候運算元棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會將這兩個int值出棧並相加,然後將相加的結果入棧。

運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,在編譯程式程式碼的時候,編譯器要嚴格保證這一點,在類校驗階段的資料流分析中還要再次驗證這一點。再以上面的iadd指令為例,這個指令用於整型數加法,它在執行時,最接近棧頂的兩個元素的資料型別必須為int型,不能出現一個long和一個float使用iadd命令相加的情況。

另外,在概念模型中,兩個棧幀作為虛擬機器棧的元素,是完全相互獨立的。但大多虛擬機器的實現裡都會做一些優化處理,令兩個棧幀出現一部分重疊。讓下面棧幀的部分運算元棧與上面棧幀的部分區域性變數表重疊在一起,這樣在進行方法呼叫時就可以共用一部分資料,無須進行額外的引數複製傳遞,重疊的過程如圖8-2所示。

Java虛擬機器的解釋執行引擎稱為“基於棧的執行引擎”,其中所指的“棧”就是運算元棧。

動態連線

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線(Dynamic Linking)。通過第6章的講解,我們知道Class檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用作為引數。這些符號引用一部分會在類載入階段或者第一次使用的時候就轉化為直接引用 ,這種轉化稱為靜態解析。另外一部分將在每一次執行期間轉化為直接引用,這部分稱為動態連線。關於這兩個轉化過程的詳細資訊,將在8.3節中詳細講解。

方法返回地址

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

另外一種退出方式是,在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,無論是Java虛擬機器內部產生的異常,還是程式碼中使用athrow位元組碼指令產生的異 常 ,只要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口( Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層呼叫者產生任何返回值的。

無論採用何種退出方式,在方法退出之後,都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,呼叫者的PC計數器的值可以作為返回地址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會儲存這部分資訊。

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

附加資訊

虛擬機器規範允許具體的虛擬機器實現增加一些規範裡沒有描述的資訊到棧幀之中,例如與除錯相關的資訊,這部分資訊完全取決於具體的虛擬機器實現,這裡不再詳述。在實際開發中 ,一般會把動態連線、方法返回地址與其他附加資訊全部歸為一類,稱為棧幀資訊。

相關文章