認識一下Java中方法重載和重寫的“真面目”
前言
考大家一道題目,下面的類執(zhí)行結果是什么???
public class DispatcherClient {
public static void main(String[] args) {
Animal a = new Animal();
Animal a1 = new Dog();
Animal a2 = new Cat();
Execute exe = new Execute();
exe.execute(a);
exe.execute(a1);
exe.execute(a2);
}
}
class Animal {
}
class Dog extends Animal {
}
class Cat extends Animal {
}
class Execute {
public void execute(Animal a) {
System.out.println("Animal");
}
public void execute(Dog d) {
System.out.println("dog");
}
public void execute(Cat c) {
System.out.println("cat");
}
}
不知道大家心里的答案是什么?反正我的答案是錯的。
正確的答案是:
為什么是Animal Animal Animal? 而不是Animal dog cat。
類重載本質——靜態(tài)分派
execute方法是一個重載方法,本質上就是虛擬機JVM如何確定調用哪個方法執(zhí)行。在java編譯后的class文件中存儲的只是方法的符號引用,而不是方法在實際運行過程中內存布局的入口地址(直接引用)。而這個方法從符號引用變成直接引用有兩種方式,解析和分派。
解析是發(fā)生在類加載的解析階段就會將一部分方法的符號引用轉換為直接引用,比如類的靜態(tài)方法、私有方法、構造方法、父類方法以及final的方法。我們這里不展開闡述,和本例無關。
而我們方法重載的情況下,java采用的是靜態(tài)分派的方式確定調用方法。
變量類型
在了解靜態(tài)分派前我們需要了解下變量的類型。
Animal a1 = new Dog();
- 靜態(tài)類型, 也叫做"外觀類型", 比如代碼中的"Animal", 它的類型是在編譯期就知道。
- 實際類型,也叫"運行時類型", 比如代碼中的"Dog", 它是在類運行時才會確定,編譯期是不知道的。
Execute exe = new Execute();
exe.execute(a);
exe.execute(a1);
exe.execute(a2);
這里多次調用了execute方法,在方法接收者已經確定是對象exe的前提下,使用哪個重載的方法,就完全取決于傳入參數(shù)的數(shù)量和數(shù)據類型。虛擬機在重載時是通過參數(shù)的靜態(tài)類型而不是實際類型作為判斷依據的。因為靜態(tài)類型是編譯期可知的,所以,在編譯階段,編譯器會根據靜態(tài)類型決定使用哪個重載版本,如下圖例子中的字節(jié)碼,技術在編譯的字節(jié)碼中確定了它調用的重載方法。
類多態(tài)本質——動態(tài)分派
既然有靜態(tài)分派,那么是不是有動態(tài)分派呢?什么又是動態(tài)派呢?
Java語言的一大特性是多態(tài)性,所謂多態(tài)就是指程序中定義的引用變量所指向的具體類型和通過該引用變量發(fā)出的方法調用在編程時并不確定,而是在程序運行期間才確定,即一個引用變量倒底會指向哪個類的實例對象,該引用變量發(fā)出的方法調用到底是哪個類中實現(xiàn)的方法,必須在由程序運行期間才能決定。
舉個簡單的例子,比如Human human = flag ? new Man() : new Woman(), human的具體類型是man還是woman在編寫代碼的時候我們是無法確定,它是由flag這個標記決定,只有在程序運行的時候才能夠確定下來,這種讓引用變量在運行時綁定到各種不同的類實現(xiàn)上,從而導致該引用調用的具體方法隨之改變,即不修改程序代碼就可以改變程序運行時所綁定的具體代碼,讓程序可以選擇多個運行狀態(tài),這就是多態(tài)性。
多態(tài)在Java中有兩種實現(xiàn)形式,分別是繼承和接口,子類重寫父類或者接口中的方法,現(xiàn)在舉個例子。
public class DynamicDispatch {
static abstract class Animal {
protected abstract void eat();
}
static class Cat extends Animal {
@Override
protected void eat() {
System.out.println("我吃魚");
}
}
static class Dog extends Animal {
@Override
protected void eat() {
System.out.println("我吃骨頭");
}
}
public static void main(String[] args) {
Animal cat = new Cat();
Animal dog = new Dog();
cat.eat();
dog.eat();
cat = new Dog();
cat.eat();
}
}
運行結果:
這個結果相信和大家想的是一致的,那大家有想過JVM是怎么找到具體的類型執(zhí)行的呢?我們定義的引用類型就是Animal,JVM是根據什么來找到對應的Cat 或者Dog這些具體的實例執(zhí)行對應的方法呢?
從字節(jié)碼角度分析
利用idea的Jclasslib插件查看字節(jié)碼:
- 0~15行主要是創(chuàng)建Cat對象和Dog對象的字節(jié)碼指令。
- 17和21行一模一樣,指令都是invokevirtual, 參數(shù)都是<com/alvin/chapter8/DynamicDispatch$Animal.eat。竟然這兩條指令一模一樣,那他是怎么確定調用哪個實際類型的方法呢?這還得要了解invokevirtual指令的運行過程:
- 找到操作數(shù)棧頂?shù)牡谝粋€元素所指向的對象作為實際類型,記作類型C,這個是在運行期確定的。
- 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,通過返回這個方法的直接引用,查過過程結束。
- 否則,按照繼承關系從下往上依次對C的各個父類進行搜索和驗證。
- 如果始終沒有找到合適的方法,拋出AbstractMethodError異常。
- 回過頭來看,我們看到字節(jié)碼中的第16行和20行的aload指令就是把剛剛創(chuàng)建的對象壓入到棧頂。
以上的過程中根據方法接收者的實際類型來確定調用那個方法,找不到往父類繼續(xù)找的過程,其實也就是重寫的本質。我們把這種在運行期根據實際類型確定方法執(zhí)行版本的分派過程叫做動態(tài)分派。
** 虛擬機動態(tài)分派的實現(xiàn) **
上面講述了虛擬即動態(tài)分派的過程,那它是怎么實現(xiàn)這一過程的呢?
因為動態(tài)分派是執(zhí)行非常頻繁的動作,而且需要在運行時搜索合適的目標方法,基于性能的考慮,java虛擬機采用了一種基礎且常見的優(yōu)化手段—為類型在方法區(qū)建立一個需方法表。使用需方法表索引來代替元數(shù)據查找以提高性能。
虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表中的地址入口和父類相同方法的地址入口時一致的,如果子類重寫了方法,子類虛方法表中的地址會被替換為指向子類實現(xiàn)版本的入口地址。
總結
總結下,所有依賴靜態(tài)類型來定位方法執(zhí)行版本的分派叫做靜態(tài)分派。靜態(tài)分派的典型應用就是方法重載,它是在編譯階段確定的,它會選擇一個最合適的版本方法進行調用。而動態(tài)分派簡單來說就是根據變量的動態(tài)類型確定執(zhí)行哪個方法,典型的應用就是方法的重寫。