1. 程式人生 > >深入理解Java虛擬機器 | 第六篇:虛擬機器位元組碼執行引擎

深入理解Java虛擬機器 | 第六篇:虛擬機器位元組碼執行引擎

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

一:執行時棧幀結構

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


二:區域性變量表

區域性變量表的容量以變數槽(Variable Slot)為最小單位,虛擬機器規範中並沒有明確指明一個Slot暫用的記憶體空間大小,只是很有“導向性”地說明每個Slot都應該能存放一個32位以內的boolean,byte,char,short,int,float,refrence,returnAddress這8種類型的資料。

對於64位的資料型別,虛擬機器會以高位在前的方式為其分配兩個連續的Slot空間。Java語言中明確的(reference型別則可能是32位也可能是64位)64位的資料型別只有long和double兩種。

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

三:運算元棧
後進先出(Last-In-First-Out),也可以稱之為表示式棧(Expression Stack)。運算元棧和區域性變量表在訪問方式上存在著較大差異,運算元棧並非採用訪問索引的方式來進行資料訪問的,而是通過標準的入棧和出棧操作來完成一次資料訪問。每一個運算元棧都會擁有一個明確的棧深度用於儲存數值,一個32bit的數值可以用一個單位的棧深度來儲存,而2個單位的棧深度則可以儲存一個64bit的數值,當然運算元棧所需的容量大小在編譯期就可以被完全確定下來,並儲存在方法的Code屬性中。
請看下面的執行加法運算的位元組碼指令:
public void testAddOperation();  
         Code:  
          0: bipush        15  
     2: istore_1  
     3: bipush        8  
     5: istore_2  
     6: iload_1  
     7: iload_2  
     8: iadd  
     9: istore_3  
     10: return 

在上述位元組碼指令示例中,首先會由“bipush”指令將數值15從byte型別轉換為int型別後壓入運算元棧的棧頂(對於byte、short和char型別的值在入棧之前,會被轉換為int型別),當成功入棧之後,“istore_1”指令便會負責將棧頂元素出棧並存儲在區域性變量表中訪問索引為1的Slot上。接下來再次執行“bipush”指令將數值8壓入棧頂後,通過“istore_2”指令將棧頂元素出棧並存儲在區域性變量表中訪問索引為2的Slot上。“iload_1”和“iload_2”指令會負責將區域性變量表中訪問索引為1和2的Slot上的數值15和8重新壓入運算元棧的棧頂,緊接著“iadd”指令便會將這2個數值出棧執行加法運算後再將運算結果重新壓入棧頂,“istore_3”指令會將運算結果出棧並存儲在區域性變量表中訪問索引為3的Slot上。最後“return”指令的作用就是方法執行完成之後的返回操作。在運算元棧中,一項運算通常由多個子運算(subcomputation)巢狀進行,一個子運算過程的結果可以被其他外圍運算所使用。

四:動態連結

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

五:方法返回地址

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

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

六:附加資訊

虛擬機器規範允許具體的虛擬機器實現增加一些規範裡沒有描述的資訊到棧幀之中。

七:方法呼叫
方法呼叫並不等同於方法執行,方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法),暫時還不涉及方法內部的具體執行過程。在程式執行時,進行方法呼叫是最普遍、最頻繁的操作,但我們知道,Class檔案的編譯過程中不包含傳統編譯中的連線步驟,一切方法呼叫在Class檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體佈局中的入口地址(相當於之前說的直接引用)。
7.1、解析:所有方法呼叫中的目標方法在Class檔案裡面都是一個常量池中的符號引用,在類載入的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。換句話說,呼叫目標在程式程式碼寫好、編譯器進行編譯時就必須確定下來。這類方法的呼叫稱為解析(Resolution)。在Java語言中符合“編譯期可知,執行期不可變”這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與型別直接關聯,後者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類載入階段進行解析。7.2、分派:眾所周知,Java是一門面向物件的程式語言,因為Java具備面向物件的3個基本特徵:繼承、封裝和多型。其中分派呼叫過程將會揭示多型性特徵的一些最基本的體現,如“過載”和“重寫”在Java虛擬機器之中是如何實現的,這裡的實現當然不是語法上該如何寫,我們關心的依然是虛擬機器如何確定正確的目標方法。

7.2.1、靜態分派:

/**
 * @author 2018年7月6日15:53:21
 */
public class StaticDispatchTest {

        static abstract class Human{
        }
        static class Man extends Human{
        }
        static class Woman extends Human{
        }
        public void sayHello(Human guy){
            System.out.println("hello,guy!");
        }
        public void sayHello(Man guy){
            System.out.println("hello,gentleman!");
        }
        public void sayHello(Woman guy){
            System.out.println("hello,lady!");
        }
        public static void main(String[]args){
            Human man=new Man();
            Human woman=new Woman();
            StaticDispatchTest sr=new StaticDispatchTest();
            sr.sayHello(man);
            sr.sayHello(woman);
        }
}

輸出結果:


我們把上面程式碼中的“Human”稱為變數的靜態型別(Static Type),或者叫做的外觀型別(Apparent Type),後面的“Man”則稱為變數的實際型別(Actual Type),靜態型別和實際型別在程式中都可以發生一些變化,區別是靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的;而實際型別變化的結果在執行期才可確定,編譯器在編譯程式的時候並不知道一個物件的實際型別是什麼。

 public static void main(String[]args){
            Human man=new Man();
            Human woman=new Woman();
            StaticDispatchTest sr=new StaticDispatchTest();
            sr.sayHello((Man) man);
            sr.sayHello((Woman) woman);
        }

那麼輸出結果就是:


所有依賴靜態型別來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用是方法過載。

重灌方法按照一定的優先順序進行匹配,順序按照虛擬機器能夠把你傳進去的量進行自動型別轉換的優先順序,請看下面程式碼:

/**
 * @author 2018年7月6日16:25:26
 */
public class OverLoadTest {
        public static void sayHello(Object arg){
            System.out.println("hello Object");
        }
        public static void sayHello(int arg){
            System.out.println("hello int");
        }
        public static void sayHello(long arg){
            System.out.println("hello long");
        }
        public static void sayHello(Character arg){
            System.out.println("hello Character");
        }
        public static void sayHello(char arg){
            System.out.println("hello char");
        }
        public static void sayHello(char... arg){
            System.out.println("hello char……");
        }
        public static void sayHello(Serializable arg){
            System.out.println("hello Serializable");
        }
        public static void main(String[]args){
            sayHello('a');
        }
}

上面這行程式碼,每次註釋掉不同的方法,得出的輸出結果完全不同,系統進行型別轉換,按照匹配的型別進行輸出。

7.2.2、動態分派:瞭解了靜態分派,我們接下來看一下動態分派的過程,它和多型性的另外一個重要體現 ——重寫(Override)有著很密切的關聯。

/**
 * 演示動態分配
 * @author 2018年7月6日16:33:58
 */
public class DynamicDispatchTest {

    static abstract class Human{
        protected abstract void sayHello();
    }
    static class Man extends Human{
        @Override
        protected void sayHello(){
            System.out.println("man say hello");
        }
    }
    static class Woman extends Human{
        @Override
        protected void sayHello(){
            System.out.println("woman say hello");
        }
    }
    public static void main(String[]args){
        Human man=new Man();
        Human woman=new Woman();
        man.sayHello();
        woman.sayHello();
        man=new Woman();
        man.sayHello();
    }
}

輸出結果:


那麼虛擬機器如何知道要呼叫哪個方法呢?

我們之前文章講過位元組碼指令,我們在呼叫方法的時候會生成invokevirtual指令,這個指令執行步驟如下:

1)找到運算元棧頂的第一個元素所指向的物件的實際型別,記作C。
2)如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查詢過程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
3)否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜尋和驗證過程。

4)如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。

由於invokevirtual指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。我們把這種在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。

7.2.3、單分派和多分派

/**
 * 測試單分派多分派
 * @author 2018年7月6日16:51:23
 */
