深刻理解Java虛擬機(字節碼執行引擎)

本文首發於微信公衆號:BaronTalkjava

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

在 Java 虛擬機規範中制定了虛擬機字節碼執行引擎的概念模型,這個概念模型成爲各類虛擬機執行引擎的統一外觀(Facade)。在不一樣的虛擬機實現裏,執行引擎在執行 Java 代碼的時候可能會有解釋執行(經過解釋器執行)和編譯執行(經過即時編譯器產生本地代碼執行)兩種方式,也可能二者都有,甚至還可能會包含幾個不一樣級別的編譯器執行引擎。但從外觀上來看,全部 Java 虛擬機的執行引擎是一致的:輸入的是字節碼文件,處理過程是字節碼解析的等效過程,輸出的是執行結果。程序員

一. 運行時棧幀結構

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

每個棧幀都包括了局部變量表、操做數棧、動態連接、方法返回地址和一些額外的附加信息。在編譯程序代碼時,棧幀中須要多大的局部變量表,多深的操做數棧都已經徹底肯定了,而且寫入到方法表的 Code 屬性之中,所以一個棧幀須要分配多少內存,不會受到程序運行期變量數據的影響,而僅僅取決於具體的虛擬機實現。數組

一個線程中的方法調用鏈可能會很長,不少方法都處於執行狀態。對於執行引擎來講,在活動線程中,只有位於棧頂的棧幀纔是有效的,稱爲當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法成爲當前方法。執行引擎運行的全部字節碼指令對當前棧幀進行操做,在概念模型上,典型的棧幀結構以下圖:安全

局部變量表

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

操做數棧

操做數棧(Operand Stack)是一個後進先出棧。同局部變量表同樣,操做數棧的最大深度也在編譯階段寫入到 Code 屬性的 max_stacks 數據項中。操做數棧的每個元素能夠是任意的 Java 數據類型,包括 long 和 double。32 位數據類型所佔的棧容量爲 1,64 位數據類型所佔的棧容量爲 2。在方法執行的任什麼時候候,操做數棧的深度都不會超過 max_stacks 數據項中設定的最大值。微信

一個方法剛開始執行的時候,該方法的操做數棧是空的,在方法的執行過程當中,會有各類字節碼指令往操做數棧中寫入和提取內容,也就是入棧和出棧操做。數據結構

動態連接

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

方法返回地址

當一個方法開始執行後,只有兩種方式能夠退出這個方法。

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

另外一種退出方式是,在方法執行過程當中遇到了異常,而且這個異常沒有在方法體內獲得處理,不管是 Java 虛擬機內部產生的異常,仍是代碼中使用 athrow 字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會致使方法退出。這種稱爲異常完成出口。一個方法使用異常完成出口的方式退出,是不會給上層調用者產生任何返回值的。

不管採用何種退出方式,在方法退出後都須要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能須要在棧幀中保存一些信息,用來恢復它的上層方法的執行狀態。通常來講,方法正常退出時,調用者的 PC 計數器的值能夠做爲返回地址,棧幀中極可能會保存這個計數器值。而方法異常退出時,返回地址是要經過異常處理器表來肯定的,棧幀中通常不會保存這部分信息。

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

附加信息

虛擬機規範容許具體的虛擬機實現增長一些規範裏沒有描述的信息到棧幀中,例如與調試相關的信息,這部分信息徹底取決於具體的虛擬機實現。實際開發中,通常會把動態鏈接、方法返回地址與其餘附加信息所有歸爲一類,成爲棧幀信息。

二. 方法調用

方法調用並不等同於方法執行,方法調用階段惟一的任務就是肯定被調用方法的版本(即調用哪個方法),暫時還不涉及方法內部的具體運行過程。

在程序運行時,進行方法調用是最爲廣泛、頻繁的操做。前面說過 Class 文件的編譯過程是不包含傳統編譯中的鏈接步驟的,一切方法調用在 Class 文件裏面存儲的都只是符號引用,而不是方法在運行時內存佈局中的入口地址(至關於以前說的直接引用)。這個特性給 Java 帶來了更強大的動態擴展能力,但也使得 Java 方法調用過程變得相對複雜起來,須要在類加載期間,甚至到運行期間才能肯定目標方法的直接引用。

