方法調用並不等同於方法執行,方法調用階段唯一的任務就是確定被調用方法的版本(即調用哪一個方法),暫時還不涉及方法內部的具體運行過程。在程序運行時,進行方法調用是最普遍、最頻繁的操作,Class文件的編譯過程中不包含傳統編譯中的連接步驟,一切方法調用在Class文件里面存儲的都只是符號引用,而不是方法在實際運行時內存布局中的入口地址(相當於之前說的直接引用)。這個特性給Java帶來了更強大的動態擴展能力,但也使得Java方法調用過程變得相對復雜起來,需要在類加載期間,甚至到運行期間才能確定目標方法的直接引用。
解析調用一定是個靜態的過程,在編譯期間就完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變為可確定的直接引用,不會延遲到運行期再去完成。而分派(Dispatch)調用則可能是靜態的也可能是動態的,根據分派依據的宗量數可分為單分派和多分派。這兩類分派方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派4種分派組合情況。
解析
所有方法調用中的目標方法在Class文件里面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可改變的。換句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來。這類方法的調用稱為解析(Resolution)。在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,后者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類加載階段進行解析。
與之相對應的是,在Java虛擬機里面提供了5條方法調用字節碼指令,分別如下。
invokestatic:調用靜態方法。
invokespecial:調用實例構造器<init>方法、私有方法和父類方法。
invokevirtual:調用所有的虛方法。
invokeinterface:調用接口方法,會在運行時再確定一個實現此接口的對象。
invokedynamic:先在運行時動態解析出調用點限定符所引用的方法,然后再執行該方法,在此之前的4條調用指令,分派邏輯是固化在Java虛擬機內部的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。
只要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段中確定唯一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法4類,它們在類加載的時候就會把符號引用解析為該方法的直接引用。這些方法可以稱為非虛方法,與之相反,其他方法稱為虛方法(除去final方法)。
Java中的非虛方法除了使用invokestatic、invokespecial調用的方法之外還有一種,就是被final修飾的方法。雖然final方法是使用invokevirtual指令來調用的,但是由於它無法被覆蓋,沒有其他版本,所以也無須對方法接收者進行多態選擇,又或者說多態選擇的結果肯定是唯一的。在Java語言規范中明確說明了final方法是一種非虛方法。
分派
Java是一門面向對象的程序語言,因為Java具備面向對象的3個基本特征:繼承、封裝和多態。分派調用過程將會揭示多態性特征的一些最基本的體現,如“重載”和“重寫”在Java虛擬機之中是如何實現的,這里的實現當然不是語法上該如何寫,我們關心的依然是虛擬機如何確定正確的目標方法。
靜態分派
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!
main()里面的兩次sayHello()方法調用,在方法接收者已經確定是對象“sr”的前提下,使用哪個重載版本,就完全取決於傳入參數的數量和數據類型。代碼中刻意地定義了兩個靜態類型相同但實際類型不同的變量,但虛擬機(准確地說是編譯器)在重載時是通過參數的靜態類型而不是實際類型作為判定依據的。並且靜態類型是編譯期可知的,因此,在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪個重載版本,所以選擇了sayHello(Human)作為調用目標,並把這個方法的符號引用寫到main()方法里的兩條invokevirtual指令的參數中。所有依賴靜態類型來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的。另外,編譯器雖然能確定出方法的重載版本,但在很多情況下這個重載版本並不是“唯一的”,往往只能確定一個“更加合適的”版本。這種模糊的結論在由0和1構成的計算機世界中算是比較“稀罕”的事情,產生這種模糊結論的主要原因是字面量不需要定義,所以字面量沒有顯式的靜態類型,它的靜態類型只能通過語言上的規則去理解和推斷。何為“更加合適的”版本。
public class Overload{ public static void sayHello(Object arg){ System.out.println("hello Object"); } public static void sayHello(int arg){ System.out.println("hello int"); } public static void sayHello(long arg){ System.out.println("hello long"); } public static void sayHello(Character arg){ System.out.println("hello Character"); } public static void sayHello(char arg){ System.out.println("hello char"); } public static void sayHello(char……arg){ System.out.println("hello char……"); } public static void sayHello(Serializable arg){ System.out.println("hello Serializable"); } public static void main(String[]args){ sayHello('a'); } }
上面的代碼運行后會輸出:
hello char
這很好理解,'a'是一個char類型的數據,自然會尋找參數類型為char的重載方法,如果注釋掉sayHello(char arg)方法,那輸出會變為:
hello int
這時發生了一次自動類型轉換,'a'除了可以代表一個字符串,還可以代表數字97(字符'a'的Unicode數值為十進制數字97),因此參數類型為int的重載也是合適的。我們繼續注釋掉sayHello(int arg)方法,那輸出會變為:
hello long
這時發生了兩次自動類型轉換,'a'轉型為整數97之后,進一步轉型為長整數97L,匹配了參數類型為long的重載。筆者在代碼中沒有寫其他的類型如float、double等的重載,不過實際上自動轉型還能繼續發生多次,按照char->int->long->float->double的順序轉型進行匹配。但不會匹配到byte和short類型的重載,因為char到byte或short的轉型是不安全的。我們繼續注釋掉sayHello(long arg)方法,那輸出會變為:
hello Character
這時發生了一次自動裝箱,'a'被包裝為它的封裝類型java.lang.Character,所以匹配到了參數類型為Character的重載,繼續注釋掉sayHello(Character arg)方法,那輸出會變為:
hello Serializable
這個輸出可能會讓人感覺摸不着頭腦,一個字符或數字與序列化有什么關系?出現hello Serializable,是因為java.lang.Serializable是java.lang.Character類實現的一個接口,當自動裝箱之后發現還是找不到裝箱類,但是找到了裝箱類實現了的接口類型,所以緊接着又發生一次自動轉型。char可以轉型成int,但是Character是絕對不會轉型為Integer的,它只能安全地轉型為它實現的接口或父類。Character還實現了另外一個接口java.lang.Comparable<Character>,如果同時出現兩個參數分別為Serializable和Comparable<Character>的重載方法,那它們在此時的優先級是一樣的。編譯器無法確定要自動轉型為哪種類型,會提示類型模糊,拒絕編譯。程序必須在調用時顯式地指定字面量的靜態類型,如:sayHello((Comparable<Character>)'a'),才能編譯通過。下面繼續注釋掉sayHello(Serializable arg)方法,輸出會變為:
hello Object
這時是char裝箱后轉型為父類了,如果有多個父類,那將在繼承關系中從下往上開始搜索,越接近上層的優先級越低。即使方法調用傳入的參數值為null時,這個規則仍然適用。我們把sayHello(Object arg)也注釋掉,輸出將會變為:
hello char……
7個重載方法已經被注釋得只剩一個了,可見變長參數的重載優先級是最低的,這時候字符'a'被當做了一個數組元素。使用的是char類型的變長參數,在驗證時還可以選擇int類型、Character類型、Object類型等的變長參數重載來把上面的過程重新演示一遍。但要注意的是,有一些在單個參數中能成立的自動轉型,如char轉型為int,在變長參數中是不成立的。
另外還有一點可能比較容易混淆:解析與分派這兩者之間的關系並不是二選一的排他關系,它們是在不同層次上去篩選、確定目標方法的過程。例如,前面說過,靜態方法會在類加載期就進行解析,而靜態方法顯然也是可以擁有重載版本的,選擇重載版本的過程也是通過靜態分派完成的。
動態分派
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的兩個變量man和woman在調用sayHello()方法時執行了不同的行為,並且變量man在兩次調用中執行了不同的方法。導致這個現象的原因很明顯,是這兩個變量的實際類型不同,Java虛擬機是如何根據實際類型來分派方法執行版本的呢?
main()方法的字節碼 public static void main(java.lang.String[]); Code: Stack=2,Locals=3,Args_size=1 0:new#16;//class org/fenixsoft/polymorphic/Dynamic-Dispatch $Man 3:dup 4:invokespecial#18;//Method org/fenixsoft/polymorphic/Dynamic-Dispatch $Man."<init>":()V 7:astore_1 8:new#19;//class org/fenixsoft/polymorphic/Dynamic-Dispatch $Woman 11:dup 12:invokespecial#21;//Method org/fenixsoft/polymorphic/DynamicDispatch $Woman."<init>":()V 15:astore_2 16:aload_1 17:invokevirtual#22;//Method org/fenixsoft/polymorphic/Dynamic-Dispatch $Human.sayHello:()V 20:aload_2 21:invokevirtual#22;//Method org/fenixsoft/polymorphic/Dynamic-Dispatch $Human.sayHello:()V 24:new#19;//class org/fenixsoft/polymorphic/Dynamic-Dispatch $Woman 27:dup 28:invokespecial#21;//Method org/fenixsoft/polymorphic/DynamicDispatch $Woman."<init>":()V 31:astore_1 32:aload_1 33:invokevirtual#22;//Method org/fenixsoft/polymorphic/DynamicDispatch $Human.sayHello:()V 36:return 0~15行的字節碼是准備動作,作用是建立man和woman的內存空間、調用Man和Woman類型的實例構造器,將這兩個實例的引用存放在第1、2個局部變量表Slot之中,
這個動作也就對應了代碼中的這兩句: Human man = newMan(); Human woman = newWoman();
接下來的16~21句是關鍵部分,16、20兩句分別把剛剛創建的兩個對象的引用壓到棧頂,這兩個對象是將要執行的sayHello()方法的所有者,稱為接收者(Receiver);17和21句是方法調用指令,這兩條調用指令單從字節碼角度來看,無論是指令(都是invokevirtual)還是參數(都是常量池中第22項的常量,注釋顯示了這個常量是Human.sayHello()的符號引用)完全一樣的,但是這兩句指令最終執行的目標方法並不相同。
原因就需要從invokevirtual指令的多態查找過程開始說起,invokevirtual指令的運行時解析過程大致分為以下幾個步驟:
1)找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C。
2)如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
3)否則,按照繼承關系從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
4)如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
由於invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。我們把這種在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。
單分派與多分派
方法的接收者與方法的參數統稱為方法的宗量,這個定義最早應該來源於《Java與模式》一書。根據分派基於多少種宗量,可以將分派划分為單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。
列舉了一個Father和Son一起來做出“一個艱難的決定”的例子。
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函數中調用了兩次hardChoice()方法,這兩次hardChoice()方法的選擇結果在程序輸出中已經顯示得很清楚了。
我們來看看編譯階段編譯器的選擇過程,也就是靜態分派的過程。這時選擇目標方法的依據有兩點:一是靜態類型是Father還是Son,二是方法參數是QQ還是360。這次選擇結果的最終產物是產生了兩條invokevirtual指令,兩條指令的參數分別為常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符號引用。因為是根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派類型。
再看看運行階段虛擬機的選擇,也就是動態分派的過程。在執行“son.hardChoice(new QQ())”這句代碼時,更准確地說,是在執行這句代碼所對應的invokevirtual指令時,由於編譯期已經決定目標方法的簽名必須為hardChoice(QQ),虛擬機此時不會關心傳遞過來的參數“QQ”到底是“騰訊QQ”還是“奇瑞QQ”,因為這時參數的靜態類型、實際類型都對方法的選擇不會構成任何影響,唯一可以影響虛擬機選擇的因素只有此方法的接受者的實際類型是Father還是Son。因為只有一個宗量作為選擇依據,所以Java語言的動態分派屬於單分派類型。
虛擬機動態分派的實現
由於動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要運行時在類的方法元數據中搜索合適的目標方法,因此在虛擬機的實際實現中基於性能的考慮,大部分實現都不會真正地進行如此頻繁的搜索。面對這種情況,最常用的“穩定優化”手段就是為類在方法區中建立一個虛方法表(Vritual Method Table,也稱為vtable,與此對應的,在invokeinterface執行時也會用到接口方法表——Inteface Method Table,簡稱itable),使用虛方法表索引來代替元數據查找以提高性能。
虛方法表中存放着各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表里面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。Son重寫了來自Father的全部方法,因此Son的方法表沒有指向Father類型數據的箭頭。但是Son和Father都沒有重寫來自Object的方法,所以它們的方法表中所有從Object繼承來的方法都指向了Object的數據類型。
為了程序實現上的方便,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一樣的索引序號,這樣當類型變換時,僅需要變更查找的方法表,就可以從不同的虛方法表中按索引轉換出所需的入口地址。方法表一般在類加載的連接階段進行初始化,准備了類的變量初始值后,虛擬機會把該類的方法表也初始化完畢。
ps:方法表是分派調用的“穩定優化”手段,虛擬機除了使用方法表之外,在條件允許的情況下,還會使用內聯緩存(Inline Cache)和基於“類型繼承關系分析”(Class Hierarchy Analysis,CHA)技術的守護內聯(Guarded Inlining)兩種非穩定的“激進優化”手段來獲得更高的性能,關於這兩種優化技術的原理和運作過程,可以參考JIT晚期運行期。