public class SingleOrMoreDispatchTest {
    static class QQ{}
    static class _360{}
    public static class Father{
        public void hardChoice(QQ arg){
            System.out.println("father choose qq");
        }
        public void hardChoice(_360 arg){
            System.out.println("father choose 360");
        }
    }
    public static class Son extends Father{
        @Override
        public void hardChoice(QQ arg){
            System.out.println("son choose qq");
        }
        @Override
        public void hardChoice(_360 arg){
            System.out.println("son choose 360");
        }
    }
    public static void main(String[]args){
        Father father=new Father();
        Father son=new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }

}

來看看編譯階段編譯器的選擇過程,也就是靜態分派的過程。這時選擇目標方法的依據有兩點:一是靜態型別是Father還是Son,二是方法引數是QQ還是360。這次選擇結果的最終產物是產生了兩條invokevirtual指令,兩條指令的引數分別為常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符號引用。因為是根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派型別。

再看看執行階段虛擬機器的選擇,也就是動態分派的過程。在執行“son.hardChoice(newQQ())”這句程式碼時,更準確地說,是在執行這句程式碼所對應的invokevirtual指令時,由於編譯期已經決定目標方法的簽名必須為hardChoice(QQ),虛擬機器此時不會關心傳遞過來的引數“QQ”到底是“騰訊QQ”還是“奇瑞QQ”,因為這時引數的靜態型別、實際型別都對方法的選擇不會構成任何影響,唯一可以影響虛擬機器選擇的因素只有此方法的接受者的實際型別是Father還是Son。因為只有一個宗量作為選擇依據,所以Java語言的動態分派屬於單分派型別。

7.2.4、虛擬機器動態分配的實現

由於動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要執行時在類的方法元資料中搜索合適的目標方法,因此在虛擬機器的實際實現中基於效能的考慮,大部分實現都不會真正地進行如此頻繁的搜尋。面對這種情況,最常用的“穩定優化”手段就是為類在方法區中建立一個虛方法表(Vritual  Method  Table,也稱為vtable,與此對應的,在invokeinterface執行時也會用到介面方法表——Inteface Method Table,簡稱itable),使用虛方法表索引來代替元資料查詢以提高效能。


虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。

八:基於棧的位元組碼解釋執行引擎
許多Java虛擬機器的執行引擎在執行Java程式碼的時候都有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生代碼執行)兩種選擇。


Java語言中,Javac編譯器完成了程式程式碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的位元組碼指令流的過程。因為這一部分動作是在Java虛擬機器之外進行的,而直譯器在虛擬機器的內部,所以Java程式的編譯就是半獨立的實現。中間的那條分支,自然就是解釋執行的過程。

九:基於棧的指令集與基於暫存器的指令集
基於棧的指令集主要的優點就是可移植,暫存器由硬體直接提供 ,程式直接依賴這些硬體暫存器則不可避免地要受到硬體的約束。例如,現在32位80x86體系的處理器中提供了8個32位的暫存器,而ARM體系的CPU(在當前的手機、PDA中相當流行的一種處理器)則提供了16個32位的通用暫存器。如果使用棧架構的指令集,使用者程式不會直接使用這些暫存器,就可以由虛擬機器實現來自行決定把一些訪問最頻繁的資料(程式計數器、棧頂快取等)放到暫存器中以獲取儘量好的效能,這樣實現起來也更加簡單一些。棧架構的指令集還有一些其他的優點,如程式碼相對更加緊湊(位元組碼中每個位元組就對應一條指令,而多地址指令集中還需要存放參數)、編譯器實現更加簡單(不需要考慮空間分配的問題,所需空間都在棧上操作)等。

雖然棧架構指令集的程式碼非常緊湊,但是完成相同功能所需的指令數量一般會比暫存器架構多,因為出棧、入棧操作本身就產生了相當多的指令數量。更重要的是,棧實現在記憶體之中,頻繁的棧訪問也就意味著頻繁的記憶體訪問,相對於處理器來說,記憶體始終是執行速度的瓶頸。儘管虛擬機器可以採取棧頂快取的手段,把最常用的操作對映到暫存器中避免直接記憶體訪問,但這也只能是優化措施而不是解決本質問題的方法。由於指令數量和記憶體訪問的原因,所以導致了棧架構指令集的執行速度會相對較慢。所以主流物理機的指令集都是暫存器架構。