在前面兩篇文章中介紹了 .class 文件的結構和虛擬機加載 .class 文件的過程,在本篇文章中主要介紹加載進來之后,虛擬機是如何執(zhí)行字節(jié)碼的,在程序執(zhí)行的過程中主要是方法的調(diào)用和執(zhí)行,所以本篇文章中介紹虛擬機是如何調(diào)用方法并且執(zhí)行方法的,文章結構如下:

一. 概述
執(zhí)行引擎是 Java 虛擬機最核心的組成部分之一?!疤摂M機” 是一個相對于 “物理機” 的概念,這兩種機器都有代碼執(zhí)行能力,其區(qū)別是物理機的執(zhí)行引擎是直接建立在處理器、硬件、指令集和操作系統(tǒng)層面上的,而虛擬機的執(zhí)行引擎則是由自己實現(xiàn)的,因此可以自行制定指令集與執(zhí)行引擎的結構體系,并且能夠執(zhí)行哪些不被硬件直接支持的指令集格式。
在 Java 虛擬機規(guī)范中制定了虛擬機字節(jié)碼執(zhí)行引擎的概念模型,這個概念模型稱為各種虛擬機執(zhí)行引擎的統(tǒng)一外觀(Facade)。在不同的虛擬機實現(xiàn)里面,執(zhí)行引擎在執(zhí)行 Java 代碼的時候可能會有解釋執(zhí)行(通過解釋器執(zhí)行)和編譯執(zhí)行(通過即時編譯器產(chǎn)生本地代碼執(zhí)行)兩種選擇,也可能兩者兼?zhèn)?,甚至還可能會包含幾個不同級別的編譯器執(zhí)行引擎。但從外觀上看起來,所有的 Java 虛擬機的執(zhí)行引擎都是一致的:輸入的是字節(jié)碼文件,處理過程是字節(jié)碼解析的等效過程,輸出的是執(zhí)行結果。
二. 運行時棧幀
棧幀(Stack Frame)是用于支持虛擬機進行方法調(diào)用和方法執(zhí)行的數(shù)據(jù)結構,它是虛擬機運行時數(shù)據(jù)區(qū)中的虛擬機棧(Virtual Machine Stack)的棧元素。棧幀存儲了方法的局部變量表、操作數(shù)棧、動態(tài)連接和方法返回地址等信息。每一個方法從調(diào)用開始至執(zhí)行完成的過程,都對應著一個棧幀在虛擬機棧里面從入棧到出棧的過程。
每一個棧幀都包括了局部變量表、操作數(shù)棧、動態(tài)連接、方法返回地址和一些額外的附加信息。在編譯程序代碼的時候,棧幀中需要多大的局部變量表,多深的操作數(shù)棧都已經(jīng)完全確定了,并且寫入到方法表的 Code 屬性之中,因此一個棧幀需要分配多少內(nèi)存,不會受到程序運行期變量數(shù)據(jù)的影響,而僅僅取決于具體的虛擬機實現(xiàn)。
一個線程中的方法調(diào)用鏈可能會很長,很多方法都同時處于執(zhí)行狀態(tài)。對于執(zhí)行引擎來說,在活動線程中,只有位于棧頂?shù)臈攀怯行У?,稱為當前棧幀(Current Stack Frame),與這個棧幀相關聯(lián)的方法稱為當前方法(Current Method)。執(zhí)行引擎運行的所有字節(jié)碼指令都只針對當前棧幀進行操作,在概念模型上,典型的棧幀結構如下圖所示

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

