Java虛擬機字節碼執行引擎是jvm最核心的組成部分之一,它做的事情很簡單:輸入的是字節碼文件,處理過程是字節碼解析的等效過程,輸出的是執行結果。在不同的虛擬機實現裡,執行引擎在執行java代碼的時候可能會有解釋執行和編譯執行兩種選擇,也可能兩者兼備。
java字節碼執行引擎在調用和執行方法的時候使用了一種叫做棧幀的數據結構。
在jvm的內存結構裡,存在這一塊稱為虛擬機棧的內存區域,虛擬機棧中的元素就是棧幀。每個方法的調用至結束對應著一個棧幀在虛擬機棧的入棧和出棧。
棧幀數據結構示意圖:
如圖所示,因為虛擬機棧是線程私有的,所以每個線程都有自己的虛擬機棧;而每個線程的虛擬機棧中都有多個棧幀對應一個方法調用鏈中的多個方法,棧頂的棧幀是當前正在執行的方法;每個棧幀主要包含4個部分:
在編譯代碼的階段,棧幀中需要多大的局部變量表,多深的操作數棧都是已經完全確定的,而且會寫入到方法表的Code屬性中。
接下來一次介紹棧幀中的4個部分:
局部變量表是一組變量值存儲空間,用於存放方法參數和方法內局部變量。
局部變量表的容量以變量槽Slot為最小單位,規定一個Slot可以存放一個32位以內的數據類型,java中占32位以內的數據類型有8種基本數據除了long和double以外的6種,還有reference和returnAddress,共8種類型。而對於64位的數據類型,即long和double,則會以高位對齊的方式為其分配兩個連續的Slot空間。
在方法執行時,方法的參數列表是通過局部變量表傳遞的,如果執行的是實例方法(非static方法),則局部變量表中的第0位索引的Slot默認保存方法所屬對象的引用,以支持“this”關鍵字來訪問對象數據。其余參數則按參數列表順序,占用從1開始的局部變量Slot,參數表分配完成後,在根據方法體內局部變量定義的順序和作用域為局部變量分配其余Slot。之所以這裡要強調作用域是因為為了節省棧幀空間,局部變量表中的Slot是可以重用的,當一個Slot中保存的局部變量超出其作用域後,這個Slot可以被復用來存儲新的變量。
操作數棧是用來執行字節碼命令的,比如iadd命令在運行的時候,就是把操作數棧中最接近棧頂的2個元素出棧相加,然後將結果入棧。
操作數棧的每一個元素可以是任意的java數據類型,32位數據所占棧容量為1,64位數據所占棧容量為2。
java虛擬機的解釋執行引擎是“基於棧的執行引擎”,這裡的棧就是指操作數棧。
一個指向運行時常量池的引用,用來支持當前方法的代碼實現動態鏈接。
一個方法開始後,只有兩種方式可以退出,一種是當執行引擎遇到任意一個返回字節碼指令的時候,另一種是在執行中遇到異常並且沒有捕獲的時候。無論以哪種方式退出,退出後都需要返回到方法被調用的位置,因此需要在棧幀中保存一些信息,用來恢復它上層方法的執行狀態。
方法調用不等於方法執行,方法調用階段的唯一目的就是確定被調用方法的版本。
我們知道,在虛擬機進行類加載的時候,有一個階段叫做“解析”,在解析階段會將常量池中的一部分符號引用轉化為直接引用,在這個階段,有一部分方法調用就已經被解析為了直接引用,解析的前提是,這部分方法的調用目標在編譯階段就可以確定下來。
在java虛擬機中共有5個方法調用指令:
可以在“解析”階段就確認調用目標的有invokestatic和invokespecial指令調用的方法以及invokevirtual調用的final方法,這些方法被稱為“非虛方法”,與之相反的被稱為“虛方法”。
分派分為“靜態分派”和“動態分派”。
靜態分派
靜態分派用來實現java語言的"重載"特性。
當我們聲明一個變量時,可能會用到這種形式:Human man=new Man();(Man是Human子類),對於man這個變量來說,Human叫做它的靜態類型,Man叫做它的實際類型。
"重載"是基於靜態類型。也就是說,對於相同名稱,參數列表不同的方法,選擇哪個方法取決於參數列表中參數的靜態類型,而靜態類型在編譯時是可知的,因此靜態分派發生在編譯階段。
動態分派
動態分派用來實現java語言的“重寫”特性,動態分派對應invokevirtual命令。
invokevirtual命令的執行過程如下:
我們可以看出invokevirtual指令執行的時候是由對象的實際類型決定調用哪個方法版本的,而對象的實際類型只有到運行期的時候才能確定,我們把這種在運行期確定方法版本的分派過程稱為“動態分派”。
方法的接收者與方法的參數統稱為方法的宗量,根據分派基於宗量的多少,將分派分為單分派和多分派兩種。
目前的java語言中,靜態分派的時候,是基於接收者和方法參數兩個宗量進行的,因此靜態分派是多分派;動態分派的時候,是基於接收者一個宗量進行的,因此動態分派是單分派。
一般從程序代碼到物理機可以運行的目標代碼都要經歷下圖所示的過程:
圖中展示了編譯執行和解釋執行這兩種路徑,無論是哪種執行方式,一般都會先通過詞法分析和語法分析把源代碼轉化為抽象語法樹(AST)。
然後從抽象語法樹節點開始,中間的分支代表的是解釋執行過程,下邊的分支代表的是編譯執行過程。
jvm執行字節碼是采用的解釋執行,javac編譯器完成了程序代碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的字節碼指令流的過程。而解釋器則在虛擬機的內部。
javac編譯器輸出的字節碼指令流是一種基於棧的指令集架構,指令流中的大部分指令都是零地址指令,它們依賴操作數棧進行工作。與之相對的是基於寄存器的指令集。
基於棧的指令集有以下優點:
基於棧的指令集的主要缺點就是執行速度慢:一個是因為基於棧完成相同功能所需要的指令集數量一般比基於寄存器要多,因為入棧、出棧會產生多余的指令數量;另一個是因為棧是基於內存實現的,內存的讀取速度本身就比處理器寄存器要慢,這一點可以采取棧頂緩存的手段,把最常用的操作映射到寄存器中避免直接訪問內存,但是畢竟只是優化措施,不能解決本質問題。
執行方法中的代碼本質就是通過棧的指令集來進行運算,這裡就不詳述了。