基礎強化:深入理解JVM中的方法調用
作者:xiaolyuh
my.oschina.net/xiaolyuh/blog/3168216
方法調用并不等同于方法中的代碼被執(zhí)行,方法調用階段唯一的任務就是確定被調用方法的版本(即調用哪一個方法),暫時還未涉及方法內部的具體運行過程。
一切方法調用在Class文件里面存儲的都只是符號引用,而不是方法在實際運行時內存布局中的入口地址(也就是之前說的直接引用)。
解析
所有方法調用的目標方法在Class文件里面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能夠成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,并且這個方法的調用版本在運行期是不可改變的。
換句話說,調用目標在程序代碼寫好、編譯器進行編譯那一刻就已經(jīng)確定下來。這類方法的調用被稱為解析(Resolution),在Java語言中符合這種要求的主要有靜態(tài)方法和私有方法。
方法調用指令
invokestatic:用于調用靜態(tài)方法。invokespecial:用于調用實例構造器<init>()方法、私有方法和父類中的方法。invokevirtual:用于調用所有的虛方法。invokeinterface:用于調用接口方法,會在運行時再確定一個實現(xiàn)該接口的對象。invokedynamic:先在運行時動態(tài)解析出調用點限定符所引用的方法,然后再執(zhí)行該方法。
前面4條調用指令,分派邏輯都固化在Java虛擬機內部,而
invokedynamic指令的分派邏輯是由用戶設定的引導方法來決定的。
方法分類
在java語言中方法主要分為“虛方法”和“非虛方法”。
非虛方法:在類加載的時候就可以把符號引用解析為該方法的直接引用。比如:靜態(tài)方法、私有方法、實例構造器、父類方法和被final修飾的方法。
虛方法:需要在運行時才能將符號引用轉換成直接引用,如,分派。
分派
分派(Dispatch)它可能是靜態(tài)的也可能是動態(tài)的,按照分派依據(jù)的宗量數(shù)可分為單分派和多分派。這兩類分派方式兩兩組合就構成了靜態(tài)單分派、靜態(tài)多分派、動態(tài)單分派、動態(tài)多分派4種分派組合情況。
靜態(tài)分派
依賴靜態(tài)類型來決定方法執(zhí)行版本的分派動作,都稱為靜態(tài)分派。靜態(tài)分派的最典型應用表現(xiàn)就是方法重載,虛擬機(或者準確地說是編譯器)在重載時是通過參數(shù)的靜態(tài)類型來作為判定依據(jù)的。
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 guy) {
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy) {
System.out.println("hello,lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
運行結果:
hello,guy!
hello,guy!
Human man = new Man();
這里的Human就是變量的“靜態(tài)類型”(Static Type),或者叫“外觀類型”(Apparent Type);Man就是變量的“實際類型”(Actual Type)或者叫“運行時類型”(Runtime Type)。
動態(tài)分派
我們把在運行期根據(jù)實際類型確定方法執(zhí)行版本的分派過程稱為動態(tài)分派。最典型的表現(xiàn)就是重寫。
public class DynamicDispatch {
static abstract class Human {
abstract void sayHello();
}
static class Man extends Human {
public void sayHello() {
System.out.println("hello,Man!");
}
}
static class Woman extends Human {
public 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();
}
}
運行結果:
hello,Man!
hello,Woman!
我們通過javap命令看下main方法的字節(jié)碼:
...
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class com/xiaolyuh/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method com/xiaolyuh/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class com/xiaolyuh/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method com/xiaolyuh/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method com/xiaolyuh/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method com/xiaolyuh/DynamicDispatch$Human.sayHello:()V
24: return
LineNumberTable:
line 27: 0
line 28: 8
line 29: 16
line 30: 20
line 31: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
8 17 1 man Lcom/xiaolyuh/DynamicDispatch$Human;
16 9 2 woman Lcom/xiaolyuh/DynamicDispatch$Human;
}
...
通過字節(jié)碼我們發(fā)現(xiàn):在main方法中,sayHello()方法的調用對應的符號引用是一樣的,com/xiaolyuh/DynamicDispatch$Human.sayHello:()V。
在這里我們可以得出一個結論:在動態(tài)分派的情況下,在編譯時期我們是無法確定方法的直接引用的,那么它是怎么實現(xiàn)重載方法的調用的呢?問題關鍵是在invokevirtual指令上,在執(zhí)行invokevirtual指令時,invokevirtual指令會去確定方法的調用版本。
invokevirtual指令的運行過程
找到操作數(shù)棧頂?shù)牡谝粋€元素所指向的對象的實際類型,記作C。
如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;不通過則返回java.lang.IllegalAccessError異常。
否則,按照繼承關系從下往上依次對C的各個父類進行第二步的搜索和驗證過程。4. 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
正是因為invokevirtual指令執(zhí)行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令并不是把常量池中方法的符號引用解析到直接引用上就結束了,還會根據(jù)方法接收者的實際類型來選擇方法版本,這個過程就是Java語言中方法重寫的本質。
當子類聲明了與父類同名的字段時,雖然在子類的內存中兩個字段都會存在,但是子類的字段會遮蔽父類的同名字段
動態(tài)分派的實現(xiàn)
因為動態(tài)方法執(zhí)行非常頻繁,并且動態(tài)分派的方法版本選擇需要在運行時,在實際接受者類型的方法元數(shù)據(jù)中搜索合適的目標方法,因此,Java虛擬機實現(xiàn)基于執(zhí)行性能的考慮,虛擬機會為類型在方法區(qū)中建立一個虛方法表(Virtual Method Table,也稱為vtable,與此對應的,在invokeinterface執(zhí)行時也會用到接口方法表——Interface Method Table,簡稱itable),使用虛方法表索引來代替元數(shù)據(jù)查找以提高性能。

虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表中的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現(xiàn)入口。如果子類中重寫了這個方法,子類虛方法表中的地址也會被替換為指向子類實現(xiàn)版本的入口地址。
在圖中,Son重寫了來自Father的全部方法,因此Son的方法表沒有指向Father類型數(shù)據(jù)的箭頭。但是Son和Father都沒有重寫來自Object的方法,所以它們的方法表中所有從Object繼承來的方法都指向了Object的數(shù)據(jù)類型。
虛方法表一般在類加載的連接階段進行初始化,準備了類的變量初始值后,虛擬機會把該類的虛方法表也一同初始化完畢。
單分派與多分派
方法的接收者與方法的參數(shù)統(tǒng)稱為方法的宗量。分派基于多少種宗量,可以將分派劃分為單分派和多分派兩種。單分派是根據(jù)一個宗量對目標方法進行選擇,多分派則是根據(jù)兩個及以上的宗量對目標方法進行選擇。
靜態(tài)分派需要根據(jù)靜態(tài)類型和方法參數(shù)兩個宗量來確定方法調用,所以屬于多分派。
動態(tài)分派只需要根據(jù)實際類型一個宗量來確定方法的調用,所以屬于單分派。
在動態(tài)分派的過程中,方法簽名是確定的,所以方法參數(shù)就不會變,方法調用就取決于參數(shù)的實際類型。
總結
解析調用一定是個靜態(tài)的過程,在編譯期間就完全確定,在類加載的解析階段就會把涉及的符號引用全部轉變?yōu)槊鞔_的直接引用,不必延遲到運行期再去完成。分派(Dispatch)調用則要復雜許多,它可能是靜態(tài)的也可能是動態(tài)的,按照分派依據(jù)的宗量數(shù)可分為單分派和多分派。
這兩類分派方式兩兩組合就構成了靜態(tài)單分派、靜態(tài)多分派、動態(tài)單分派、動態(tài)多分派4種分派組合情況。