從上圖中,我們可以看到 main() 方法的字節(jié)碼指令執(zhí)行過程:
- 0 ~ 7 句是調(diào)用 Man 類的實例構造器創(chuàng)建一個 Man 類的對象,并將對象的引用壓入到局部變量表的第 1 個 Slot 中
- 8 ~ 15 句是調(diào)用 Woman 類的實例構造器創(chuàng)建一個 Woman 類的對象,并將對象的引用壓入到局部變量表的第 2 個 Slot 中
- 16 ~ 17 句是將第 1 個 Slot 中的變量(也就是 man)加載到局部變量表中,并調(diào)用 sayHello() 方法,關鍵的就是第 17 句指令 invokevirtual
雖然第 17 句指令調(diào)用的常量池中的 Human.sayHello() 方法,但是最終執(zhí)行的卻是 Man.sayHello() 方法,這就要從 invokevirtual 指令的多態(tài)查找說起,invokevirtual 的查找過程如下所示:
- 找到操作數(shù)棧頂?shù)囊盟傅膶ο蟮膶嶋H類型,記做 C
- 在類型 C 中查找與常量中的描述符和簡單名稱相同的方法,如果找到則進行訪問權限的判斷,如果通過則返回這個方法的直接引用,查找結束;如果權限不通過,則返回 java.lang.IllegalAccessError 的異常
- 如果在 C 中沒有找到描述符和簡單名稱都符合的方法,則按照繼承關系從下往上依次在 C 的父類中進行查找和驗證過程
- 如果最終還是沒有找到該方法,則拋出 java.lang.AbstractMethodError 的異常
在上述 invokespecial 查找方法的過程中,最重要的就是第一步,根據(jù)對象的引用確定對象的實際類型,這個方法重寫的本質(zhì)。如上所述,在運行期內(nèi),根據(jù)對象的實際類型確定方法執(zhí)行版本的分派過程叫做動態(tài)分派。
3.2.3 單分派和多分派
分派根據(jù)基于多少種總量,可以分為單分派和多分派??偭渴侵福悍椒ǖ慕邮照吆头椒ǖ膮?shù)。根據(jù)分派時依據(jù)的宗量多少,可以分為單分派和多分派。
到目前為止,Java 語言還是一門 "靜態(tài)多分派、動態(tài)單分派" 的語言,也就是說在執(zhí)行靜態(tài)分派時是根據(jù)多個宗量判斷調(diào)用哪個方法的,因為在靜態(tài)分派時要根據(jù)不同的靜態(tài)類型和不同的方法描述符選擇目標方法,在動態(tài)分派的時候,是根據(jù)單宗量選擇目標方法的,因為在運行期,方法的描述符已經(jīng)確定好,invokevirtual 字節(jié)碼指令根據(jù)變量的實際類型選擇目標方法。
3.2.4 虛擬機動態(tài)分派的實現(xiàn)
虛擬機中的動態(tài)分派是十分頻繁的動作,并且是在運行時在類方法元數(shù)據(jù)中進行搜索的,因此基于性能的考慮,虛擬機會采用各種優(yōu)化手段優(yōu)化動態(tài)分派的過程,最常見的"穩(wěn)定優(yōu)化"的手段就是為類在方法區(qū)中建立一個虛方法表,使用虛方法表索引來代替元數(shù)據(jù)以提高性能。

上圖就是一個虛方法表,F(xiàn)ather、Son、Object 三個類在方法區(qū)中都有一個自己的虛方法表,如果子類中實現(xiàn)了父類的方法,那么在子類的虛方法表中該方法就指向子類實現(xiàn)的該方法的入口地址,如果子類中沒有重寫父類中的方法,那么在子類的虛方法表中,該方法的索引就指向父類的虛方法表中的方法的入口地址。有兩點需要注意:
- 為了程序實現(xiàn)上的方便,一個具有相同簽名的方法,在子類的方法表和父類的方法表中應該具有相同的索引,這樣在類型變化的時候,只需要改變查找方法的虛方法表即可。
- 虛方法表是在類加載的連接階段實現(xiàn)的,類的變量初始化完成之后,就會初始化該類的虛方法表
四. 基于棧的字節(jié)碼解釋執(zhí)行引擎
本節(jié)我們探討虛擬機是如何執(zhí)行方法中的字節(jié)碼指令的。Java 虛擬機的執(zhí)行引擎在執(zhí)行 Java 代碼的時候都有解釋執(zhí)行和編譯執(zhí)行兩種選擇,我們探討一下解釋執(zhí)行時,虛擬機執(zhí)行引擎是如何工作的。
4.1 解釋執(zhí)行 & 編譯執(zhí)行 & 編譯器
在開始之前,先介紹一下解釋執(zhí)行和編譯執(zhí)行的含義
- 解釋執(zhí)行:代碼由生成字節(jié)碼指令之后,由解釋器解釋執(zhí)行
- 編譯執(zhí)行:通過即時編譯器生成本地代碼執(zhí)行
如下圖所示,中間這條分支是解釋執(zhí)行,下面那條分支是編譯執(zhí)行

