過載和重寫的實現原理
Java具有面向物件的三大特徵:繼承、封裝、和多型。多型性特徵的最基本體現有“過載”和“重寫”,其實這兩個體現在Java虛擬機器中時分派的作用。
分派又分為靜態分派和動態分派,靜態分派是指所有依賴靜態型別來定位方法執行版本的分派動作,動態分派是指在執行期根據實際型別確定方法執行版本的分派過程。
Animal animal = new Bird();
在上面程式碼中Animal是父類,Bird是繼承Animal的子類;那麼在定義animal物件時前面的“Animal”稱為變數的靜態型別(Static Type),或者叫外觀型別(Apparent Type),後面的“Bird”則稱為變數的實際型別(Actual Type),靜態型別和實際型別在程式中都可以發生一些變化,區別是靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯器可知的;而實際型別變化的結果在執行期才可以確定,編譯器在編譯程式的時候並不知道一個物件的實際物件是什麼
//實際型別變化
Animal bird = new Bird();
Animal eagle = new Eagle();
//靜態型別變化
sd.sayHello((Bird)bird);
sd.sayHello((Eagle)eagle);
1.過載
我們先看一段程式碼,想想會輸出什麼,然後圍繞該類的過載方法來分析。
package test; /** * @Description: 方法靜態分派演示 * @version: v1.0.0 */ public class StaticDispatch { static abstract class Animal{ } static class Bird extends Animal{ } static class Eagle extends Animal{ } public void sayHello(Animal animal) { System.out.println("hello,animal"); } public void sayHello(Bird bird) { System.out.println("hello,I'm bird"); } public void sayHello(Eagle eagle) { System.out.println("hello,I'm eagle"); } public static void main(String[] args){ Animal bird = new Bird(); Animal eagle = new Eagle(); StaticDispatch sd = new StaticDispatch(); sd.sayHello(bird); sd.sayHello(eagle); } }
執行結果:
hello,animal
hello,animal
看到輸出內容之後我們知道,程式選擇了引數型別為Animal的過載;在main()裡面的兩次sayHello()方法的呼叫,在方法接收者已經確定是物件“sd”的前提下,使用哪個過載版本,就完全取決於傳入引數的數量和資料型別。程式碼中刻意地定義了兩個靜態型別相同但實際型別不同的變數,但虛擬機器(準確的是編譯器)在過載時是通過引數的靜態型別而不是實際型別來作為判斷依據的。並且靜態型別是編譯期可知的,因此,在編譯階段,Javac編譯器會根據引數的靜態型別決定使用哪個過載版本,因此選擇了sayHello(Animal)作為呼叫目標。
方法過載是通過靜態分派實現的,並且靜態分派是發生在編譯階段,所以確定靜態分派的動作實際上不是由虛擬機器來執行的;另外,編譯器雖然能確定出方法過載的版本,但在很多情況下這個版本並不是“唯一的”,往往只能確定一個“更加適合”的版本。
2.重寫
方法的重寫與虛擬機器中動態分派的過程有著密切聯絡。再看一段程式碼例子
package test;
/**
* @Description: 方法動態分派演示
* @version: v1.0.0
*/
public class DynamicDispatch {
static abstract class Animal{
protected abstract void sayHello();
}
static class Bird extends Animal{
@Override
protected void sayHello() {
System.out.println("Bird say hello");
}
}
static class Eagle extends Animal{
@Override
protected void sayHello() {
System.out.println("Eagle say hello");
}
}
public static void main(String[] args){
Animal bird = new Bird();
Animal eagle = new Eagle();
bird.sayHello();
eagle.sayHello();
bird = new Eagle();
bird.sayHello();
}
}
執行結果:
Bird say hello
Eagle say hello
Eagle say hello
這是一個經典的學習多型的例子,相信很多人都知道結果,但是我們我們現在要知道虛擬機器是如何知道要呼叫哪個方法的。顯然這裡不是通過靜態型別來確定的,因為靜態型別都相同的兩個物件呼叫同一個方法時執行了不同的行為。看了型別的講解我們知道是因為這兩個變數實際型別不同。我們通過javap反編譯命令來看一段該程式碼的位元組碼:
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 #16 // class test/DynamicDispatch$Bird
3: dup
4: invokespecial #18 // Method test/DynamicDispatch$Bird."<init>":()V
7: astore_1
8: new #19 // class test/DynamicDispatch$Eagle
11: dup
12: invokespecial #21 // Method test/DynamicDispatch$Eagle."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #22 // Method test/DynamicDispatch$Animal.sayHello:()V
20: aload_2
21: invokevirtual #22 // Method test/DynamicDispatch$Animal.sayHello:()V
24: new #19 // class test/DynamicDispatch$Eagle
27: dup
28: invokespecial #21 // Method test/DynamicDispatch$Eagle."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #22 // Method test/DynamicDispatch$Animal.sayHello:()V
36: return
0~15行的位元組碼是準備動作,作用時建立bird和eagle的記憶體空間、呼叫Bird和Eagle型別的例項構造器,將這兩個例項的引用存放在第1、2個區域性變量表Slot之中,這個動作也就對應了程式碼中的這兩句:
Animal bird = new Bird();
Animal eagle = new Eagle();
接下來的16~21行時關鍵部分;這部分把剛剛建立的兩個物件的引用壓到棧頂,這兩個物件是將要執行的sayHello()方法的所有者,稱為接收者(Receiver);17和21句是方法呼叫命令,這兩條呼叫命令單從位元組碼角度來看,無論是指令(invokevirtual)還是引數(都是常量池中第22項的常量,註釋顯示了這個常量是sayHello方法的符號引用)完全一樣的,但是這兩句執行的目標方法並不同,這是因為invokevirtual指令的多型查詢過程引起的,該指令執行時的解析過程可分為以下幾個步驟:
- 找到運算元棧第一個元素所指向的物件的實際型別,記為C。
- 如果在型別C中找到了與常量中描述符和簡單名稱都一樣的方法,則進行訪問許可權校驗,如果通過則返回該方法的的直接引用,查詢過程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
- 否則,按照繼承關係從下往上一次對C的各個父類進行第二步的搜尋和驗證過程。
- 如果始終都沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。
由於invokevirtual指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中重寫的本質。