1. 程式人生 > >《深入理解 Java 虛擬機器》讀書筆記:虛擬機器位元組碼執行引擎

《深入理解 Java 虛擬機器》讀書筆記:虛擬機器位元組碼執行引擎

# 正文 執行引擎是 Java 虛擬機器最核心的組成部分之一。在不同的虛擬機器實現裡,執行引擎在執行 Java 程式碼時可能會有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生代碼執行)兩種選擇,也可能兩者兼備。但從外觀上看,所有 Java 虛擬機器的執行引擎都是一致的:輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果。 **物理機與虛擬機器的執行引擎:** * 物理機的執行引擎:直接建立在處理器、硬體、指令集和作業系統層面上。 * 虛擬機器的執行引擎:由自己實現,可自行制定指令集與執行引擎的體系結構,能夠執行那些不被硬體直接支援的指令集格式。 ## 一、執行時棧幀結構 棧幀(Stack Frame)是用於支援虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中虛擬機器棧的棧元素。棧幀儲存了方法的區域性變量表、運算元棧、動態連線、方法返回地址和一些額外的附加資訊。每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器裡面從入棧到出棧的過程。 對於執行引擎來說,在活動執行緒中,只有位於棧頂的棧幀才是有效的,稱為當前棧幀,與這個棧幀相關聯的方法稱為當前方法。執行引擎執行的所有位元組碼指令都只針對當前棧幀進行操作。 ### 1、區域性變量表 區域性變量表(Local Variable Table)是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。 區域性變量表的容量以變數槽(Variable Slot,下稱 Slot)為最小單位,虛擬機器規範中並沒有明確指明一個 Slot 應占用的記憶體空間大小。為了儘可能節省棧幀空間,區域性變量表中的 Slot 是可以重用的。 虛擬機器通過索引定位的方式使用區域性變量表,索引值的範圍是從 0 開始至區域性變量表最大的 Slot 數量。 在方法執行時,虛擬機器通過區域性變量表完成引數值到引數變數列表的傳遞。如果執行的是例項方法(非 static 方法),那區域性變量表中第 0 位索引的 Slot 預設用於傳遞方法所屬物件例項的引用,在方法中可通過關鍵字“this”訪問到這個隱含的引數。其餘引數則按照引數表順序排列,引數表分配完畢後,再根據方法體內部定義的變數順序和作用域分配其餘的 Slot。 ### 2、運算元棧 運算元棧(Operand Stack)也稱為操作棧,它是一個後入先出的棧。運算元棧的每一個元素可以是任意的 Java 資料型別。 當一個方法剛開始執行時,這個方法的運算元棧是空的,在方法執行過程中,會有各種位元組碼指令往運算元棧中寫入和提取內容,這就是出棧/入棧操作。 在概念模型中,兩個棧幀作為虛擬機器棧的元素,是完全獨立的。但在大多數虛擬機器的實現裡會做一些優化處理,令兩個棧幀出現一部分重疊。這樣在進行方法呼叫時就可以共用一部分資料,無須進行額外的引數複製傳遞。 ### 3、動態連線 每個棧幀都包含一個指向執行時常量池中,該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線(Dynamic Linking)。 * 靜態解析:在類載入階段或第一次使用時,將符號引用轉化為直接引用。 * 動態連線:在每一次執行期間,將符號引用轉化為直接引用。 ### 4、方法返回地址 **兩種退出方法的方式:** * **正常完成出口:** 執行引擎遇到任意一個方法返回的位元組碼指令,此時可能會有返回值傳遞給上層的方法呼叫者。 * **異常完成出口:** 方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,這種退出方式不會有返回值。。 無論何種退出方式,方法退出後都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層方法的執行狀態。 一般來說,方法正常退出時,呼叫者的 PC 計數器的值可以作為返回地址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,返回地址要通過異常處理器表來確定,棧幀中一般不會儲存這部分資訊。 方法退出的過程實際上等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的區域性變量表和運算元棧,把返回值(有的話)壓入呼叫者棧幀的運算元棧中,調整 PC 計數器的值以指向方法呼叫指令後面的一條指令等。 ### 5、附加資訊 虛擬機器規範允許具體的虛擬機器實現增加一些規範裡沒有描述的資訊到棧幀中,例如與除錯相關的資訊。 ## 二、方法呼叫 方法呼叫並不等於方法執行,方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法),暫時還不涉及方法內部的具體執行過程。 一切方法呼叫在 Class 檔案裡儲存的只是符號引用,而不是直接引用,只有在類載入期間,甚至是執行期間才能確定目標方法的直接引用。 **方法呼叫位元組碼指令:** * invokestatic:呼叫靜態方法。 * invokespecial:呼叫例項構造器 init() 方法、私有方法、父類方法。 * invokevirtual:呼叫所有虛方法。 * invokeinterface:呼叫介面方法,會在執行時再確定一個實現此介面的物件。 * invokedynamic:先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法。 ### 1、解析 在類載入的解析階段,將方法的符號引用轉化為直接引用,這類方法呼叫稱為解析。這種解析能成立的前提是:方法在程式執行之前有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期不可改變,即“編譯期可知,執行期不可變”。 只要能被 invokestatic 和 invokespecial 指令呼叫的方法,都可以在解析階段確定唯一的呼叫版本,因此都能在類載入階段被解析。這些方法稱為非虛方法,與之相反,其他方法稱為虛方法。 final 方法雖然是使用 invokevirtual 指令呼叫的,但由於它無法被覆蓋,沒有其他版本,所以無須對方法接收者進行多型選擇。因此,fanal 方法也屬於非虛方法。 ### 2、分派 #### (1)靜態分派 依賴靜態型別(又稱外觀型別)來定位方法執行版本的分派動作,稱為靜態分派。靜態分派的典型應用是方法過載。 靜態型別是編譯期可知的。 靜態分派發生在編譯階段,因此確定靜態分派的動作不是由虛擬機器來執行的。 #### (2)動態分派 在執行期根據實際型別確定方法執行版本的分派過程,稱為動態分派。動態分派的典型應用是方法重寫。 實際型別是在執行期才可確定。 動態分派是非常頻繁的動作,而且執行時需要在類的方法元資料中搜索合適的目標方法。基於效能的考慮,大部分的虛擬機器實現都不會真正地進行如此頻繁的搜尋。最常用的“穩定優化”手段是為類在方法區中建立一個虛方法表,使用虛方法表索引來代替元資料查詢以提高效能。 虛方法表中存放著各個方法的實際入口地址。 #### (3)單分派與多分派 根據分派基於多少種宗量,可以將分派劃分為單分派和多分派。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。 方法的接收者與方法的引數統稱為方法的宗量。 靜態分派是根據方法接收者的靜態型別和方法引數來選擇目標方法的,因此靜態分派屬於多分派型別。 動態分派只根據方法接收者的例項型別來選擇目標方法,因此動態分派屬於單分派型別。 ## 三、基於棧的位元組碼解釋執行引擎 ### 1、編譯過程 ![](https://img2020.cnblogs.com/blog/1613877/202003/1613877-20200315232536410-297751954.jpg) 如今,基於物理機、虛擬機器的語言,大多都會遵循基於現代經典編譯原理的思路,在執行前先對程式原始碼進行詞法分析和語法分析處理,把原始碼轉化為抽象語法樹。 對於一門具體語言的實現來說,詞法分析、語法分析以及後面的優化器和目的碼生成器都可以選擇獨立於執行引擎,形成一個完整意義的編譯器去實現,這類代表是 C/C++ 語言。也可以選擇把其中的一部分(如生成抽象語法樹之前的步驟)實現為一個半獨立的編譯器,這類代表是 Java 語言。又或者把這些步驟和執行引擎全部集中封裝在一個封閉的黑匣子之中,如大多數的 JavaScript 執行器。 Java 語言中,Javac 編譯器完成了程式程式碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的位元組碼指令流的過程。因為這一部分動作是在 Java 虛擬機器之外進行的,而直譯器在虛擬機器內部,所以 Java 程式的編譯就是半獨立的實現。 ### 2、解釋執行 Java 語言經常被定位為“解釋執行”的語言,在 Java 初生的 JDK1.0 時代,這種定義還算準確,但當主流的虛擬機器中包含了即時編譯器後,Class 檔案中的程式碼到底會被解釋執行還是編譯執行,就成了只有虛擬機器自己才能準確判斷的事情。 ### 3、基於棧的指令集與基於暫存器的指令集 Java 編譯器輸出的指令流,基本上是一種基於棧的指令集架構,指令流中的指令大部分是零地址指令,它們依賴運算元棧進行工作。與之相對的另外一套常用的指令集架構是基於暫存器的指令集,最典型的就是 x86 的二地址指令集,這些指令依賴暫存器進行工作。 * 基於棧的指令集:可移植,但執行速度相對較慢。 * 基於暫存器的指令集:執行速度快,但由於暫存器由硬體直接提供,程式不可避免要受到硬體的