虛擬機字節碼執行引擎

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

在Java虛擬機規範中制定了虛擬機字節碼執行引擎的概念模型,這個概念模型成爲各種虛擬機執行引擎的統一外觀。在不同的虛擬機實現裏面,執行引擎在執行Java代碼的時候可能會有解釋執行(通過解釋器執行)和編譯執行(通過即時編譯器產生本地代碼執行)兩種選擇。

一.運行時棧幀結構

棧幀是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧的棧元素。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每一個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛擬機裏面從入棧到出棧的過程。

每一個棧幀都包括了局部變量表、操作數棧、動態連接、方法返回地址和一些額外的附加信息。在編譯程序代碼的時候,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全確定了,並且寫入到方法表的Code屬性之中,因此一個棧幀需要分配多少內存,不會受到程序運行變量數據的影響,而僅僅取決於具體的虛擬機實現。接下來詳細講解一下棧幀中的局部變量表、操作數棧、動態連接和方法返回地址等各個部分的作用和數據結構。

1.局部變量

局部變量表是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。在Java程序編譯爲class文件時,就在方法的Code屬性的max_locals數據項中確定了該方法所需要分配的局部變量表的最大容量。

局部變量表的容量以變量槽Slot爲最小單位,虛擬機規範中並沒有明確指明一個slot應占用的內存空間,只是很有嚮導性地說到一個slot都應該能存放一個32位的數據類型。對於64位的數據類型,虛擬機會以高位對齊的方式爲其分配兩個連續的slot空間。

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

在方法執行時,虛擬機是使用局部變量表完成參數值到參數變量列表的傳遞過程的,如果執行的是實例方法,那局部變量表中第0位索引的slot默認是用於傳遞方法所屬對象的引用,在方法中可以通過關鍵字this來訪問到這個隱含的參數。其餘參數則按照參數表順序排序,佔用從1開始的局部變量slot,參數表分配完畢後,再根據方法體內部定義的變量順序和作用域分配其餘的slot。

爲了儘可能節省棧幀空間,局部變量表中的slot是可以重用的,方法體中定義的變量,其作用域不一定會覆蓋整個方法體。

2.操作數棧

操作數棧也稱操作棧,同局部變量表一樣,它的最大深度也在編譯的時候寫入到Code屬性的max_stacks數據項中。操作數棧的每一個元素可以是任意的Java類型數據,包括long和double。32位數據類型所佔的佔容量爲1,64位數據類型所佔的佔容量爲2.在方法執行的任何時候,操作數棧的深度都不會超過在max_stacks數據項中設定的最大值。

當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種字節碼指令往操作數棧中寫入和提取內容,也就是出棧/入棧操作。操作數棧中元素的數據類型必須與字節碼指令的序列嚴格匹配,在編譯程序代碼的時候,編譯期要嚴格保證這一點,在類校驗階段的數據流分析中還要再次驗證這一點。

在概念模型中,兩個棧幀作爲虛擬機棧的元素,是完全相互獨立的。但在大多數虛擬機的實現裏都會做一些優化處理,令兩個棧幀出現一部分重疊,這樣在進行方法調用時就可以公用一部分數據,無須進行額外的參數賦值傳遞。

Java虛擬機的解釋執行引擎稱爲“基於棧的執行引擎”,其中所指的棧就是操作數棧。

3.動態連接

每一個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程中的動態連接。class文件的常量池存在有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用作爲參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化爲直接引用,這種轉化稱爲靜態解析。另外一部分將在每一次運行期間轉化爲直接引用,這部分稱爲動態連接。

4.方法返回地址

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

另爲一種退出方式是,在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,無論是Java虛擬機內部產生的異常,還是代碼中使用athrow字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱爲異常完成出口。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者產生任何返回值的。無論採用何種退出方式,在方法退出之前,都需要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能需要在棧幀中保留一些信息,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,調用者的PC計數器的值可以作爲返回地址,棧幀中很有可能會保存這個計數器值。而異常退出時,返回地址是要通過異常處理表來確定的,棧幀中一般不會保存這部分信息。

方法退出的過程實際上就等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令後面的一條指令等。

5.附加信息

虛擬機規範允許具體的虛擬機實現增加一些規範裏沒有描述的信息到棧幀之中,這部分信息完全取決於具體的虛擬機實現。在實際開發中,一般會把動態連接、方法返回地址和其他附加信息全部歸爲一類,稱爲棧幀信息。

二.方法調用

方法調用並不等同於方法執行,方法調用階段唯一的任務就是確定被調用方法的版本,暫時還不涉及方法內部的具體運行過程。在程序運行時,進行方法調用是最普遍、最頻繁的操作。

1.解析

方法在程序真正運行之前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可改變的。換句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來。這類方法的調用稱爲解析。

在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,後者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類加載階段進行解析。

解析調用一定是一個靜態的過程,在編譯期就完全確定,在類裝載的解析階段就會把設計的符號引用全部轉換爲可調用的直接引用,不會延遲到運行期再去完成。而分派調用則可能是靜態的也可能是動態的,根據分派依據的宗量數可分爲單分派和多分派。這兩類分派方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派4種分派組合情況。

2.分派

所有依賴靜態類型來定位方法執行版本的分派動作稱爲靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的。另外,編譯器雖然能確定出方法的重載版本,但在很多情況下這個重載版本並不是“唯一的”,往往只能確定一個“更加合適的”版本。這種迷糊的結論在由0和1構成的計算機世界中算是比較“稀罕”的事情,產生這種模糊結論的主要原因是字面量不需要定義,所以字面量沒有顯示的靜態類型,它的靜態類型只能通過語言上的規則去理解和推斷。

在運行期根據實際類型確定方法執行版本的分派過程稱爲動態分派,它與多態性的重寫有着密切的關聯。

3.動態類型語言支持

動態類型語言的關鍵特徵是它的類型檢查的主體過程是在運行期而不是編譯期。

三.基於棧的字節碼解釋執行引擎

1.解釋執行

Java語言中,Javac編譯器完成了程序代碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的字節碼指令流的過程。因爲這一部分在Java虛擬機之外進行的,而解釋器在虛擬機的內部,所以Java程序的編譯就是半獨立實現的。

2.基於棧的指令集與基於寄存器的指令集

Java編譯器輸出的指令流,基本上是一種基於棧的指令集架構,指令流中的指令大部分都是零地址指令,它們依賴操作數棧進行工作。與之相對的另外一套常用的指令集架構是基於寄存器的指令集。

基於棧的指令集的優點是可移植,寄存器由硬件直接提供,程序直接依賴這些硬件寄存器則不可避免地要受到硬件的約束。棧架構的指令集實現簡單,代碼相對更加緊湊、編譯器實現更加簡單,它的主要缺點是執行速度相對來說會慢一些。

發佈了43 篇原創文章 · 獲贊 4 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章