虛擬機器位元組碼執行引擎
在前面兩篇文章中介紹了 .class 檔案的結構和虛擬機器載入 .class 檔案的過程,在本篇文章中主要介紹載入進來之後,虛擬機器是如何執行位元組碼的,在程式執行的過程中主要是方法的呼叫和執行,所以本篇文章中介紹虛擬機器是如何呼叫方法並且執行方法的,文章結構如下:

catalog.png
一. 概述
執行引擎是 Java 虛擬機器最核心的組成部分之一。“虛擬機器” 是一個相對於 “物理機” 的概念,這兩種機器都有程式碼執行能力,其區別是物理機的執行引擎是直接建立在處理器、硬體、指令集和作業系統層面上的,而虛擬機器的執行引擎則是由自己實現的,因此可以自行制定指令集與執行引擎的結構體系,並且能夠執行哪些不被硬體直接支援的指令集格式。
在 Java 虛擬機器規範中制定了虛擬機器位元組碼執行引擎的概念模型,這個概念模型稱為各種虛擬機器執行引擎的統一外觀(Facade)。在不同的虛擬機器實現裡面,執行引擎在執行 Java 程式碼的時候可能會有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生代碼執行)兩種選擇,也可能兩者兼備,甚至還可能會包含幾個不同級別的編譯器執行引擎。但從外觀上看起來,所有的 Java 虛擬機器的執行引擎都是一致的:輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果。
二. 執行時棧幀
棧幀(Stack Frame)是用於支援虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中的虛擬機器棧(Virtual Machine Stack)的棧元素。棧幀儲存了方法的區域性變量表、運算元棧、動態連線和方法返回地址等資訊。每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。
每一個棧幀都包括了局部變量表、運算元棧、動態連線、方法返回地址和一些額外的附加資訊。在編譯程式程式碼的時候,棧幀中需要多大的區域性變量表,多深的運算元棧都已經完全確定了,並且寫入到方法表的 Code 屬性之中,因此一個棧幀需要分配多少記憶體,不會受到程式執行期變數資料的影響,而僅僅取決於具體的虛擬機器實現。
一個執行緒中的方法呼叫鏈可能會很長,很多方法都同時處於執行狀態。對於執行引擎來說,在活動執行緒中,只有位於棧頂的棧幀才是有效的,稱為當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法稱為當前方法(Current Method)。執行引擎執行的所有位元組碼指令都只針對當前棧幀進行操作,在概念模型上,典型的棧幀結構如下圖所示

