Java多態性——分派


一、基本概念

Java是一門面向對象的程序設計語言,因為Java具備面向對象的三個基本特征:封裝、繼承和多態。這三個特征並不是各自獨立的,從一定角度上看,封裝和繼承幾乎都是為多態而准備的。多態性主要體現在對象的方法調用上:

1.編譯期根據對象的靜態類型進行靜態分派。

2.運行期根據對象的實際類型進行動態分派。

在進一步解釋分派的原理之前,先熟悉幾個概念:

1.靜態類型和實際類型

1 Map map = new HashMap();
2 System.out.println((Object)map);
3 map = new IdentityHashMap();

上面的第1行代碼中,定義了一個變量map,其中‘Map’稱為變量的靜態類型(Static Type)或外觀類型(Apparent Type),‘HashMap’稱為變量的實際類型(Actual Type)。

靜態類型和實際類型在程序中都可以發生變化,區別是靜態類型的變化僅僅在使用時發生變化,並且靜態類型是在編譯期可知的。而實際類型變化的結果在運行期才能確定,編譯器在編譯程序的時候並不知道一個對象的實際類型是什么。如上面的第2行代碼:變量的靜態類型改變成了Object,第三行代碼:對象的實際類型變成了IdentityHashMap。

這里可能有人會問:為什么編譯器在編譯程序的時候不知道一個對象的實際類型是什么?像上面的第3行代碼中對象的實際類型變成了IdentityHashMap不是一眼就看出來了嗎?編譯器不是應該能解析出來的嗎?如果你有這樣的疑問,那么請看下面的代碼:

1 public void type(Map map){
2     // doSomething
3 }

這個方法提供一個Map類型的入參,在方法中使用這個map實例做一些事情。那么這個時候,編譯器是無法知道map的實際類型的。這個方法在項目中可能被很多地方調用,有的調用方傳HashMap,有的調用方傳IdentityHashMap,還有的調用方傳LinkedHashMap,等等。所以編譯器在編譯期間是無法確定對象的實際類型的。不過對象的靜態類型是始終能確定的,就如上面的這個方法,不管入參傳的什么,該對象的靜態類型都是Map。

2.方法宗量

方法的接收者與方法的參數統稱為方法的宗量。

二、靜態分派

所有依賴靜態類型來定位方法執行版本的分派動作稱為靜態分派。Java里面的靜態分派的具體體現是方法的重載,這里我們不去討論重載的語法,直接討論重載的實現。

 1 public class StaticDispatch {
 2     static class Shape{
 3     }
 4 
 5     static class Circle extends Shape{
 6     }
 7 
 8     public void draw(Shape shape){
 9         System.out.println("It is shape!");
10     }
11 
12     public void draw(Circle circle){
13         System.out.println("It is circle!");
14     }
15 
16     public static void main(String[] args) {
17         Shape shape = new Shape();
18         Shape circle = new Circle();
19         StaticDispatch staticDispatch =new StaticDispatch();
20         staticDispatch.draw(shape);
21         staticDispatch.draw(circle);
22     }
23 }

上面的例子,第2-6行代碼定義了兩個類:一個形狀的抽象類Shape,一個具體的形狀-圓形類Circle。

第8-14行代碼定義了StaticDispatch的兩個重載方法draw,根據不同的形狀參數(Shape或Circle),打印出不同的形狀信息。

第16-22行代碼對重載方法進行測試。需要注意的是第18行,變量circle的實際類型是Circle,靜態類型是Shape。程序運行的結果為:

1 It is shape!
2 It is shape!

上面的結果對於Java有經驗的程序員不足為奇,但是對於初學者或多或少會感到疑惑。21行代碼的調用參數明明是Circle的實例,怎么打印的信息是“It is shape!”而不是“It is circle!”。為了解釋這個現象,我們來看一下這段代碼經過編譯之后的匯編代碼。(這里的匯編和我們一般說的匯編代碼有些區別,一般意義上的匯編代碼是機器指令的可讀形式。這里說的匯編代碼是JVM指令的可讀形式)。

1 ALOAD 3
2 ALOAD 1
3 INVOKEVIRTUAL StaticDispatch.draw (LStaticDispatch$Shape;)V
4 ALOAD 3
5 ALOAD 2
6 INVOKEVIRTUAL StaticDispatch.draw (LStaticDispatch$Shape;)V

這里我們把非關注點的代碼都省略了,只留下20和21行代碼對應的匯編代碼。第1-2行將變量槽中的引用對象推送至操作棧,兩個對象分別是staticDispatch和shape。第3行的INVOKEVIRTUAL才是方法的真正調用,調用的方法描述符是StaticDispatch.draw (LStaticDispatch$Shape;)V。對應的Java代碼方法:public void draw(Shape)。執行方法調用的時候,INVOKEVIRTUAL指令將先前入棧的操作數彈出棧作為方法的參數和接收者,這里的參數是shape,接收者是staticDispatch。這兩者的結合也就是我們上面提及的方法宗量。

