深入理解JVM位元組碼執行引擎

劉美利發表於2018-07-30

我們都知道,在當前的Java中(1.0)之後,編譯器講原始碼轉成位元組碼,那麼位元組碼如何被執行的呢?這就涉及到了JVM的位元組碼執行引擎,執行引擎負責具體的程式碼呼叫及執行過程。就目前而言,所有的執行引擎的基本一致:

  1. 輸入:位元組碼檔案

  2. 處理:位元組碼解析

  3. 輸出:執行結果。

物理機的執行引擎是由硬體實現的,和物理機的執行過程不同的是虛擬機器的執行引擎由於自己實現的。


執行時候的棧結構

每一個執行緒都有一個棧,也就是前文中提到的虛擬機器棧,棧中的基本元素我們稱之為棧幀。棧幀是用於支援虛擬機器進行方法呼叫和方法執行的資料結構。每個棧幀都包括了一下幾部分:區域性變數表、運算元棧、動態連線、方法的返回地址 和一些額外的附加資訊。棧幀中需要多大的區域性變數表和多深的運算元棧在編譯程式碼的過程中已經完全確定,並寫入到方法表的Code屬性中。在活動的執行緒中,位於當前棧頂的棧幀才是有效的,稱之為當前幀,與這個棧幀相關聯的方法稱為當前方法。執行引擎執行的所有位元組碼指令只針對當前棧幀進行操作。需要注意的是一個棧中能容納的棧幀是受限,過深的方法呼叫可能會導致StackOverFlowError,當然,我們可以認為設定棧的大小。其模型示意圖大體如下: 


針對上面的棧結構,我們重點解釋一下區域性變數表,操作棧,指令計數器幾個概念:

1、區域性變數表

是變數值的儲存空間,由方法引數和方法內部定義的區域性變數組成,其容量用Slot1作為最小單位。在編譯期間,就在方法的Code屬性的max_locals資料項中確定了該方法所需要分配的區域性變數表的最大容量。由於區域性變數表是建立線上程的棧上,是執行緒的私有資料,因此不存在資料安全問題。在方法執行時,虛擬機器透過使用區域性變數表完成引數值到引數變數列表的傳遞過程。如果是例項方法,那區域性變數表第0位索引的Slot儲存的是方法所屬物件例項的引用,因此在方法內可以透過關鍵字this來訪問到這個隱含的引數。其餘的引數按照參數列順序排列,參數列分配完畢之後,再根據方法體內定義的變數的順序和作用域分配。我們知道類變數表有兩次初始化的機會,第一次是在“準備階段”,執行系統初始化,對類變數設定零值,另一次則是在“初始化”階段,賦予程式設計師在程式碼中定義的初始值。和類變數初始化不同的是,區域性變數表不存在系統初始化的過程,這意味著一旦定義了區域性變數則必須人為的初始化,否則無法使用。舉例說明:

為了方便起見,假設以上兩段程式碼在同一個類中。這時call()所對應的棧幀中的區域性變數表大體如下: 
 
而call2()所對應的棧幀的區域性變數表大體如下: 


2、運算元棧

後入先出棧,由位元組碼指令往棧中存資料和取資料,棧中的任何一個元素都是可以任意的Java資料型別。和區域性變數類似,運算元棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks資料項中。當一個方法剛開始執行的時候,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令往運算元中寫入和提取內容,也就是出棧/入棧操作。運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配2,這由編譯器在編譯器期間進行驗證,同時在類載入過程中的類檢驗階段的資料流分析階段要再次驗證。另外我們說Java虛擬機器的解釋引擎是基於棧的執行引擎,其中的棧指的就是運算元棧。


3、動態連線

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


4、方法返回地址

存放呼叫呼叫該方法的pc計數器的值。當一個方法開始之後,只有兩種方式可以退出這個方法:1、執行引擎遇到任意一個方法返回的位元組碼指令,也就是所謂的正常完成出口。2、在方法執行的過程中遇到了異常,並且這個異常沒有在方法內進行處理,也就是隻要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種方式成為異常完成出口。正常完成出口和異常完成出口的區別在於:透過異常完成出口退出的不會給他的上層呼叫者產生任何的返回值。 
無論透過哪種方式退出,在方法退出後都返回到該方法被呼叫的位置,方法正常退出時,呼叫者的pc計數器的值作為返回地址,而透過異常退出的,返回地址是要透過異常處理器表來確定,棧幀中一般不會儲存這部分資訊。本質上,方法的退出就是當前棧幀出棧的過程。


方法呼叫

方法呼叫的主要任務就是確定被呼叫方法的版本(即呼叫哪一個方法),該過程不涉及方法具體的執行過程。按照呼叫方式共分為兩類:

  1. 解析呼叫是靜態的過程,在編譯期間就完全確定目標方法。

  2. 分派呼叫即可能是靜態,也可能是動態的,根據分派標準可以分為單分派和多分派。兩兩組合有形成了靜態單分派、靜態多分派、動態單分派、動態多分派

解析

在Class檔案中,所有方法呼叫中的目標方法都是常量池中的符號引用,在類載入的解析階段,會將一部分符號引用轉為直接引用,也就是在編譯階段就能夠確定唯一的目標方法,這類方法的呼叫成為解析呼叫。此類方法主要包括靜態方法和私有方法兩大類,前者與型別直接關聯,後者在外部不可訪問,因此決定了他們都不可能透過繼承或者別的方式重寫該方法,符合這兩類的方法主要有以下幾種:靜態方法、私有方法、例項構造器、父類方法。虛擬機器中提供了以下幾條方法呼叫指令:

  1. invokestatic:呼叫靜態方法,解析階段確定唯一方法版本

  2. invokespecial:呼叫 <init> 方法、私有及父類方法,解析階段確定唯一方法版本

  3. invokevirtual:呼叫所有虛方法

  4. invokeinterface:呼叫介面方法

  5. invokedynamic:動態解析出需要呼叫的方法,然後執行