解析

全部方法調用中的目標方法在 Class 文件裏都是一個常量池中的符號引用,在類加載的解析階段,會將其中一部分符號引用轉化爲直接引用,這種解析能成立的前提是方法在程序真正運行以前就有一個可肯定的調用版本,而且這個方法的調用版本在運行期是不可改變的。話句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須肯定下來。這類方法的調用稱爲解析(Resolution)。

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

與之相應的是,在 Java 虛擬機裏提供了 5 條方法調用字節碼指令,分別是:

  • invokestatic:調用靜態方法;
  • invokespecial:調用實例構造器 方法、私有方法和父類方法;
  • invokevirtual:調用全部虛方法;
  • invokeinterface:調用接口方法,會在運行時再肯定一個實現此接口的對象;
  • invokedynamic:先在運行時動態解析出調用點限定符所引用的方法,而後再執行該方法。

只要能被 invokestatic 和 invokespecial 指令調用的方法,均可以在解析階段中肯定惟一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法 4 類,它們在加載的時候就會把符號引用解析爲直接引用。這些方法能夠稱爲非虛方法,與之相反,其它方法稱爲虛方法(final 方法除外)。

Java 中的非虛方法除了使用 invokestatic、invokespecial 調用的方法以外還有一種,就是被 final 修飾的方法。雖然 final 方法是使用 invokevirtual 指令來調用的,可是因爲它沒法被覆蓋,沒有其它版本,因此也無需對方法接受者進行多態選擇,又或者說多態選擇的結果確定是惟一的。在 Java 語言規範中明確說明了 final 方法是一種非虛方法。

解析調用必定是個靜態過程,在編譯期間就能徹底肯定,在類裝載的解析階段就會把涉及的符號引用所有轉變爲可肯定的直接引用,不會延遲到運行期再去完成。而分派(Dispatch)調用則多是靜態的也多是動態的,根據分派依據的宗量數可分爲單分派和多分派。這兩類分派方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派 4 種分派組合狀況,下面咱們再看看虛擬機中的方法分派是如何進行的。

分派

面向對象有三個基本特徵,封裝、繼承和多態。這裏要說的分派將會揭示多態特徵的一些最基本的體現,如「重載」和「重寫」在 Java 虛擬機中是如何實現的?虛擬機是如何肯定正確目標方法的?

靜態分派

在開始介紹靜態分派前咱們先看一段代碼。

/** * 方法靜態分派演示 * * @author baronzhang */
public class StaticDispatch {

    private static abstract class Human { }

    private static class Man extends Human { }

    private static class Woman extends Human { }

    private void sayHello(Human guy) {
        System.out.println("Hello, guy!");
    }

    private void sayHello(Man man) {
        System.out.println("Hello, man!");
    }

    private void sayHello(Woman woman) {
        System.out.println("Hello, woman!");
    }

    public static void main(String[] args) {

        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch dispatch = new StaticDispatch();
        dispatch.sayHello(man);
        dispatch.sayHello(woman);
    }
}
複製代碼

運行後這段程序的輸出結果以下:

Hello, guy!
Hello, guy!
複製代碼

稍有經驗的 Java 程序員都能得出上述結論,但爲何咱們傳遞給 sayHello() 方法的實際參數類型是 Man 和 Woman,虛擬機在執行程序時選擇的倒是 Human 的重載呢?要理解這個問題,咱們先弄清兩個概念。

Human man = new Man();
複製代碼

上面這段代碼中的「Human」稱爲變量的靜態類型(Static Type),或者叫作外觀類型(Apparent Type),後面的「Man」稱爲變量爲實際類型(Actual Type),靜態類型和實際類型在程序中均可以發生一些變化,區別是靜態類型的變化僅發生在使用時,變量自己的靜態類型不會被改變,而且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期纔可肯定,編譯器在編譯程序的時候並不知道一個對象的實際類型是什麼。