Java 程序在執(zhí)行前先對程序源碼進行詞法分析和語法分析處理,把源代碼轉化抽象語法樹。對于一門具體語言的實現(xiàn)來說,詞法分析、語法分析以及后面的優(yōu)化器和目標代碼生成器都可以選擇獨立于執(zhí)行引擎,形成一個完整意義的編譯器去實現(xiàn),這類代表是 C/C++ 語言。當然也可以選擇其中的一部分步驟實現(xiàn)一個半獨立的編譯器,這類代表是 Java 語言,又或者把這些步驟和執(zhí)行引擎全部集中封裝到一個封閉黑匣子中,如大多數(shù)的 JS 執(zhí)行器。
Java 語言中,Javac 編譯器完成了詞法分析、語法分析、轉換為抽象語法樹,然后再生成字節(jié)碼指令流的過程,這些動作是獨立于 Java 虛擬機之外的,所以 Javac 編譯器是一個半獨立的編譯器。
4.2 基于棧的指令集和基于寄存器的指令集
基于棧的指令集中的指令是依賴于操作數(shù)棧運行的,基于寄存器的指令是依賴于寄存器進行工作的。那么它們兩者有什么區(qū)別呢?用一個簡單的例子說明:1 + 1 這個例子來說明
-
基于棧的指令如下:
iconst_1 iconst_1 iadd istore_0兩條
iconst_1指令分別把兩個1壓入到工作棧中去,然后執(zhí)行iadd兩條指令,對棧頂?shù)膬蓚€1進行出棧并相加的動作,然后將相加的結果2壓入到棧中,接著執(zhí)行istore_0將棧頂?shù)?2存入到局部變量表中第0個 Slot 中去 -
基于寄存器的指令如下:
mov eax,1 add eax, 1mov指令將寄存器eax中的值設置為1,然后執(zhí)行add指令將寄存器eax中的值加1,結果就保存在eax寄存器中
基于棧的指令的特點
- 可移植:寄存器由硬件決定,限制較大,但是虛擬機可以在不同硬件條件的機器上執(zhí)行
- 代碼相對更加緊湊:字節(jié)碼中每個字節(jié)就對應一條指令,而多地址指令集中還需要存放參數(shù)
- 編譯器實現(xiàn)更加簡單
- 基于棧的指令缺點就是執(zhí)行速度慢,因為虛擬機中操作數(shù)棧是在內(nèi)存中實現(xiàn)的,頻繁的棧訪問也就意味著頻繁的訪問內(nèi)存,內(nèi)存的訪問還是要比直接操作寄存器要慢的
4.3 基于棧的解釋器執(zhí)行過程
我們通過一段示例代碼來學習基于棧的解釋器執(zhí)行過程,示例代碼如下所示:
public class Test {
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
}
執(zhí)行 javac Test.java 生成 Test.class 字節(jié)碼文件之后,再使用 javap -verbose Test.class 命令查看 Test.class 字節(jié)碼指令如下圖所示:

由上圖中可以看到,Test#calc() 方法對應的字節(jié)碼如下:
public int calc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
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
從上面的字節(jié)碼指令中可以分析到:calc() 方法需要深度為 2 的操作數(shù)棧和 4 個 Slot 的局部變量空間,如下 7 張圖描述上述代碼執(zhí)行過程中的代碼、操作數(shù)棧和局部變量表的變化情況



上面的執(zhí)行過程僅僅是一種概念模型,虛擬機最終會對執(zhí)行過程做一些優(yōu)化來提高性能,實際的運作過程不一定完全符合概念模型的描述。
更準確的說,實際情況會和上面描述的概念模型差距非常大,這種差距產(chǎn)生的原因是虛擬機中解釋器和即時編譯器都會對輸入的字節(jié)碼進行優(yōu)化。