第4-6行也是類似的邏輯。唯一不同的是第五行壓入操作棧參數是circle。可是調用的方法仍然是StaticDispatch.draw (LStaticDispatch$Shape;)V

由此可見,編譯器在編譯的時候是只認靜態類型,由於變量shape和circle的靜態類型都是Shape,所以最終編譯后的匯編代碼都是調用的同一個方法。這樣一來,兩次調用都打印出“It is shape!”就解釋的通了。這里需要額外注意的一個細節點是,兩次方法調用的接收者是一樣的,都是staticDispatch

編譯器雖然能確定出方法的重載版本,但是在很多情況下這個重載版本並不是“唯一的”,往往只能確定一個“更加合適的”版本。對於上面的例子,變量的靜態類型是顯式指明的,所以是能唯一確定的,不會存在二義性。但是如果變量的靜態類型沒有顯式指明,那么該怎么去確定方法的執行版本呢?下面的例子演示了編譯器如何選擇“更加合適的”版本。

 1 public class Overload {
 2     public static void sayHello(Object arg){
 3         System.out.println("Hello Object!");
 4     }
 5 
 6     public static void sayHello(int arg){
 7         System.out.println("Hello int!");
 8     }
 9 
10     public static void sayHello(long arg){
11         System.out.println("Hello long!");
12     }
13 
14     public static void sayHello(Character arg){
15         System.out.println("Hello Character!");
16     }
17 
18     public static void sayHello(char arg){
19         System.out.println("Hello char!");
20     }
21 
22     public static void sayHello(char... arg){
23         System.out.println("Hello char...!");
24     }
25 
26     public static void sayHello(Serializable arg){
27         System.out.println("Hello Serializable!");
28     }
29 
30     public static void main(String[] args) {
31         sayHello('a');
32     }
33 }

Overload類中定義了一系類的重載方法sayHello,在main函數中進行了調用,傳入的參數不是帶類型的變量,而是字符字面量'a'。這里並沒有顯式的指明變量的靜態類型,哪個重載的方法是滿足的呢?

答案是:每個方法都是滿足的。因為你可以說字面量'a'是char型,可以說它是Character型,甚至可以說它是Object型。那么編譯器該如何抉擇呢?是隨機的選擇一個方法嗎?

答案是:不是的。編譯器是根據匹配優先級確定方法的執行版本。

因為'a'最符合char型的定義,所以優先匹配sayHello(char arg)方法。上面的代碼執行后將輸出"Hello char...!"。

如果把sayHello(char arg)注釋掉,會輸出什么呢?這個時候編譯器會自動將'a'轉型為int,將會調用sayHello(int arg),輸出"Hello int!"。這是由於'a'除了可以代表一個字符,還可以代表數字97(字符'a'的Unicode值是97)。

如果再把sayHello(int arg)注釋掉呢?這里我們就不挨個的去解釋了。這些類型匹配的優先級是:char->int->long->float->double->Character->Serializable->Object->char...,讀者自行體會。

三、動態分派

了解了靜態分派,我們接下來看一下動態分派。所有依賴動態類型來定位方法執行版本的分派動作稱為動態分派,動態分派發生在運行期。Java里面的動態分派主要體現在“重寫”上。請看下面的例子:

 1 public class DynamicDispatch {
 2     static class Shape {
 3         protected void draw(){
 4             System.out.println("It is shape!");
 5         }
 6     }
 7 
 8     static class Circle extends Shape {
 9         protected void draw(){
10             System.out.println("It is circle!");
11         }
12     }
13 
14     public static void main(String[] args) {
15         Shape shape = new Shape();
16         Shape circle = new Circle();
17         shape.draw();
18         circle.draw();
19     }
20 }

上面的代碼分別創建了兩個類Shape和Circle,Circle繼承Shape並且重寫了draw方法。在main函數中,分別定義了它們的兩個實例shape和circle,把兩個實例的的靜態類型都設置為Shape。那么這次調用它們的draw方法會輸出什么呢?

1 It is shape!
2 It is circle!

根據上面的結果可以看出,雖然shape和circle的靜態類型都是Shape,但是虛擬機在執行的時候並沒有傻乎乎的都去執行Shape類中定義的draw方法。為何如此,我們還是來看一下main函數的匯編代碼:

 1 NEW DynamicDispatch$Shape
 2 DUP
 3 INVOKESPECIAL DynamicDispatch$Shape.<init> ()V
 4 ASTORE 1
 5 NEW DynamicDispatch$Circle
 6 DUP
 7 INVOKESPECIAL DynamicDispatch$Circle.<init> ()V
 8 ASTORE 2
 9 ALOAD 1
10 INVOKEVIRTUAL DynamicDispatch$Shape.draw ()V
11 ALOAD 2
12 INVOKEVIRTUAL DynamicDispatch$Shape.draw ()V

代碼1-4行是用NEW指令新建Shape實例,並調用實例的初始化方法init(這個就是我們常說的默認構造函數,雖然代碼中沒寫,但是編譯器為程序猿自動生成的),實例化完成后,把實例的引用存儲在1號變量槽中。第5-8行是類似的邏輯,只是創建的實例不同。這里需要注意的一個細節點是:存儲的引用都是實際對象的句柄,就是能通過這個引用找到堆中的實際對象。第9-12行就是兩個實例方法的調用,讀者可以看到,兩次調用的方法描述符都是一樣的DynamicDispatch$Shape.draw ()V,不同的地方在於兩次調用之前推入操作棧的變量不一樣,分別是1號槽和2號槽的變量(也就是shape和circle),這兩個變量也就是方法的接收者。問題的關鍵就在這,在JVM真正去執行方法調用的時候,會去方法的接收者那去尋找方法。所以執行draw方法的時候,shape和circle會去各自的draw方法。

到這里讀者應該明白了,雖然編譯器按照靜態類型生成了方法執行的版本。但是在JVM運行的時候是不看靜態類型的,JVM只看方法簽名(如上面的draw ()V)和方法的接收者。也就是說:對於一個要調用的方法(比如例子中的draw),最終決定方法執行版本的因素就是方法的宗量。

四、單分派和多分派

前面我們介紹過宗量的概念。根據分派基於多少種宗量,可以將分派划分為單分派和多分派。根據上面靜態分派和動態分派的闡述,我們可以知道:

在編譯期間,編譯器需要根據方法的變量的靜態類型和參數才能確定方法的描述符。所以Java的靜態分派屬於多分派。

在運行期間,方法的名稱和描述符已經是確定了的,但是在執行真正的方法調用時,JVM需要根據方法的接收者的實際類型去決定執行的方法版本。所以Java的動態分派屬於單分派。

五、虛擬機動態分派的實現

上面介紹動態分派的時候,我們了解到虛擬機是根據實際的方法接收者決定執行方法的版本。那假如方法的接收者對應的類里面沒有該方法的定義呢?請看下面的例子。

 1 public class DynamicDispatch {
 2     static class Shape {
 3         protected void draw(){
 4             System.out.println("It is shape!");
 5         }
 6     }
 7 
 8     static class Circle extends Shape {
 9     }
10 
11     public static void main(String[] args) {
12         Shape circle = new Circle();
13         circle.draw();
14     }
15 }

代碼很簡單,類Circle繼承Shape,但是沒有重寫父類的draw方法。main函數中實例化Circle,賦靜態類型Shape並調用實例的draw方法。這里的輸出相信各位讀者都知道:"It is shape!"。這里方法的接收者是circle,虛擬機會先去Circle類里面尋找draw方法,但是Circle類中並沒有這個方法,所以虛擬機會向上查找Circle的父類Shape,調用Shape的draw方法。

道理很簡單,但是動態分派是個非常頻繁的動作,如果每次都這么向上查找的話,會嚴重影響虛擬機的執行性能。所以虛擬機針對這種情況會做出一些優化手段,最常用的”穩定優化“手段就是為類在方法區中建立一個虛方法表(Virtual Method Table,也稱為vtable),使用虛方法表的索引來代替元數據查找以提高性能。

虛擬機會為每個類建立一個虛方法表,如上圖左右兩個表格分別為Shape和Circle的虛方法表。虛方法表中存放各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表里面的地址入口和父類相同方法的地址入口是一致的。對於上面的例子,Shape和Circle都默認繼承Object,所以它們的vtable里面繼承的方法clone、hashCode、equals等都指向Object中對應的方法。這里Circle沒有重寫父類Shape的draw方法,所以它的vtable中draw方法地址入口指向Shape中的draw方法。如果這里Circle重寫了draw方法,它的vtable里面這一項就會指向Circle類的draw方法地址入口。

由於使用了vtable技術,虛擬機在執行動態分派的時候,只需要找到方法接收者所對應的類的虛方法表,就能立即找到實際的方法,不用再向上查找。我們的例子比較簡單,只有一個繼承層級,真實應用中很可能類存在多個層級,使用vtable技術可以很大程度上提高虛擬機的執行性能。與此對應的,對於接口方法的查找也會用到方法表,只是換了個名字”接口方法表“--Interface Method Table,簡稱itable。

 

作者:南唐三少
出處:http://www.cnblogs.com/nantang
如果您覺得閱讀本文對您有幫助,請點一下“推薦”按鈕,您的“推薦”將是我們最大的寫作動力!歡迎各位轉載,但是未經作者本人同意,轉載文章之后必須在文章頁面明顯位置給出作者和原文鏈接,否則保留追究法律責任的權利。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM