深入理解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 吧!






免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM