深入理解“重載”與“重寫”——分派

java語言雖不是動態類型語言,但它具有動態特性,方法重寫是java語言動態特性的一個重要因素。本文將從虛擬機層次去理解方法重載和方法重寫的實現原理。

一、方法調用

在java語言中,方法調用是程序運行時最普遍、最頻繁的操作。方法調用不等同於方法執行。方法調用階段的唯一任務是確認本調用方法的版本,也就是確定被調用方法的直接引用。
方法調用的過程以編譯的Class文件爲起點,發生在類加載過程的解析階段,部分方法調用在程序運行階段才能完成。
根據方法調用過程的時間不同,可分爲解析分派兩類。
關於方法調用的字節碼指令有5個:

  • invokestatic:調用靜態方法
  • invokespecial:調用實例構造器<init>方法、私有方法和父類方法。
  • invokevirtual:調用所有虛方法
  • invokeinterface:調用接口方法,會在運行時再確定一個實現此接口的對象。
  • invokedynamic:先在運行時動態解析出調用點限定符所引用的方法,再執行該方法。

1、解析

所有方法調用中的目標方法在Class文件中都是一個常量池中的符號引用,在類加載的解析階段,會將一部分符號引用轉化爲直接引用。這類方法在程序運行前就已確定了一個調用版本,並且這個版本在程序運行期間不可變,這類方法調用過程成爲“解析”。
只能被invokestatic和invokespecial指令調用的方法,都可以在解析階段確定唯一的調用版本,符合這一條件的有靜態方法、私有方法、實例構造器、父類方法四種。這些方法成爲“非虛方法”,其他方法都成爲“虛方法”(final方法除外)。
解析調用是一個完全靜態的過程,在編譯期間就完全確定,在類加載階段會把所涉及的符號引用全部轉化爲可確定的直接引用,不會延遲到運行期間去完成。而分派則可能是靜態的也可能是動態的。

2、分派

分派也是方法調用的一種重要方法,它分爲靜態分派和動態分派,分別對應多態性的兩種基本體現:重載和重寫。

1)靜態分派

要理解靜態分派,先來看一道關於重載的題目:

public class TestStaticDispatch {
    static abstract class Pet{}
    static class Dog extends Pet{}
    static class Cat extends Pet{}
    
    public void feed(Pet pet){
        System.out.println(" feed pet");
    }
    public void feed(Dog dog){
        System.out.println(" feed dog");
    }
    public void feed(Cat cat){
        System.out.println(" feed cat");
    }

    public static void main(String[] args) {
        TestStaticDispatch tsd = new TestStaticDispatch();
        Pet dog = new Dog();
        Pet cat = new Cat();
        tsd.feed(dog);
        tsd.feed(cat);
    }
}

運行結果:
feed pet
feed pet
在以下這行代碼中:Pet dog = new Dog(); “Pet”稱爲變量的“靜態類型”,或者叫“外觀類型”,“Dog”稱爲變量的實際類型,當發生類型變化時,靜態類型的變化在編譯期可知,而實際類型需要到運行期間纔可確定。編譯期在編譯期間並不知道變量的實際類型是什麼,所以java提供強制類型轉化的語法。
在代碼中,main()方法兩次調用feed()方法,方法接受者已確定爲“tsd”,在編譯階段,編譯期選擇參數的靜態類型而不是實際類型作爲判斷依據,來決定使用方法的哪個重載版本。這種依賴靜態類型定位方法執行版本的分派動作稱爲“靜態分派”。靜態分派的典型應用是方法重載。
重載方法優先級
在java中,有些字面量不需要進行顯示的類型定義。如:

import java.io.Serializable;

public class TestOverloadPriority {
    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(char arg){
        System.out.println("hello char");
    }
    public static void sayHello(Character arg){
        System.out.println("hello Character");
    }
    public static void sayHello(Serializable arg){
        System.out.println("hello Serializable");
    }
    public static void sayHello(char ... arg){
        System.out.println("hello char ...");
    }

    public static void main(String[] args) {
        TestOverloadPriority.sayHello('a');
    }
}

運行結果:
hello char
參數’a’默認爲char類型,所以調用重載方法的sayHello(char arg)方法。如果去掉這個重載方法,
運行結果:
hello int
這時發生了一次自動類型轉換,'a’轉爲十進制數值97,參數類型爲int,再去掉這個重載方法,
運行結果:
hello long
int類型可自動類型轉換爲long類型,再去掉這個重載方法,
運行結果:
hello Character
自動裝箱,char類型轉化爲char的包裝類型Character,再去掉這個重載方法,
運行結果:
hello Serializable
java.lang.Character實現了java.lang.Serializable接口,自動轉化爲Serializable接口類型,再去掉這個重載方法,
運行結果:
hello Object
子類轉型爲父類,再去掉這個重載方法,
運行結果:
hello char …
可變長參數,優先級最低。
由上面的測試,重載的優先級不言而喻。
注意:靜態分派和解析並不是二選一的排他關係。

2、動態分派

動態分派和多態的另一個重要體現:重寫,有密切的關聯。先看題目:

public class TestDynamicDispatch {
    static abstract class Pet{
        protected abstract void feed();
    }
    static class Dog extends Pet{
        @Override
        protected void feed() {
            System.out.println("feed dog");
        }
    }
    static class Cat extends Pet{
        @Override
        protected void feed() {
            System.out.println("feed cat");
        }
    }
    
    public static void main(String[] args) {
        Pet dog = new Dog();
        Pet cat = new Cat();
        dog.feed();
        cat.feed();
        dog = new Cat();
        dog.feed();
    }
}

運行結果:
feed dog
feed cat
feed cat
從結果可以看出,方法重寫已經不會再根據變量的靜態類型來決定調用方法的版本了,而應該是根據變量的實際類型。這種情況下會用到字節碼指令invokevirtual,invokevirtual指令的運行解析過程大致分爲以下幾個步驟:

  1. 找到操作數棧頂的第一個元素所指向的對象的實際類型,記做C
  2. 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找結束;如果不通過,則返回java.lang.IllegalAccessError異常。
  3. 否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜索及驗證。
  4. 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。

invokevirtual指令執行的第一步就是在運行期間確定方法接收者的實際類型,也就是把常量池中的符號引用解析道不同的直接引用上,這個過程也就是方法中邪的本質。也就是動態分派的過程。
動態分派的實現與優化
由於動態分派是非常頻繁的動作,虛擬機的實際實現會基於性能的考慮,做一些優化手段。最常用的優化手段就是在類的方法區簡歷一個“虛方法表”,使用虛方法表索引來替代元數據查找以提高性能。
虛方法表中存放着各個方法的實際入口地址。如果子類沒有重寫父類方法,那麼虛方法表裏的方法入口都指向父類的方法入口地址。
方法表一般在類加載的連接階段進行初始化,準備了類的變量初始值後,虛擬機會把該類的方法表也初始化。
不同虛擬機的優化手段可能不同,還有兩種比較激進的優化手段:"內聯緩存"和“守護內聯”。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章