stack_frame.png
接下來詳細講解一下棧幀的區域性變量表、運算元棧、動態連線、方法返回地址等各個部分的作用和資料結構
2.1 區域性變量表
區域性變量表是一組變數值的儲存空間,用於存放方法引數和方法內部定義的區域性變數。
在編譯的時候,就在方法的 Code 屬性的 max_locals 資料項中確定了該方法所需要分配的區域性變量表的最大容量。
區域性變量表的容量以變數槽(Variable Slot,下稱 Slot)為最小單位,虛擬機器規範中並沒有明確指明一個 Slot 應占用的記憶體空間大小,只是很有導向性地說到每個 Slot 都應該能儲存一個 boolean、byte、char、short、int、float、reference 或 returnAddress 型別的資料,這 8 中資料型別,都可以使用 32 位或更小的實體記憶體來存放,在 Java 虛擬機器的資料型別中,64 位的資料型別只有 long 和 double 兩種,關於這幾種區域性變量表中的資料有兩點需要注意
- reference 資料型別,虛擬機器規範並沒有明確指明它的長度,也沒有明確指明它的資料結構,但是虛擬機器通過 reference 資料可以做到兩點:1. 通過此 reference 引用,可以直接或間接的查詢到物件在 Java 堆上的其實地址索引;2. 通過此 reference 引用,可以直接或間接地查詢到物件所屬資料型別在方法區中的儲存的型別資訊
- 對於 64 位的 long 和 double 資料,虛擬機器會以高位對齊的方式為其分配兩個連續的 Slot 空間
在方法執行時,虛擬機器是使用區域性變量表完成引數變數列表的傳遞過程,如果是例項方法,那麼區域性變量表中的每 0 位索引的 Slot 預設是用於傳遞方法所屬物件例項的引用,在方法中可以通過關鍵字 “this” 來訪問這個隱藏的區域性變數,其餘引數則按照引數列表的順序來排列,佔用從 1 開始的區域性變數 Slot,引數表分配完畢後,再跟進方法體內部定義的變數順序和作用域來分配其餘的 Slot。需要注意的是區域性變數並不存在如類變數的"準備"階段,類變數會在類載入的時候經過“準備”和“初始化”階段,即使程式設計師沒有為類變數在 "初始化" 賦予初始值,也還是會在"準備"階段賦予系統的型別預設值,但是區域性變數不會這樣,區域性變量表沒有"準備"階段,所以需要程式設計師手動的為區域性變數賦予初始值
2.2 運算元棧
運算元棧也常被稱為操作棧,它是一個後入先出棧。同區域性變量表一樣,運算元棧的最大深度也是在編譯時期就寫入到方法表的 Code 屬性的 max_stacks 資料項中。運算元棧的每一個元素可以是可以是任意 Java 資料型別,包括 long 和 double,32 位資料型別所佔的棧容量為 1,64 位資料型別所佔的棧容量為 2
在一個方法剛開始執行的時候,運算元棧是空的,隨著方法的執行,會有各種位元組碼往運算元棧中寫入和提取內容,也就是出棧/入棧操作。
Java 虛擬機器的解釋執行引擎稱為"基於棧的執行引擎",其中所指的"棧"就是運算元棧。
棧容量的單位是 “字寬”,對於 32 位虛擬機器來說,一個 “字寬” 佔 4 個位元組,對於 64 位虛擬機器來說,一個 “字寬” 佔 8 個位元組
2.3 動態連線
每個棧幀都包含一個指向執行時常量池中該棧幀所屬性方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線。
在 Class 檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用為引數。這些符號引用一部分會在類載入階段或第一次使用的時候轉化為直接引用,這種轉化稱為靜態解析。另外一部分將在每一次的執行期期間轉化為直接引用,這部分稱為動態連線。
2.4 方法返回地址
在一個方法被執行後,有兩種方式退出這個方法:正常完成出口和異常完成出口
- 正常完成出口:當執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法呼叫者(呼叫當前方法的方法稱為呼叫者),是否有返回值和返回值的型別將根據遇到何種方法返回指令來決定
- 異常完成出口:在方法執行的過程中如果遇到了異常,並且這個異常沒有再方法體內得到處理,無論是 Java 虛擬機器內部產生的異常,還是在程式碼中使用 athrow 位元組碼指令產生的異常,只要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出
方法退出時,需要返回到方法被呼叫的位置,程式才能繼續執行。方法正常退出時,呼叫者的 PC 計數器的值可以作為返回地址,棧幀中很可能會儲存這個計數器值;而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會儲存這部分資訊。
方法退出的過程實際上等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的區域性變量表和運算元棧,把返回值(如果有的話)壓入呼叫者棧幀的運算元棧中,調整 PC 計數器的值以指向方法呼叫指令後面的一條指令等
三. 方法呼叫
方法呼叫即指確認呼叫哪個方法的過程,並不是指執行方法的過程。Java 的編譯並不包含傳統編譯過程中的連線步驟,所以在 .java 程式碼編譯成 .class 檔案之後,在 .class 檔案中儲存的是方法的符號引用(方法在常量池中的符號),並不是方法的直接引用(方法在記憶體佈局中的入口地址),所以需要在載入或執行階段才會確認目標方法的直接引用。
3.1 解析
有幾種方法的呼叫,在載入階段就可以確認該方法的直接引用,前提是:方法在程式真正執行之前就有一個可確定的呼叫版本(呼叫哪一個方法),並且這個方法的呼叫版本在執行期是不可變的。換句話說,呼叫目標在程式程式碼寫好、編譯器進行編譯時就必須確定下來。這類方法的呼叫稱為解析。
有四種方法是進行的方法的解析:靜態方法、私有方法、例項構造器、父類方法,這四類方法稱為非虛方法,與之對應的就是續方法(final 方法除外),呼叫這四類方法的位元組碼指令是:invokestatic、invokespecial 指令,也就是說被 invokestatic、invokespecial 位元組碼呼叫的方法,在類載入的解析階段就可以通過方法的符號引用確認方法的直接引用。
在 Java 位元組碼中,還有幾種呼叫方法的位元組碼指令如下:
- invokestatic:呼叫靜態方法
- invokespecial:呼叫例項構造器方法<init>、私有方法、父類方法
- invokevirtual:呼叫所有的虛方法
- invokeinterface:呼叫介面方法,會在執行時確認一個實現此介面的物件
- invokedynamic:先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法。
被 final 關鍵字修飾的方法,在位元組碼中是被 invokevirtual 指令呼叫的,但是被 final 修飾的方法無法被過載或重寫,所以只有一個方法,在載入階段就可以確認呼叫哪個方法,所以也是一種虛方法,方法呼叫時走的也是解析流程。
3.2 分派
解析呼叫是一個靜態的過程,在載入階段就可以確認目標方法的直接引用。分派呼叫有可能是靜態的,也有可能是動態的,根據分派的宗量數又可以分為單分派和多分派,這兩類兩兩組合,所以分派共可以細分為:靜態單分派、靜態多分派、動態單分派、動態多分派。
在講解本節中的分派的過程中,會揭示一些 Java 中的多型性在 Java 虛擬機器層面的基本體現,如“過載”和“重寫”在 Java 虛擬機器中是如何實現的。
3.2.1 靜態分派
先看如下一個靜態分派的程式碼示例:
public class StaticDispatch { 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(Main guy) { System.out.println("hello, man"); } public void sayHello(Woman guy) { 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); } }
有 Java 開發經驗的開發者都會知道,上面程式碼是一個方法過載的示例程式碼,其輸出結果如下所示:
hello, guy hello, guy
有人就問了,為什麼會呼叫引數型別是 Human 的方法,而不執行方法引數是 Man 和 Woman 的方法呢?接下來我們就來分析一下,在分析之前,我們先定義兩個重要的概念:變數的靜態型別和實際型別,假如有如下程式碼:
Human man = new Man();
- 靜態型別:是指物件 man 的 Human 型別, 靜態型別本身是不會發送變化的,只有在使用時才會傳送變化,靜態型別在編譯期間就可以確定一個變數的靜態型別
- 實際型別:是指物件 man 的 Man 型別,實際型別在編譯期間是不可確定的,只有在執行期才可確定
如下程式碼所示:
// 實際型別變化 Human man = new Man(); man = new Woman(); // 靜態型別變化 dispatch.sayHello((Man) man); dispatch.sayHello((Woman) man);
所以第一段程式碼中,方法接收者是 StaticDispatch 物件,雖然兩個變數的實際型別不同,但是靜態型別是相同的都是 Human,虛擬機器(準確的說是編譯器)在實現過載時是通過引數的靜態型別而不是實際型別做出判定的,並且在編譯階段,變數的靜態型別是可以確定的,所以編譯器會根據變數的靜態型別決定使用哪個過載方法。
所有依賴靜態型別定位目標方法的分派動作稱為靜態分派,靜態分派典型的應用就是方法的過載。靜態分派發生在編譯階段,所以方法的靜態分派動作是由編譯器執行的。
3.2.2 動態分派
動態分派和 Java 語言中的"方法重寫"有著密切的聯絡,還是看如下的一個例子:
public class DynamicDispatch { static abstract class Human { abstract void sayHello(); } static class Man extends Human { void sayHello() { System.out.println("hello, man"); } } static class Woman extends Human { void sayHello() { System.out.println("hello, woman"); } } public static void main(String[] args){ Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); man = new Woman(); man.sayHello(); } }
輸出結果如下所示:
hello, man hello, woman hello, man
上面的輸出結果不會出乎人的預料,從之前的靜態分派中,我們可以指定,在這個例子中,不是根據物件的靜態型別判斷的,而是根據物件的實際型別判斷的,那在 Java 虛擬機器中是如何根據例項型別來判斷的呢?我們使用 javap 命令得到上面 main() 方法的位元組碼如下所示:

DynamicDispatch.png
從上圖中,我們可以看到 main() 方法的位元組碼指令執行過程:
- 0 ~ 7 句是呼叫 Man 類的例項構造器建立一個 Man 類的物件,並將物件的引用壓入到區域性變量表的第 1 個 Slot 中
- 8 ~ 15 句是呼叫 Woman 類的例項構造器建立一個 Woman 類的物件,並將物件的引用壓入到區域性變量表的第 2 個 Slot 中
- 16 ~ 17 句是將第 1 個 Slot 中的變數(也就是 man)載入到區域性變量表中,並呼叫 sayHello() 方法,關鍵的就是第 17 句指令 invokevirtual
雖然第 17 句指令呼叫的常量池中的 Human.sayHello() 方法,但是最終執行的卻是 Man.sayHello() 方法,這就要從 invokevirtual 指令的多型查詢說起,invokevirtual 的查詢過程如下所示:
- 找到運算元棧頂的引用所指的物件的實際型別,記做 C
- 在型別 C 中查詢與常量中的描述符和簡單名稱相同的方法,如果找到則進行訪問許可權的判斷,如果通過則返回這個方法的直接引用,查詢結束;如果許可權不通過,則返回 java.lang.IllegalAccessError 的異常
- 如果在 C 中沒有找到描述符和簡單名稱都符合的方法,則按照繼承關係從下往上依次在 C 的父類中進行查詢和驗證過程
- 如果最終還是沒有找到該方法,則丟擲 java.lang.AbstractMethodError 的異常
在上述 invokespecial 查詢方法的過程中,最重要的就是第一步,根據物件的引用確定物件的實際型別,這個方法重寫的本質。如上所述,在執行期內,根據物件的實際型別確定方法執行版本的分派過程叫做動態分派。
3.2.3 單分派和多分派
分派根據基於多少種總量,可以分為單分派和多分派。總量是指:方法的接收者和方法的引數。根據分派時依據的宗量多少,可以分為單分派和多分派。
到目前為止,Java 語言還是一門 "靜態多分派、動態單分派" 的語言,也就是說在執行靜態分派時是根據多個宗量判斷呼叫哪個方法的,因為在靜態分派時要根據不同的靜態型別和不同的方法描述符選擇目標方法,在動態分派的時候,是根據單宗量選擇目標方法的,因為在執行期,方法的描述符已經確定好,invokevirtual 位元組碼指令根據變數的實際型別選擇目標方法。
3.2.4 虛擬機器動態分派的實現
虛擬機器中的動態分派是十分頻繁的動作,並且是在執行時在類方法元資料中進行搜尋的,因此基於效能的考慮,虛擬機器會採用各種優化手段優化動態分派的過程,最常見的"穩定優化"的手段就是為類在方法區中建立一個虛方法表,使用虛方法表索引來代替元資料以提高效能。