弄清了這兩個概念,再來看 StaticDispatch 類中 main() 方法裏的兩次 sayHello() 調用,在方法接受者已經肯定是對象「dispatch」的前提下,使用哪一個重載版本,就徹底取決於傳入參數的數量和數據類型。代碼中定義了兩個靜態類型相同可是實際類型不一樣的變量,可是虛擬機(準確的說是編譯器)在重載時是經過參數的靜態類型而不是實際類型做爲斷定依據的。而且靜態類型是編譯期可知的,所以在編譯階段, Javac 編譯器會根據參數的靜態類型決定使用哪一個重載版本,因此選擇了 sayHello(Human) 做爲調用目標,並把這個方法的符號引用寫到 man() 方法裏的兩條 invokevirtual 指令的參數中。

全部依賴靜態類型來定位方法執行版本的分派動做稱爲靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,所以肯定靜態分派的動做實際上不是由虛擬機來執行的。

另外,編譯器雖然能肯定方法的重載版本,可是不少狀況下這個重載版本並非「惟一」的,所以每每只能肯定一個「更加合適」的版本。產生這種狀況的主要緣由是字面量不須要定義,因此字面量沒有顯示的靜態類型,它的靜態類型只能經過語言上的規則去理解和推斷。下面的代碼展現了什麼叫「更加合適」的版本。

/** * @author baronzhang */
public class Overlaod {

    static void sayHello(Object arg) {
        System.out.println("Hello, Object!");
    }

    static void sayHello(int arg) {
        System.out.println("Hello, int!");
    }

    static void sayHello(long arg) {
        System.out.println("Hello, long!");
    }

    static void sayHello(Character arg) {
        System.out.println("Hello, Character!");
    }

    static void sayHello(char arg) {
        System.out.println("Hello, char!");
    }

    static void sayHello(char... arg) {
        System.out.println("Hello, char...!");
    }

    static void sayHello(Serializable arg) {
        System.out.println("Hello, Serializable!");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}
複製代碼

上面代碼的運行結果爲:

Hello, char!
複製代碼

這很好理解,‘a’ 是一個 char 類型的數據,天然會尋找參數類型爲 char 的重載方法,若是註釋掉 sayHello(chat arg) 方法,那麼輸出結果將會變爲:

Hello, int!
複製代碼

這時發生了一次類型轉換, ‘a’ 除了能夠表明一個字符,還能夠表明數字 97,由於字符 ‘a’ 的 Unicode 數值爲十進制數字 97,所以參數類型爲 int 的重載方法也是合適的。咱們繼續註釋掉 sayHello(int arg) 方法,輸出變爲:

Hello, long!
複製代碼

這時發生了兩次類型轉換,‘a’ 轉型爲整數 97 以後,進一步轉型爲長整型 97L,匹配了參數類型爲 long 的重載方法。咱們繼續註釋掉 sayHello(long arg) 方法,輸出變爲:

Hello, Character!
複製代碼

這時發生了一次自動裝箱, ‘a’ 被包裝爲它的封裝類型 java.lang.Character,因此匹配到了類型爲 Character 的重載方法,繼續註釋掉 sayHello(Character arg) 方法,輸出變爲:

Hello, Serializable!
複製代碼

這裏輸出之因此爲「Hello, Serializable!」,是由於 java.lang.Serializable 是 java.lang.Character 類實現的一個接口,當自動裝箱後發現仍是找不到裝箱類,可是找到了裝箱類實現了的接口類型,因此緊接着又發生了一次自動轉換。char 能夠轉型爲 int,可是 Character 是絕對不會轉型爲 Integer 的,他只能安全的轉型爲它實現的接口或父類。Character 還實現了另一個接口 java.lang.Comparable,若是同時出現兩個參數分別爲 Serializable 和 Comparable 的重載方法,那它們在此時的優先級是同樣的。編譯器沒法肯定要自動轉型爲哪一種類型,會提示類型模糊,拒絕編譯。程序必須在調用時顯示的指定字面量的靜態類型,如:sayHello((Comparable) 'a'),才能編譯經過。繼續註釋掉 sayHello(Serializable arg) 方法,輸出變爲:

Hello, Object!
複製代碼

這時是 char 裝箱後轉型爲父類了,若是有多個父類,那將在繼承關係中從下往上開始搜索,越接近上層的優先級越低。即便方法調用的入參值爲 null,這個規則依然適用。繼續註釋掉 sayHello(Serializable arg) 方法,輸出變爲:

Hello, char...!
複製代碼

7 個重載方法以及被註釋得只剩一個了,可見變長參數的重載優先級是最低的,這時字符 ‘a’ 被當成了一個數組元素。

前面介紹的這一系列過程演示了編譯期間選擇靜態分派目標的過程,這個過程也是 Java 語言實現方法重載的本質。

動態分派

動態分派和多態性的另外一個重要體現「重寫(Override)」有着密切的關聯,咱們依舊經過代碼來理解什麼是動態分派。

/** * 方法動態分派演示 * * @author baronzhang */
public class DynamicDispatch {

    static abstract class Human {

        abstract void sayHello();
    }

    static class Man extends Human {

        @Override
        void sayHello() {
            System.out.println("Man say hello!");
        }
    }

    static class Woman extends Human {
        @Override
        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();
    }
}
複製代碼

代碼執行結果:

Man say hello!
Woman say hello!
Woman say hello!
複製代碼

對於上面的代碼,虛擬機是如何肯定要調用哪一個方法的呢?顯然這裏再也不經過靜態類型來決定了,由於靜態類型一樣都是 Human 的兩個變量 man 和 woman 在調用 sayHello() 方法時執行了不一樣的行爲,而且變量 man 在兩次調用中執行了不一樣的方法。致使這個結果的緣由是由於它們的實際類型不一樣。對於虛擬機是如何經過實際類型來分派方法執行版本的,這裏咱們就不作介紹了,有興趣的能夠去看看原著。

咱們把這種在運行期根據實際類型來肯定方法執行版本的分派稱爲動態分派

單分派和多分派

方法的接收者和方法的參數統稱爲方法的宗量,這個定義最先來源於《Java 與模式》一書。根據分派基於多少宗量,可將分派劃分爲單分派多分派

單分派是根據一個宗量來肯定方法的執行版本;多分派則是根據多餘一個宗量來肯定方法的執行版本。

咱們依舊經過代碼來理解(代碼以著名的 3Q 大戰做爲背景):

/** * 單分派、多分派演示 * * @author baronzhang */
public class Dispatch {

    static class QQ { }

    static class QiHu360 { }

    static class Father {

        public void hardChoice(QQ qq) {
            System.out.println("Father choice QQ!");
        }

        public void hardChoice(QiHu360 qiHu360) {
            System.out.println("Father choice 360!");
        }
    }

    static class Son extends Father {

        @Override
        public void hardChoice(QQ qq) {
            System.out.println("Son choice QQ!");
        }

        @Override
        public void hardChoice(QiHu360 qiHu360) {
            System.out.println("Son choice 360!");
        }
    }

