JVM學習筆記(四)——字節碼執行引擎
代碼編譯的結果從機器碼轉變為字節碼,是存儲格式的一小步,確實編程語言發展的一大步。正是因為有了字節碼這一中間格式才有了Java語言跨平台的特性。
字節碼並不能直接基於物理機執行引擎執行,因為物理機執行引擎是建立在特定的處理器,指令集以及操作係統之上的,並不具備跨平台特性。所以執行字節碼的責任就交給了虛擬機中的字節碼執行引擎。
1 運行時棧幀結構
棧幀是用於刻畫Java程序運行時一個方法的調用、執行以及返回過程的數據結構。通過學習前麵的博客我們知道Java程序運行時有一塊區域叫做虛擬機棧,而虛擬機棧中的元素就是棧幀。一個方法從調用到返回的過程就是一個棧幀從入棧到出棧的過程。
一個棧幀主要由以下4部分構成:
- 局部變量表
- 操作數棧
- 動態鏈接
- 返回地址
1.1 局部表量表
局部變量表是一個變量值存儲空間,用於存儲方法參數以及方法內部局部變量。局部變量表的基本存儲單位為slot,一個slot可以存放一個int、byte,char,boolean,reference等基本數據結構。
當執行一個方法時,虛擬機使用局部變量表完成從形參到實參的轉變過程。如果執行的是實例方法,則局部變量表的0號slot用於存儲方法所屬實例對象的索引,即this。其餘的方法參數則按照順序從第1號slot開始存儲;如果執行的是類方法,方法參數從第0號slot開始存儲。
1.2 操作數棧
操作數棧用於存儲字節碼執行過程中的操作數。當一個方法剛開始執行時,其操作數棧是空的,方法在執行的過程中會有各種字節碼指令往操作數棧中寫入或讀取操作數。舉例來說,當執行一個整數加法的指令iadd時,執行引擎會將操作數棧棧頂的兩個元素取出(出棧),相加獲得結果後再壓入棧。
1.3 動態鏈接
每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支持方法調用過程中的動態連接。在Class文件的常量池中存有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用為參數。這些符號引用一部分會在類加載階段或第一次使用的時候轉化為直接引用,這種轉化稱為靜態解析。另外一部分將在每一次的運行期期間轉化為直接引用,這部分稱為動態連接。
1.4 返回地址
當一個方法執行完成後有兩種返回方式:
- 正常返回:執行引擎執行到任意一個返回的字節碼指令
- 異常返回:在方法執行過程中遇到異常而退出。異常包括虛擬機內部異常以及代碼中使用athrow字節碼拋出的異常
無論以何種方式返回,方法退出前都需要回到方法被調用的位置。一般來說方法正常退出時,調用者PC計數器的值即為返回地址;異常退出時,返回地址通過異常處理器來確定。
2 方法調用
方法調用不是方法執行,方法調用的唯一任務就是確定方法執行的版本,並不涉及具體方法的執行。Class文件在編譯的過程中並不包含傳統編譯中的連接,一切方法調用在編譯期間隻是符號引用而不是方法在實際執行時的內存地址入口。方法調用主要分為兩種:
- 解析調用
- 分派調用
2.1 解析
所有方法調用的目標方法在Class文件中都隻是一個符號引用,在類加載的過程中會將其中一部分符號引用轉換成直接引用,能夠轉換的前提是:該方法在編譯時即可確定其調用的版本,且該方法在運行期間是不會改變的。上述解析過程稱為靜態解析,而與之相對應的就是動態解析。符合靜態解析標準的方法主要有以下幾種:
- 私有方法
- 靜態方法
- 父類方法
- 被final修飾的方法
可以看出,上述幾種方法都是不支持覆寫的,所以在編譯期即可確認其執行版本,因而支持靜態解析。
Java虛擬機一共提供了5個方法調用的指令:
- invokestatic:調用靜態方法
- invokespecial:調用實例構造器方法,私有方法和父類方法
- invokevirtual:調用虛方法
- invokeinterface: 調用接口方法,會在運行時再確定一個實現此接口的對象
- invokedynamic: 先在運行時動態解析出調用點限定符所引用的方法,然後再執行該方法,在此之前的4條調用指令,分派邏輯是固化在Java虛擬機內部的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的
invokestatic,invokespecial兩個指令所調用的方法都是在編譯期即可確定其唯一調用版本,符合這個條件的有靜態方法,私有方法,實例構造器和父類方法四類。它們在類加載的時候就會把符號引用解析為該方法的直接引用。這些方法可以統稱為非虛方法,與之相反,其它方法就稱為虛方法(除去final方法)。
Java中的非虛方法除了使用invokestatic與invokespecial指令調用的方法之後還有一種,就是被final修飾的方法。雖然final方法是使用invokevirtual指令來調用的,但是由於它無法被覆蓋,沒有其它版本,所以也無須對方法接收都進行多態選擇,又或者說多態選擇的結果是唯一的。在Java語言規範中明確說明了final方法是一種非虛方法。
靜態調用一定是個靜態過程,在編譯期完全確定。
2.2 分派調用
與解析調用不同的是,分派調用既有靜態分派也有動態分派。
2.2.1 靜態分派
靜態分派多發生在方法的重載上,來看下下麵這個例子:
package com.xtayfjpk.jvm.chapter8;
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(Man man) {
System.out.println("hello man...");
}
public void sayHello(Woman woman) {
System.out.println("hello woman...");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(woman);
}
}
執行結果為:
hello guy...
hello guy...
為什麼虛擬機執行的是public void sayHello(Human guy)
呢?這裏需要解釋一個概念,首先來看下main方法中的前兩行代碼:
Human man = new Man();
Human woman = new Woman();
一個實例對象有靜態類型和實際類型兩個類型,靜態類型在編譯時即確定而實際類型則需要到運行時才可確定。上述兩個變量的靜態類型均為Human
,而實際類型則為Man
和Woman
。
靜態類型在編譯時即可確定並不是說靜態類型不可改變,下麵兩行代碼即可改變靜態類型:
sd.sayHello((Man)man);
sd.sayHello((Woman)woman);
由於虛擬機在編譯重載方法調用指令時是通過參數的靜態類型進行選擇的,並且靜態類型是在編譯期即可確定的,所以在上述的例子中虛擬機執行的方法是public void sayHello(Human guy)
。
2.2.2 動態分派
與靜態分派相對應的便是動態分派,動態分派的含義也較容易理解,即在運行時才確定方法執行的具體版本並進行分派。動態分派最典型的場景就是——方法重寫。
package com.xtayfjpk.jvm.chapter8;
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected 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的對象調用相同的方法卻實際上並沒有執行相同的方法,說明其方法的分派並不是通過靜態類型來確定,而是根據兩個變量的實際類型來確定的。Java虛擬機是如何利用實際類型來分派方法的執行版本的呢?來看看上述代碼的字節碼:
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #16 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Man
3: dup
4: invokespecial #18 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #19 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman
11: dup
12: invokespecial #21 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
24: new #19 // class com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman
27: dup
28: invokespecial #21 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22 // Method com/xtayfjpk/jvm/chapter8/DynamicDispatch$Human.sayHello:()V
36: return
第16-21行是關鍵部分,第16和第20兩行分別把剛剛創建的兩個對象的引用壓到棧頂,這兩個對象是將執行的sayHello()方法的所有者,稱為接收者(Receiver),第17和第21兩行是方法調用指令,單從字節碼的角度來看,這兩條調用指令無論是指令(都是invokevirtual)還是參數(都是常量池中Human.sayHello()的符號引用)都完全一樣,但是這兩條指令最終執行的目標方法並不相同,其原因需要從invokevirutal指令的多態查找過程開始說起,invokevirtual指令的運行時解析過程大致分為以下步驟:
- a.找到操作數棧頂的第一個元素所指向的對象實際類型,記作C。
- b.如果在類型C中找到與常量中描述符和簡單名稱都相同的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找結束;不通過則返回java.lang.IllegalAccessError錯誤。
- c.否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜索與校驗過程。
- d.如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError錯誤。
由於invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。我們把這種在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。
2.2.3 動態分派實現
由於動態分派在Java程序運行過程中經常會出現,所以通常Java虛擬機在動態分派過程中並不是通過上述查找過程實現的,而是通過虛方法表實現的。
虛擬機為每個類構建了一個方法表,方法表中的每一項存放對應方法的實際入口地址。如果某個方法在子類中沒有實現,則子類虛方法表中的該方法指向的是父類的該方法;相反則指向子類的該方法。因而動態分派的過程實際上就是查找虛方法表的過程。
另外為了實現上的方便,具有相同簽名的方法,在父類,子類的虛方法表中應該具有一樣的索引號,這樣當類型轉換時,僅需變更查找的方法表即。
最後更新:2017-07-11 01:02:54