virtual_table.png
上圖就是一個虛方法表,Father、Son、Object 三個類在方法區中都有一個自己的虛方法表,如果子類中實現了父類的方法,那麼在子類的虛方法表中該方法就指向子類實現的該方法的入口地址,如果子類中沒有重寫父類中的方法,那麼在子類的虛方法表中,該方法的索引就指向父類的虛方法表中的方法的入口地址。有兩點需要注意:
- 為了程式實現上的方便,一個具有相同簽名的方法,在子類的方法表和父類的方法表中應該具有相同的索引,這樣在型別變化的時候,只需要改變查詢方法的虛方法表即可。
- 虛方法表是在類載入的連線階段實現的,類的變數初始化完成之後,就會初始化該類的虛方法表
四. 基於棧的位元組碼解釋執行引擎
本節我們探討虛擬機器是如何執行方法中的位元組碼指令的。Java 虛擬機器的執行引擎在執行 Java 程式碼的時候都有解釋執行和編譯執行兩種選擇,我們探討一下解釋執行時,虛擬機器執行引擎是如何工作的。
4.1 解釋執行 & 編譯執行 & 編譯器
在開始之前,先介紹一下解釋執行和編譯執行的含義
- 解釋執行:程式碼由生成位元組碼指令之後,由直譯器解釋執行
- 編譯執行:通過即時編譯器生成原生代碼執行
如下圖所示,中間這條分支是解釋執行,下面那條分支是編譯執行