前四條指令固化在虛擬機器內部,方法的呼叫執行不可認為干預,而invokedynamic指令則支援由使用者確定方法版本。其中invokestatic指令和invokespecial指令呼叫的方法稱為非虛方法,其餘的(final修飾的除外[^footnote4])稱為虛方法。

分派

分派呼叫更多的體現在多型上。

  1. 靜態分派:所有依賴靜態型別3來定位方法執行版本的分派成為靜態分派,發生在編譯階段,典型應用是方法 過載

  2. 動態分派:在執行期間根據實際型別4來確定方法執行版本的分派成為動態分派,發生在程式執行期間,典型的應用是方法的 重寫

  3. 單分派:根據一個宗量5 對目標方法進行選擇。

  4. 多分派:根據多於一個宗量對目標方法進行選擇。

JVM實現動態分派

動態分派在Java中被大量使用,使用頻率及其高,如果在每次動態分派的過程中都要重新在類的方法後設資料中搜尋合適的目標的話就可能影響到執行效率,因此JVM在類的方法區中建立虛方法表(virtual method table)來提高效能。每個類中都有一個虛方法表,表中存放著各個方法的實際入口。如果某個方法在子類中沒有被重寫,那子類的虛方法表中該方法的地址入口和父類該方法的地址入口一樣,即子類的方法入口指向父類的方法入口。如果子類重寫父類的方法,那麼子類的虛方法表中該方法的實際入口將會被替換為指向子類實現版本的入口地址。 
那麼虛方法表什麼時候被建立?虛方法表會在類載入的連線階段被建立並開始初始化,類的變數初始值準備完成之後,JVM會把該類的方法表也初始化完畢。


方法的執行

解釋執行

在jdk 1.0時代,Java虛擬機器完全是解釋執行的,隨著技術的發展,現在主流的虛擬機器中大都包含了即時編譯器(JIT)。因此,虛擬機器在執行程式碼過程中,到底是解釋執行還是編譯執行,只有它自己才能準確判斷了,但是無論什麼虛擬機器,其原理基本符合現代經典的編譯原理,如下圖所示: 
 
在Java中,javac編譯器完成了詞法分析、語法分析以及抽象語法樹的過程,最終遍歷語法樹生成線性位元組碼指令流的過程,此過程發生在虛擬機器外部。

基於棧的指令集與基於暫存器的指令集

Java編譯器輸入的指令流基本上是一種基於 的指令集架構,指令流中的指令大部分是零地址指令,其執行過程依賴於操作棧。另外一種指令集架構則是基於 暫存器 的指令集架構,典型的應用是x86的二進位制指令集,比如傳統的PC以及Android的Davlik虛擬機器。兩者之間最直接的區別是,基於棧的指令集架構不需要硬體的支援,而基於暫存器的指令集架構則完全依賴硬體,這意味基於暫存器的指令集架構執行效率更高,單可移植性差,而基於棧的指令集架構的移植性更高,但執行效率相對較慢,初次之外,相同的操作,基於棧的指令集往往需要更多的指令,比如同樣執行2+3這種邏輯操作,其指令分別如下: 
基於棧的計算流程(以Java虛擬機器為例):

而基於暫存器的計算流程:


基於棧的程式碼執行示例

下面我們用簡單的案例來解釋一下JVM程式碼執行的過程,程式碼例項如下:

使用javap指令檢視位元組碼:

執行過程中程式碼、運算元棧和區域性變數表的變化情況如下: 


  1. 也成為容量槽,虛擬規範中並沒有規定一個Slot應該佔據多大的記憶體空間。 

  2. 這裡的嚴格匹配指的是位元組碼操作的棧中的實際元素型別必須要位元組碼規定的元素型別一致。比如iadd指令規定操作兩個整形資料,那麼在操作棧中的實際元素的時候,棧中的兩個元素也必須是整形。 

  3. Animal dog=new Dog();其中的Animal我們稱之為靜態型別,而Dog稱之為動態型別。兩者都可以發生變化,區別在於靜態型別只在使用時發生變化,變數本身的靜態型別不會被改變,最終的靜態型別是在編譯期間可知的,而實際型別則是在執行期才可確定。 

  4. Animal dog=new Dog();其中的Animal我們稱之為靜態型別,而Dog稱之為動態型別。兩者都可以發生變化,區別在於靜態型別只在使用時發生變化,變數本身的靜態型別不會被改變,最終的靜態型別是在編譯期間可知的,而實際型別則是在執行期才可確定。 

  5. 宗量:方法的接受者與方法的引數稱為方法的宗量。 
    舉個例子: 
    public void dispatcher(){ 
    int result=this.execute(8,9); 

    public void execute(int pointX,pointY){ 
    //TODO 


    在dispatcher()方法中呼叫了execute(8,9),那此時的方法接受者為當前this指向的物件,8、9為方法的引數,this物件和引數就是我們所說的宗量。

【本文轉載自Java高階架構師,原文連結:https://mp.weixin.qq.com/s/rXdd7zEJxY4SBSSAg5Dw3w,轉載授權請聯絡原作者】

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31542119/viewspace-2168644/,如需轉載,請註明出處,否則將追究法律責任。

相關文章