    public static void main(String[] args) {

        Father father = new Father();
        Father son = new Son();

        father.hardChoice(new QQ());
        son.hardChoice(new QiHu360());
    }
}
複製代碼

代碼輸出結果:

Father choice QQ!
Son choice 360!
複製代碼

咱們先來看看編譯階段編譯器的選擇過程,也就是靜態分派過程。這個時候選擇目標方法的依據有兩點:一是靜態類型是 Father 仍是 Son;二是方法入參是 QQ 仍是 QiHu360。由於是根據兩個宗量進行選擇的,因此 Java 語言的靜態分派屬於多分派

再看看運行階段虛擬機的選擇過程,也就是動態分派的過程。在執行 son.hardChoice(new QiHu360()) 時,因爲編譯期已經肯定目標方法的簽名必須爲 hardChoice(QiHu360),這時參數的靜態類型、實際類型都不會對方法的選擇形成任何影響,惟一能夠影響虛擬機選擇的因數只有此方法的接收者的實際類型是 Father 仍是 Son。由於只有一個宗量做爲選擇依據,因此 Java 語言的動態分派屬於單分派。

綜上所述,Java 語言是一門靜態多分派、動態單分派的語言。

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

虛擬機如何調用方法已經介紹完了,下面咱們來看看虛擬機是如何執行方法中的字節碼指令的。

解釋執行

Java 語言常被人們定義成「解釋執行」的語言,但隨着 JIT 以及可直接將 Java 代碼編譯成本地代碼的編譯器的出現,這種說法就不對了。只有肯定了談論對象是某種具體的 Java 實現版本和執行引擎運行模式時,談解釋執行仍是編譯執行纔會比較確切。

不管是解釋執行仍是編譯執行,不管是物理機仍是虛擬機,對於應用程序,機器都不可能像人同樣閱讀、理解,而後得到執行能力。大部分的程序代碼到物理機的目標代碼或者虛擬機執行的指令以前,都須要通過下圖中的各個步驟。下圖中最下面的那條分支,就是傳統編譯原理中程序代碼到目標機器代碼的生成過程;中間那條分支,則是解釋執行的過程。

現在,基於物理機、Java 虛擬機或者非 Java 的其它高級語言虛擬機的語言,大多都會遵循這種基於現代編譯原理的思路,在執行前先對程序源代碼進行詞法分析和語法分析處理,把源代碼轉化爲抽象語法樹。對於一門具體語言的實現來講,詞法分析、語法分析以致後面的優化器和目標代碼生成器均可以選擇獨立於執行引擎,造成一個完整意義的編譯器去實現,這類表明是 C/C++。也能夠爲一個半獨立的編譯器,這類表明是 Java。又或者把這些步驟和執行所有封裝在一個封閉的黑匣子中,如大多數的 JavaScript 執行器。

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

許多 Java 虛擬機的執行引擎在執行 Java 代碼的時候都有解釋執行(經過解釋器執行)和編譯執行(經過即時編譯器產生本地代碼執行)兩種選擇。而對於最新的 Android 版本的執行模式則是 AOT + JIT + 解釋執行,關於這方面咱們後面有機會再聊。

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

Java 編譯器輸出的指令流,基本上是一種基於棧的指令集架構。基於棧的指令集主要的優勢就是可移植,寄存器由硬件直接提供,程序直接依賴這些硬件寄存器則不可避免的要受到硬件約束。棧架構的指令集還有一些其餘優勢,好比相對更加緊湊(字節碼中每一個字節就對應一條指令,而多地址指令集中還須要存放參數)、編譯實現更加簡單(不須要考慮空間分配的問題,全部空間都是在棧上操做)等。

棧架構指令集的主要缺點是執行速度相對來講會稍慢一些。全部主流物理機的指令集都是寄存器架構也從側面印證了這一點。

雖然棧架構指令集的代碼很是緊湊,可是完成相同功能須要的指令集數量通常會比寄存器架構多,由於出棧、入棧操做自己就產生了至關多的指令數量。更重要的是,棧實如今內存中,頻繁的棧訪問也意味着頻繁的內存訪問,相對於處理器來講,內存始終是執行速度的瓶頸。因爲指令數量和內存訪問的緣由,因此致使了棧架構指令集的執行速度會相對較慢。

正是基於上述緣由,Android 虛擬機中採用了基於寄存器的指令集架構。不過有一點不一樣的是,前面說的是物理機上的寄存器,而 Android 上指的是虛擬機上的寄存器。

寫在最後

這一篇咱們介紹了虛擬機是如何執行方法中的字節碼指令的,下一篇文章咱們來重點介紹下虛擬機是如何優化咱們所編寫的代碼的。

參考資料:

  • 《深刻理解 Java 虛擬機:JVM 高級特性與最佳實踐(第 2 版)》

若是你喜歡個人文章,就關注下個人公衆號 BaronTalk知乎專欄 或者在 GitHub 上添個 Star 吧!

相關文章
相關標籤/搜索