image.png
Java 程式在執行前先對程式原始碼進行詞法分析和語法分析處理,把原始碼轉化抽象語法樹。對於一門具體語言的實現來說,詞法分析、語法分析以及後面的優化器和目的碼生成器都可以選擇獨立於執行引擎,形成一個完整意義的編譯器去實現,這類代表是 C/C++ 語言。當然也可以選擇其中的一部分步驟實現一個半獨立的編譯器,這類代表是 Java 語言,又或者把這些步驟和執行引擎全部集中封裝到一個封閉黑匣子中,如大多數的 JS 執行器。
Java 語言中,Javac 編譯器完成了詞法分析、語法分析、轉換為抽象語法樹,然後再生成位元組碼指令流的過程,這些動作是獨立於 Java 虛擬機器之外的,所以 Javac 編譯器是一個半獨立的編譯器。
4.2 基於棧的指令集和基於暫存器的指令集
基於棧的指令集中的指令是依賴於運算元棧執行的,基於暫存器的指令是依賴於暫存器進行工作的。那麼它們兩者有什麼區別呢?用一個簡單的例子說明:1 + 1 這個例子來說明
-
基於棧的指令如下:
iconst_1 iconst_1 iadd istore_0
兩條
iconst_1
指令分別把兩個1
壓入到工作棧中去,然後執行iadd
兩條指令,對棧頂的兩個1
進行出棧並相加的動作,然後將相加的結果2
壓入到棧中,接著執行istore_0
將棧頂的2
存入到區域性變量表中第0
個 Slot 中去 -
基於暫存器的指令如下:
moveax,1 addeax, 1
mov
指令將暫存器eax
中的值設定為1
,然後執行add
指令將暫存器eax
中的值加1
,結果就儲存在eax
暫存器中
基於棧的指令的特點
- 可移植:暫存器由硬體決定,限制較大,但是虛擬機器可以在不同硬體條件的機器上執行
- 程式碼相對更加緊湊:位元組碼中每個位元組就對應一條指令,而多地址指令集中還需要存放參數
- 編譯器實現更加簡單
- 基於棧的指令缺點就是執行速度慢,因為虛擬機器中運算元棧是在記憶體中實現的,頻繁的棧訪問也就意味著頻繁的訪問記憶體,記憶體的訪問還是要比直接操作暫存器要慢的
4.3 基於棧的直譯器執行過程
我們通過一段示例程式碼來學習基於棧的直譯器執行過程,示例程式碼如下所示:
public class Test { public int calc() { int a = 100; int b = 200; int c = 300; return (a + b) * c; } }
執行 javac Test.java 生成 Test.class 位元組碼檔案之後,再使用 javap -verbose Test.class 命令檢視 Test.class 位元組碼指令如下圖所示:

Test.png
由上圖中可以看到,Test#calc() 方法對應的位元組碼如下:
public int calc(); descriptor: ()I flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=1 0: bipush100 2: istore_1 3: sipush200 6: istore_2 7: sipush300 10: istore_3 11: iload_1 12: iload_2 13: iadd 14: iload_3 15: imul 16: ireturn LineNumberTable: line 3: 0 line 4: 3 line 5: 7 line 6: 11
從上面的位元組碼指令中可以分析到:calc() 方法需要深度為 2 的運算元棧和 4 個 Slot 的區域性變數空間,如下 7 張圖描述上述程式碼執行過程中的程式碼、運算元棧和區域性變量表的變化情況

image.png

class_jvm2.png

class_jvm3.png
上面的執行過程僅僅是一種概念模型,虛擬機器最終會對執行過程做一些優化來提高效能,實際的運作過程不一定完全符合概念模型的描述。
更準確的說,實際情況會和上面描述的概念模型差距非常大,這種差距產生的原因是虛擬機器中直譯器和即時編譯器都會對輸入的位元組碼進行優化。