1. 程式人生 > >深入理解Java虛擬機器(位元組碼執行引擎)

深入理解Java虛擬機器(位元組碼執行引擎)

本文首發於微信公眾號:BaronTalk

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

在 Java 虛擬機器規範中制定了虛擬機器位元組碼執行引擎的概念模型,這個概念模型成為各種虛擬機器執行引擎的統一外觀(Facade)。在不同的虛擬機器實現裡,執行引擎在執行 Java 程式碼的時候可能會有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生代碼執行)兩種方式,也可能兩者都有,甚至還可能會包含幾個不同級別的編譯器執行引擎。但從外觀上來看,所有 Java 虛擬機器的執行引擎是一致的:輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果。

一. 執行時棧幀結構

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

每一個棧幀都包括了局部變量表、運算元棧、動態連結、方法返回地址和一些額外的附加資訊。在編譯程式程式碼時,棧幀中需要多大的區域性變量表,多深的運算元棧都已經完全確定了,並且寫入到方法表的 Code 屬性之中,因此一個棧幀需要分配多少記憶體,不會受到程式執行期變數資料的影響,而僅僅取決於具體的虛擬機器實現。

一個執行緒中的方法呼叫鏈可能會很長,很多方法都處於執行狀態。對於執行引擎來說,在活動執行緒中,只有位於棧頂的棧幀才是有效的,稱為當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法成為當前方法。執行引擎執行的所有位元組碼指令對當前棧幀進行操作,在概念模型上,典型的棧幀結構如下圖:

 

區域性變量表

區域性變量表(Local Variable Table)是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。在 Java 程式中編譯為 Class 檔案時,就在方法的 Code 屬性的 max_locals 資料項中確定了該方法所需要分配的區域性變量表的最大容量。

運算元棧

運算元棧(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 吧!

  • 微信公眾號:BaronTalk
  • 知乎專欄:https://zhuanlan.zhihu.com/baron
  • GitHub:https://github.com/BaronZ88