Java虛擬機器之‘靜態分派、動態分派’
Java是一門面向物件的語言,因為Java具備面向物件的三個特性:封裝、繼承、多型。分派的過程會揭示多型特性的一些最基本的體現,如“過載”和“重寫”在Java虛擬機器中是如何實現的,並不是語法上如何寫,我們關心的依然是虛擬機器如何確定正確的目標方法。
一、靜態分派
先看一段程式碼
package cn.zjm.show.polymorphic; 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,gentleman!"); } public void sayHello(Woman woman) { System.out.println("hello,lady!"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); StaticDispatch s = new StaticDispatch(); s.sayHello(man); s.sayHello(woman); } }
執行結果:
hello,guy!
hello,guy!
相信對Java稍有理解的人都能想到正確的執行結果,但為什麼會選擇執行引數型別為Human的過載呢?先按如下程式碼定義兩個重要的概念:
Human man = new Man();
我們把上面程式碼的 Human 稱為變數的 靜態型別(Static Type),或者叫做 外觀型別(Apparent Type),後面的 Man 則稱之為變數的 實際型別(Actual Type),靜態型別和實際型別在程式中都可以發生一些變化,區別是 靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的;而實際型別變化的結果在執行期才可確定,編譯器在編譯程式的時候並不知道一個物件的實際型別是什麼。
//實際型別變化
Human man = new Man();
man = new Woman();
//靜態型別變化
s.sayHello((Man) man);
s.sayHello((Woman) man);
執行結果:
hello,gentleman!
hello,lady!
解釋了這兩個概念,回到第一段程式碼中。main()裡面兩次sayHello()方法呼叫,在方法的接收者已經確定是物件 s 的前提下,使用哪個過載版本,就完全取決於 傳入引數的數量和資料型別。程式碼刻意地定義了兩個靜態型別相同但是實際型別不同的變數,但編譯器在過載時是通過引數的靜態型別而不是實際型別作為判定依據的。並且靜態型別是編譯期可知的,因此,在編譯階段,Javac編譯器會根據引數的靜態型別決定使用哪個過載版本,所以選擇了 sayHello(Human) 最為呼叫目標。
二、動態分派
動態分派和多型性另一個重要體現——重寫(Override)有著很密切的關聯。我們還是用前面的Man和Woman一起sayHello的栗子來講解動態分派。
package cn.zjm.show.polymorphic;
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
這個執行結果不會出乎任何人的預料,對於習慣了面向物件思維的Java程式設計師或覺得這是理所應當的。現在的問題還和前面的一樣,虛擬機器是如何知道要呼叫哪個方法的?
顯然這裡不可能再根據靜態型別來決定,因為靜態型別同樣都是 Human 的兩個變數 man 和 woman 在呼叫 sayHello() 方法時執行了不同的行為,並且變數man在兩次呼叫中執行了不同的方法。導致這個現象的原因很明顯,是這兩個變數的實際型別不同,Java虛擬機器是如何根據實際型別來分派方法執行版本的呢?用javap進行反編譯 。
main()方法位元組碼:
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 cn/zjm/show/polymorphic/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method cn/zjm/show/polymorphic/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class cn/zjm/show/polymorphic/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method cn/zjm/show/polymorphic/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method cn/zjm/show/polymorphic/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method cn/zjm/show/polymorphic/DynamicDispatch$Human.sayHello:()V
24: new #4 // class cn/zjm/show/polymorphic/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method cn/zjm/show/polymorphic/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method cn/zjm/show/polymorphic/DynamicDispatch$Human.sayHello:()V
36: return
LineNumberTable:
line 24: 0
line 25: 8
line 26: 16
line 27: 20
line 28: 24
line 29: 32
line 30: 36
}
0~15行的位元組碼是建立兩個物件的過程,分別呼叫了 Man 和 Woman 型別的例項構造器,將兩個例項的引用存放在1、2區域性變量表Slot中,這兩個動作對應了程式碼中的這兩句:
Human man = new Man();
Human woman = new Woman();
接下來16~21句是關鍵部分,16句和20句分別把剛剛建立的兩個物件的引用壓入棧頂,這兩個物件是將要執行sayHello()方法的所有者,稱為接收者(Receiver);17和21句是方法呼叫指令,這兩條呼叫指令但從位元組碼角度來看,無論是指令(都是invokevirtual)還是引數(都是常量池中第6項的常量,第六項常量為:#6 = Methodref #12.#25 // cn/zjm/show/polymorphic/DynamicDispatch$Human.sayHello:()V)都是完全一樣的,但是這兩句指令最終執行的目標方法並不相同。原因就需要從invokevirtual執行的多型查詢過程開始說起,invokevirtual指令的執行時解析過程大致分為以下幾個步驟:
1)找到運算元棧頂的第一個元素所指向的物件的實際型別,記作C。
2)如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查詢過程結束;如果不通過,則返回java.langIllegalAccessError異常。
3)否則,按照繼承關係從下往上依此對C的各個父類進行第2步的搜尋和驗證過程。
4)如果始終沒有找到合適的方法,則丟擲java,lang.AbstractMethodError異常。
由於invokevirtual指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。我們把這種執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。
靜態分派、動態分派、虛擬機器、Java