一、方法調用
方法調用不同於方法執行,方法調用階段的唯一任務就是確定被調用方法的版本(即調用哪一個方法),暫時還不涉及方法內部的具體運行過程。Class文件的編譯過程中不包括傳統編譯器中的連接步驟,一切方法調用在Class文件里面存儲的都是符號引用,而不是方法在實際運行時內存布局中的入口地址(直接引用)。也就是需要在類加載階段,甚至到運行期才能確定目標方法的直接引用。
二、解析
如前所述,所有的方法調用中的目標方法在Class文件里面都是一個常量池中的符號引用,在類加載階段,會將其中的一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,並且這個方法的調用版本在運行期間是不可變的。也就是說,調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來,這類方法的調用成為解析。
JAVA中符號“編譯器可知、運行期不可變”的方法包括:靜態方法、私有方法兩大類。前者與類型直接關聯,后者在外部不可被訪問,這就決定了他們都不可能通過繼承或別的方式重寫其版本。因此都適合在類的加載階段進行解析。
JAVA虛擬機里面提供了5條方法調用字節碼指令。分別如下:
invokestatic:調用靜態方法
invokespecial:調用實例構造器<init>方法、私有方法和父類方法(super(),super.method())。
invokevirtual:調用所有的虛方法(靜態方法、私有方法、實例構造器、父類方法、final方法都是非虛方法)。
invokeinterface:調用接口方法,會在運行時期再確定一個實現此接口的對象。
invokedynamic:現在運行時期動態解析出調用點限定符所引用的方法,然后再執行該方法,在此之前的4條指令,分派邏輯都是固化在虛擬機里面的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。
只要能被invokestatic和invokespecial指令調用的方法都可以在解析階段中確定唯一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法4類,它們在類加載階段就會把符號引用解析為該方法的直接引用。這些方法稱為非虛方法(還包括使用final修飾的方法,雖然final方法使用invokevirtual指令調用,因為final方法注定不會被重寫,也就是無法被覆蓋,也就無需對其進行多態選擇)。
解析調用一定是一個靜態的過程,在編譯期間就可以完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉化為可確定的直接引用,不會延遲到運行期去完成。而分派調用可能是靜態的也可能是動態的,根據分派一句的宗量數可分為單分派和多分派。因此分派可分為:靜態單分派、靜態多分派、動態單分派、動態多分派。
三、分派
1.靜態分派(方法重載):
先看一段代碼:
1 public class StaticDispatch { 2 static abstract class Human{ 3 4 } 5 static class Man extends Human{ 6 7 } 8 static class Woman extends Human{ 9 10 } 11 @Test 12 public void test(){ 13 Human man = new Man(); 14 Human woman = new Woman(); 15 StaticDispatch sr = new StaticDispatch(); 16 sr.sayHello(man); 17 sr.sayHello(woman); 18 19 } 20 21 22 public void sayHello(Human guy){ 23 System.out.println("Hello guy"); 24 } 25 public void sayHello(Man guy){ 26 System.out.println("Hello man"); 27 } 28 public void sayHello(Woman guy){ 29 System.out.println("Hello woman"); 30 } 31 }
運行結果為:
Hello guy
Hello guy
要解釋上面的現象,先要說明幾個概念,看如下代碼。
Human man = new Man();
上面一行代碼中,Human成為變量man的靜態類型,或者叫做外觀類型,后面的Man則稱為變量的實際類型,靜態類型和實際類型在程序中都可以發生一些變化,區別是靜態類型的變化僅僅在使用時發生(比如強制類型轉換),變量本身的靜態類型不會改變,並且最終的靜態類型在編譯器就是可知的;而實際類型變化的結果在運行期才可以確定,編譯器在編譯程序的時候並不知道一個對象的實際類型是什么。
比如如下代碼:
//實際類型變化
Human man = new Man();
Human woman = new Woman();
//通過強轉實現靜態類型變化(變量本身靜態類型不變)
sr.sayHello((Man)man);
sr.sayHello((Woman)woman);
虛擬機(編譯器)在確定重載函數版本時是通過參數的靜態類型而不是實際類型作為判定依據。因此,在編譯階段,編譯器就可以根據靜態類型確定使用哪個重載的版本。
2.動態分派(方法重寫Override):
為了說明動態分派的概念,先看一段代碼:
1 public class DynamicDispatch{ 2 static abstract class Human{ 3 protected abstract void sayHello(); 4 } 5 static class Man extends Human{ 6 @Override 7 protected void sayHello(){ 8 System.out.println("man say hello"); 9 } 10 } 11 static class Woman extends Human{ 12 @Override 13 protected void sayHello(){ 14 System.out.println("woman say hello"); 15 } 16 } 17 18 19 public static void man(String[] args){ 20 Human man = new Man(); 21 Human woman = new Woman(); 22 man.sayHello(); 23 woman.sayHello(); 24 man = new Woman(); 25 man.sayHello(); 26 27 } 28 29 }
輸出結果為:
man say hello
woman say hello
woman say hello
熟悉多態的人對上面的結果不會感到驚訝。下面使用javap命令輸出這段代碼的字節碼。
如上所示,方法的調用指令都使用了invokevirtual指令,invokevirtual指令的運行時解析過程大致分為以下幾個步驟。
1)找到操作數棧頂的第一個元素(對象引用)所指向的對象的實際類型,記作C;
2)如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.IllegalAccessError。
3)否則,按照繼承關系從下往上依次對C的各個父類進行第2步的搜索和驗證。
4)如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
由於invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,這又是java語言中方法重寫產生多態的本質。
3.單分派與多分派
方法的接收者和方法的參數統稱為方法的宗量。根據分派基於多少種宗量,可以將分派划分為單分派和多分派。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。
1 import org.junit.Test; 2 3 /** 4 * Created by chen on 2016/3/23. 5 */ 6 public class Dispatch { 7 static class QQ{ 8 9 } 10 static class _360{ 11 12 } 13 14 public static class Father{ 15 public void hardChoice(QQ arg){ 16 System.out.println("father choose qq"); 17 } 18 public void hardChoice(_360 arg){ 19 System.out.println("father choose 360"); 20 } 21 } 22 public static class Son extends Father{ 23 public void hardChoice(QQ arg){ 24 System.out.println("son choose qq"); 25 } 26 public void hardChoice(_360 arg){ 27 System.out.println("son choose 360"); 28 } 29 } 30 @Test 31 public void test(){ 32 Father father = new Father(); 33 Father son = new Son(); 34 father.hardChoice(new _360()); 35 son.hardChoice(new QQ()); 36 } 37 }
運行結果:
father choose 360
son choose qq
上述有關於hardChoice方法的兩次調用,涉及了靜態分派和動態分派的過程。
首先看看編譯階段編譯器的選擇,也就是靜態分派的過程(關於重載)。此時選擇目標方法的依據有兩點:一是靜態類型是Father還是Son,而是方法參數是QQ還是_360。此處選擇結果最終的產物是產生了兩條invokevirtual指令,兩條指令的參數分別是指向Father.hardChoice(_360)和Father.hardChoice(QQ)方法的符號引用。因為是根據兩個宗量進行分派,所以java語言的靜態分派屬於多分派類型。
再看看運行階段虛擬機的選擇,也就是動態分派的過程(關於重寫),在執行“son.hardChoice(new QQ());”這句代碼時,更准確的說,是在執行invokevirtual指令時,由於編譯器已經確定了目標方法的簽名必須是hardChoice(QQ),虛擬機此時不會關心傳過來的參數類型,也就是此時傳過來的實際類型、靜態類型都不會對產生任何影響。唯一可以對虛擬機的選擇產生影響的就是此方法的接收者的實際類型是Father還是Son。因為只有一個宗量作為依據,所以java語言的動態分派屬於單分派。
4、虛擬機動態分派的實現
由於動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要運行時在類的方法元數據中搜索合適的目標方法,因此在虛擬機的實際實現中基於性能的考慮,大部分實現都不會真正的進行如此頻繁的搜索。面對這種情況,最常用的“穩定優化”手段就是為類在方法區中建立一個虛方法表(vtable,熟悉C++的肯定很熟悉。於此對應的,在invokeinterface執行時也會用到接口方法表---itable),使用虛方法表索引來代替元數據查找以提高性能。具體如下圖所示:
虛方法表中存放着各個方法的實際入口地址,如果某個方法在子類中沒有被重寫,那子類的虛方法表里面的地址入口和父類相同方法的入口地址是一致的,都指向父類的實現入口。如果子類重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。如上圖所示,Son重寫了來自Father的全部方法,因此Son的方法表沒有指向Father類型數據的箭頭。但是Son和Father都沒有重寫來自Object的方法,所以他們的方法表中所有從Object繼承來的方法都指向了Object的數據類型。
為了程序實現上的方便,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一樣的索引號,這樣當類型變換時,僅需要變更查找的方法表,就可以從不同的虛方法表中按照索引轉換出所需要的方法入口地址。
方法表一般在類加載階段的連接階段進行初始化,准備了類變量初始值之后,虛擬機會把該類的方法表也初始化完畢。