1. 程式人生 > >JVM----過載 與 重寫的本質 : 分派

JVM----過載 與 重寫的本質 : 分派

本篇來自周志明的<<深入理解java虛擬機器>>

眾所周知,Java是一門面向物件的程式語言,因為Java具備面向物件的3個基本特徵:繼承、封裝和多型。本節講解的分派呼叫過程將會揭示多型性特徵的一些最基本的體現, 如過載”和“重寫”在Java虛擬機器之中是如何實現的,這裡的實現當然不是語法上該如何寫, 我們關心的依然是虛擬機器如何確定正確的目標方法

1.靜態分派

在開始講解靜態分派前 ,筆者準備了一段經常出現在面試題中的程式程式碼,讀者不妨先看一遍,想一下程式的輸出結果是什麼。後面我們的話題將圍繞這個類的方法來過載(Overload)程式碼,以分析虛擬機器和編譯器確定方法版本的過程。方法靜態分派如程式碼清單8-6所示。

程式碼清單8 - 6 方法靜態分派演示

package org.fenixsoft.polymorphic;

/**
 * 方法靜態分派演示
 * @author zzm
 */
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!

程式碼清單8-6中的程式碼實際上是在考驗閱讀者對過載的理解程度,相信對Java程式設計稍有經驗的程式設計師看完程式後都能得出正確的執行結果,但為什麼會選擇執行引數型別為Human的過載呢?在解決這個問題之前,我們先按如下程式碼定義兩個重要的概念。

Human man=new Man();

我們把上面程式碼中的“Human”稱為變數的靜態型別( Static Type ) , 或者叫做的外觀型別 ( Apparent Type ) , 後面的“Man”則稱為變數的實際型別( Actual Type ), 靜態型別和實際型別在程式中都可以發生一些變化,區別是靜態型別的變化僅僅在使用時發生

,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的;而實際型別變化的結果在執行期才可確定,編譯器在編譯程式的時候並不知道一個物件的實際型別是什麼。例如下面的程式碼:

// 實際型別變化
Human man=new Man(); 
man=new Woman();
// 靜態型別變化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);

解釋了這兩個概念,再回到程式碼清單8-6的樣例程式碼中。main()裡面的兩次sayHello() 方法呼叫,在方法接收者已經確定是物件“sr”的前提下,使用哪個過載版本,就完全取決於傳入引數的數量和資料型別。程式碼中刻意地定義了兩個靜態型別相同實際型別不同的變數,但虛擬機器(準確地說是編譯器)在過載時是通過引數的靜態型別而不是實際型別作為判定依據的。並且靜態型別是編譯期可知的,因此 ,在編譯階段,Javac編譯器會根據引數的靜態型別決定使用哪個過載版本,所以選擇了sayHello(Human) 作為呼叫目標, 並把這個方法的符號引用寫到main() 方法裡的兩條invokevirtual指令的中 。

所有依賴靜態型別來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用是方法過載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機器來執行的。另外 ,編譯器雖然能確定出方法的過載版本,但在很多情況下這個過載版本並不 是“唯一的” ,往往只能確定一個“更加合適的”版本。這種模糊的結論在由0和1構成的計算機世界中算是比較“稀罕” 的事情 ,產生這種模糊結論的主要原因是字面量不需要定義,所以字面量沒有顯式的靜態型別,它的靜態型別只能通過語言上的規則去理解和推斷。程式碼清單8- 7演示了何為“更加合適的”版本。

程式碼清單8 - 7 過載方法匹配優先順序

package org.fenixsoft.polymorphic;

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 ,在變長引數中是不成立的

程式碼清單8-7演示了編譯期間選擇靜態分派目標的過程,這個過程也是Java語言實現方法過載的本質。演示所用的這段程式屬於很極端的例子,除了用做面試題為難求職者以外,在 實際工作中幾乎不可能有實際用途。筆者拿來做演示僅僅是用於講解過載時目標方法選擇的過程 ,大部分情況下進行這樣極端的過載都可算是真正的“關於茴香豆的茴有幾種寫法的研究”。無論對過載的認識有多麼深刻,一個合格的程式設計師都不應該在實際應用中寫出如此極端的過載程式碼。

另外還有一點讀者可能比較容易混淆:筆者講述的解析與分派這兩者之間的關係並不是二選一的排他關係,它們是在不同層次上去篩選、確定目標方法的過程。例如,前面說過, 靜態方法會在類載入期就進行解析,而靜態方法顯然也是可以擁有過載版本的,選擇過載版本的過程也是通過靜態分派完成的。

動態分派

瞭解了靜態分派,我們接下來看一下動態分派的過程,它和多型性的另外一個重要體現——-重寫(Override)有著很密切的關聯。我們還是用前面的Man和Woman一起sayHello的例子來講解動態分派,請看程式碼清單8-8中所示的程式碼。

程式碼清單8 - 8 方法動態分派演示

package org.fenixsoft.polymorphic;

/**
 * 方法動態分派演示
 * @author zzm
 */
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

這個執行結果相信不會出乎任何人的意料,對於習慣了面向物件思維的Java程式設計師會覺得這是完全理所當然的。現在的問題還是和前面的一樣,虛擬機器是如何知道要呼叫哪個方法的?

顯然這裡不可能再根據靜態型別來決定,因為靜態型別同樣都是Human的兩個變數man和woman在呼叫sayHello()方法時執行了不同的行為,並且變數man在兩次呼叫中執行了不同的方法。導致這個現象的原因很明顯,是這兩個變數的實際型別不同,Java虛擬機器是如何根據實際型別來分派方法執行版本的呢?我們使用javap命令輸出這段程式碼的位元組碼,嘗試從中尋找答案,輸出結果如程式碼清單8-9所示。

程式碼清單8-9 main() 方法的位元組碼

 0 〜15行的位元組碼是準備動作,作用是建立man和woman的記憶體空間、呼叫Man和Woman 型別的例項構造器,將這兩個例項的引用存放在第1、2個區域性變量表Slot之中 ,這個動作也就對應了程式碼中的這兩句:

Human man=new Man(); 
Human woman=new Woman();

接下來的16〜21句是關鍵部分,16、20兩句分別把剛剛建立的兩個物件的引用壓到棧頂 ,這兩個物件是將要執行的sayHello()方法的所有者,稱為接收者( Receiver ) ; 17和21句是方法呼叫指令,這兩條呼叫指令單從位元組碼角度來看,無論是指令(都是invokevirtual) 還是引數(都是常量池中第22項的常量,註釋顯示了這個常量是Human.sayHello()的符號引用)完全一樣的,但是這兩句指令最終執行的目標方法並不相同。原因就需要從invokevirtual指令的多型查詢過程開始說起,invokevirtual指令的執行時解析過程大致分為以下幾個步驟:

  • 找到運算元棧頂的第一個元素所指向的物件的實際型別,記作C。
  • 如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗 ,如果通過則返回這個方法的直接引用,查詢過程結束;如果不通過,則返回 java.lang.IllegalAccessError異常。
  • 否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜尋和驗證過程。
  • 如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。

由於invokevirtual指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。我們把這種在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。