??對面向對象的3大特點,很多人可以絕不猶豫地講出來,封裝,繼承,多態。封裝,和繼承自沒必要說,而對多態的理解,可能對很多人來講,總好像理解了,但是好像又有點迷惑,這篇文章側重介紹這個特性。
??多態的定義:指允許不同類的對象對同1消息做出響應。即同1消息可以根據發送對象的不同而采取多種不同的行動方式。這類技術稱為動態綁定(dynamic binding),是指在履行期間判斷所援用對象的實際類型,根據其實際的類型調用其相應的方法。
??現實中,關于多態的例子不勝枚舉。比方說按下 F1 鍵這個動作,如果當前在 Flash 界面下彈出的就是 AS 3 的幫助文檔;如果當前在 Word 下彈出的就是 Word 幫助;在 Windows 下彈出的就是 Windows 幫助和支持。同1個事件產生在不同的對象上會產生不同的結果。
多態的作用:消除類型之間的耦合關系,增加程序的靈活性和擴大性。
Java多態性主要體現在以下兩個方面。
子類繼承父類,重寫父類方法,注意的是方法簽名必須相同, 返回類型必須是本類或其子類的實例(jdk 1.5 版本以后)。
類內部可以有很多同名的方法,注意的是名稱相同, 參數及返回值類型可以不同, 這就叫重載。
要了解多態機制的具體實現機制,就需要深入了解Java 虛擬機對方法的調用進程和分派特性。
??首先需要明白,方法調用其實不同等于方法履行,方法調用階段唯1的任務就是肯定被調用方法的版本(即調用哪個方法),暫時還不觸及方法內部的具體運行進程。在程序運行時,進行方法調用是最普遍、最頻繁的操作,Class文件的編譯進程中不包括傳統編譯中的連接步驟,1切方法調用在Class文件里面存儲的都只是符號援用,而不是方法在實際運行時內存布局中的入口地址(相當于之前說的直接援用)。這個特性給Java帶來了更強大的動態擴大能力,但也使得Java方法調用進程變得相對復雜起來,需要在類加載期間,乃至到運行期間才能肯定目標方法的直接援用。
??所有方法調用中的目標方法在Class文件里面都是1個常量池中的符號援用,在類加載的解析階段,會將其中的1部份符號援用轉化為直接援用,這類解析能成立的條件是:方法在程序真正運行之前就有1個可肯定的調用版本,并且這個方法的調用版本在運行期是不可改變的。換句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須肯定下來。這類方法的調用稱為解析(Resolution)。
??在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,后者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都合適在類加載階段進行解析。
與之相對應的是,在Java虛擬機里面提供了5條方法調用字節碼指令,分別以下。
invokestatic:調用靜態方法。
invokespecial:調用實例構造器<init>方法、私有方法和父類方法。
invokevirtual:調用所有的虛方法。
invokeinterface:調用接口方法,會在運行時再肯定1個實現此接口的對象。
invokedynamic:先在運行時動態解析出調用點限定符所援用的方法,然后再履行該方法,在此之前的4條調用指令,分派邏輯是固化在Java虛擬機內部的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。
??**只要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段中肯定唯1的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法**4類,它們在類加載的時候就會把符號援用解析為該方法的直接援用。這些方法可以稱為非虛方法,與之相反,其他方法稱為虛方法(除去final方法,后文會提到)。
??Java中的非虛方法除使用invokestatic、invokespecial調用的方法以外還有1種,就是被final修飾的方法。雖然final方法是使用invokevirtual指令來調用的,但是由于它沒法被覆蓋,沒有其他版本,所以也不必對方法接收者進行多態選擇,又或說多態選擇的結果肯定是唯1的。,在Java語言規范中明確說明了final方法是1種非虛方法。
??解析調用1定是個靜態的進程,在編譯期間就完全肯定,在類裝載的解析階段就會把觸及的符號援用全部轉變成可肯定的直接援用,不會延遲到運行期再去完成。而分派(Dispatch)調用則多是靜態的也多是動態的,根據分派根據的宗量數可分為單分派和多分派。這兩類分派方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派4種分派組合情況。
??所有依賴靜態類型來定位方法履行版本的分派動作稱為靜態分派。靜態分派的典型利用是方法重載。靜態分派產生在編譯階段,因此肯定靜態分派的動作實際上不是由虛擬機來履行的。另外,編譯器雖然能肯定出方法的重載版本,但在很多情況下這個重載版本其實不是“唯1的”,常常只能肯定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(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 sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(woman);
}
}
運行結果
Hello guy!
Hello guy!
進程分析:
??我們把上面代碼中的“Human”稱為變量的靜態類型(Static Type),或叫做的外觀類型(Apparent Type),后面的“Man”則稱為變量的實際類型(Actual Type),靜態類型和實際類型在程序中都可以產生1些變化,區分是靜態類型的變化僅僅在使用時產生,變量本身的靜態類型不會被改變,并且終究的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期才可肯定,編譯器在編譯程序的時候其實不知道1個對象的實際類型是甚么。
main()里面的兩次sayHello()方法調用,在方法接收者已肯定是對象“sd”的條件下,使用哪一個重載版本,就完全取決于傳入參數的數量和數據類型。代碼中刻意地定義了兩個靜態類型相同但實際類型不同的變量,但虛擬機(準確地說是編譯器)在重載時是通過參數的靜態類型而不是實際類型作為判定根據的。并且靜態類型是編譯期可知的,因此,在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪一個重載版本,所以選擇了sayHello(Human)作為調用目標,并把這個方法的符號援用寫到main()方法里的兩條invokevirtual指令的參數中。
我們把運行期根據實際類型肯定方法履行版本的分派進程稱為動態分派,動態分派的典型利用是方法重寫。
/**
* 動態分派
*
* @author bridge
*/
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
利用 javap -c 命令反編class文件,可以得到Main方法的字節碼以下:
public static void main(java.lang.String[]);
Code:
0: new #2 // class methodInvoke/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method methodInvoke/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class methodInvoke/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method methodInvoke/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method methodInvoke/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method methodInvoke/DynamicDispatch$Human.sayHello:()V
24: new #4 // class methodInvoke/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method methodInvoke/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method methodInvoke/DynamicDispatch$Human.sayHello:()V
36: return
字節碼分析:
0~15行的字節碼是準備動作,作用是建立man和woman的內存空間、調用Man和Woman類型的實例構造器,將這兩個實例的援用寄存在第1、2個局部變量表Slot當中,這個動作也就對應了代碼中的這兩句:
Human man=new Man();
Human woman=new Woman();
接下來的16~21句是關鍵部份,16、20兩句分別把剛剛創建的兩個對象的援用壓到棧頂,這兩個對象是將要履行的sayHello()方法的所有者,稱為接收者(Receiver);
17和21句是方法調用指令,這兩條調用指令單從字節碼角度來看,不管是指令(都是invokevirtual)還是參數(都是常量池中第22項的常量,注釋顯示了這個常量是Human.sayHello()的符號援用)完全1樣的,但是這兩句指令終究履行的目標方法其實不相同。緣由就需要從invokevirtual指令的多態查找進程開始說起,invokevirtual指令的運行時解析進程大致分為以下幾個步驟:
1)找到操作數棧頂的第1個元素所指向的對象的實際類型,記作C。
2)如果在類型C中找到與常量中的描寫符和簡單名稱都符合的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接援用,查找進程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
3)否則,依照繼承關系從下往上順次對C的各個父類進行第2步的搜索和驗證進程。
4)如果始終沒有找到適合的方法,則拋出java.lang.AbstractMethodError異常。
??由于invokevirtual指令履行的第1步就是在運行期肯定接收者的實際類型,所以兩次調用中的invokevirtual指令把常量池中的類方法符號援用解析到了不同的直接援用上,這個進程就是Java語言中方法重寫的本質。
??方法的接收者與方法的參數統稱為方法的宗量,根據分派基于多少種宗量,可以將分派劃分為單分派和多分派兩種。
單分派是根據1個宗量對目標方法進行選擇,多分派則是根據多于1個宗量對目標方法進行選擇。
/**
* 單分派與多分派演示
*
* @author bridge
*/
public class Dispatch {
static class QQ {
}
static class _360 {
}
public static class Father {
public void hardChoice(QQ arg) {
System.out.println("Father choose QQ");
}
public void hardChoice(_360 arg) {
System.out.println("Father choose 360");
}
}
public static class Son extends Father {
public void hardChoice(QQ arg) {
System.out.println("Son choose QQ");
}
public void hardChoice(_360 arg) {
System.out.println("Son choose 360");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}
運行結果
Father choose 360
Son choose QQ
Main 方法的字節碼以下:
public class methodInvoke.Dispatch {
public methodInvoke.Dispatch();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class methodInvoke/Dispatch$Father
3: dup
4: invokespecial #3 // Method methodInvoke/Dispatch$Father."<init>":()V
7: astore_1
8: new #4 // class methodInvoke/Dispatch$Son
11: dup
12: invokespecial #5 // Method methodInvoke/Dispatch$Son."<init>":()V
15: astore_2
16: aload_1
17: new #6 // class methodInvoke/Dispatch$_360
20: dup
21: invokespecial #7 // Method methodInvoke/Dispatch$_360."<init>":()V
24: invokevirtual #8 // Method methodInvoke/Dispatch$Father.hardChoice:(LmethodInvoke/Dispatch$_360;)V
27: aload_2
28: new #9 // class methodInvoke/Dispatch$QQ
31: dup
32: invokespecial #10 // Method methodInvoke/Dispatch$QQ."<init>":()V
35: invokevirtual #11 // Method methodInvoke/Dispatch$Father.hardChoice:(LmethodInvoke/Dispatch$QQ;)V
38: return
}
分析:
在main函數中調用了兩次hardChoice()方法,這兩次hardChoice()方法的選擇結果在程序輸出中已顯示得很清楚了。
我們來看看編譯階段編譯器的選擇進程,也就是靜態分派的進程。這時候選擇目標方法的根據有兩點:1是靜態類型是Father還是Son,2是方法參數是QQ還是360。這次選擇結果的終究產物是產生了兩條invokevirtual指令,兩條指令的參數分別為常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符號援用。由于是根據兩個宗量進行選擇,所以Java語言的靜態分派屬于多分派類型。
再看看運行階段虛擬機的選擇,也就是動態分派的進程。在履行“son.hardChoice(new QQ())”這句代碼時,更準確地說,是在履行這句代碼所對應的invokevirtual指令時,由于編譯期已決定目標方法的簽名必須為hardChoice(QQ),虛擬機此時不會關心傳遞過來的參數“QQ”究竟是“騰訊QQ”還是“奇瑞QQ”,由于這時候參數的靜態類型、實際類型都對方法的選擇不會構成任何影響,唯1可以影響虛擬機選擇的因素只有此方法的接受者的實際類型是Father還是Son。由于只有1個宗量作為選擇根據,所以Java語言的動態分派屬于單分派類型。
根據上述論證的結果,我們可以總結1句:到目前為止,Java語言是1門靜態多分派、動態單分派的語言。
參考資料
【深入理解Java虛擬機】 周志明著
上一篇 PGM:貝葉斯網的參數估計
下一篇 計算從[1,n]的素數個數