虛擬機字節碼執行引擎 —— 方法調用


本文部分摘自《深入理解 Java 虛擬機第三版》


概述

方法調用並不等同於方法中的代碼被執行,方法調用階段唯一的任務就是確定被調用方法的版本(即調用哪一個方法)。之前講過,一切方法調用在 Class 文件裏面都是以符號引用的形式存儲,而非方法在實際運行時內存佈局中的入口地址(直接引用)。這個特性給 Java 帶來強大的動態擴展能力,但也使得 Java 方法調用過程變得相對複雜,某些調用需要在類加載期間,甚至到運行期間才能確定目標方法的直接引用


解析

所有方法調用的目標方法在 Class 文件裏面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分引用轉化爲直接引用,這種解析能成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可變的。換句話說,調用目標在程序代碼寫好、編譯器進行編譯那一刻就已經確定下來了。這類方法的調用被稱爲解析(Resolution)

在 Java 中符合“編譯期可知,運行期不可變”要求的方法,主要有靜態方法和私有方法兩大類,前者和類型直接關聯,後者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫出其他版本,因此更適合在類加載階段進行解析

調用不同類型的方法,字節碼指令集裏設計了不同的指令。Java 虛擬機支持以下 5 條方法調用字節碼指令:

  • invokestatic

    用於調用靜態方法

  • invokespecial

    用於調用實例構造器方法、私有方法和父類中的方法

  • invokevirtual

    用於調用所有虛方法

  • invokeinterface

    用於調用接口方法,會在運行時再確定一個實現該接口的對象

  • invokedynamic

    先在運行時動態解析出調用點限定符所引用的方法,然後再執行該方法

只要能被 invokestatic 和 invokespecial 指令調用的方法,都可以在解析階段確定唯一的調用版本,被 final 修飾的方法也是如此(使用 invokevirtual 指令調用)。能在類加載時就把符號引用解析爲直接引用的方法統稱爲非虛方法(Non-Virtual Method),其他方法則稱爲虛方法(Virtual Method)


分派

解析調用是一個靜態的過程,在編譯期就完全確定,在類加載的解析階段就會把涉及的符號引用全部轉變爲明確的直接引用,不必延遲到運行期再去完成。而另一種方法調用形式:分派(Dispatch)調用則要複雜許多。分派調用也是多態實現的基礎,比如重載和重寫,就是依靠分派調用機制來確定正確的目標方法

分派調用可能是靜態的也可能是動態的,按照分派依據的宗量數又可分爲單分派和多分派。這兩類分派方式兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派四種分派組合情況:

1. 靜態類型與實際類型

爲了瞭解分派,首先要清楚靜態類型和動態類型這兩個概念,代碼如下:

// Human 是 Man 的父類
Human man = new Man();

我們把 Human 稱爲變量的靜態類型(Static Type),後面的 Man 則被稱爲變量的實際類型(Actual Type)或者運行時類型(Runtime Type)。靜態類型和實際類型在程序中都可能發生變化,區別在於靜態類型的變化僅僅在編譯期可知;而實際類型的變化的結果在運行期纔可確定

// 實際類型變化,必須等到程序運行到這行才能確定
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 靜態類型變化,編譯期即可知
Man man = (Man)human;
Woman woman = (Woman)woman;

2. 靜態分派

所有依賴靜態類型來決定方法執行版本的分派動作,都稱爲靜態分派。靜態分派最典型的應用表現就是方法重載,代碼如下:

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 sd = new StaticDispatch();
        sd.sayHello(man);
        sd.sayHello(woman);
    }
}

程序的運行結果是兩次打印內容都是“hello guy”,因爲使用哪個重載版本,完全取決於傳入參數的數量和類型。代碼中故意定義了兩個靜態類型相同,但實際類型不同的變量,但虛擬機在重載時只通過參數的靜態類型而不是實際類型作爲判定依據。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行,而由編譯器來確定方法的重載版本。

3. 動態分派

動態分派與 Java 多態性的另外一個重要體現 —— 重寫(Override)有着很密切的關係,我們還是用前面的代碼爲例:

public class StaticDispatch {
    
    static abstract class Human {
        protected abstract void sayHello();
    }
    
    static class Man extends Human {
        @Override
        protected abstract void sayHello() {
            System.out.println("hello gentleman");
        }
    }
    
    static class Woman extends Human {
        @Override
        protected abstract void sayHello() {
            System.out.println("hello lady");
        }
    }
    
    public static void main(String args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
    }
}

運行結果分別是“hello gentleman”和“hello lady”,對於習慣了 Java 思想的我們來說是很正常的事,但虛擬機是符合判斷應該調用哪個方法的呢?顯然這裏不可能再根據靜態類型來決定了,而是兩個變量的實際類型。動態分派是由虛擬機執行的,上述 Java 代碼被編譯成 class 字節碼後,對應的 man.sayHello()woman.sayHello() 會被編譯成 invokevirtual 方法調用指令,並且 man 和 woman 兩個方法的所有者(接收者)的引用會被壓到棧頂。invokevirtual 指令的運行時解析過程大致可分爲以下幾步:

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

invokevirtual 指令執行的第一步就是在運行期確定方法所有者的實際類型,這也是 Java 中方法重寫的本質。我們把這種在運行期根據實際類型確定方法執行版本的分派過程稱爲動態分派

4. 單分派與多分派

方法的接收者與方法的參數統稱爲方法的宗量,根據分派基於多少種宗量,可以將分派劃分爲單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇

public class Dispatch {
    
    static class Rice {}
    static class Chocolate {}
    
    public static class Father {
        public void eat(Rice rice) {
            System.out.println("father eat rice");
        }
        
        public void eat(Chocolate chocolate) {
            System.out.println("father eat chocolate");
        }
    }
    
    public static class Son extends Father {
        public void eat(Rice rice) {
            System.out.println("son eat rice");
        }
        
        public void eat(Chocolate chocolate) {
            System.out.println("son eat chocolate");
        }
    }
    
    public static void main(String[] args) {
		Father father = new Father();
        Father son = new Son();
        father.eat(new Rice());
        son.eat(new Chocolate());
    }
}

打印結果分別是“father eat rice”和“son eat chocolate”,我們可以發現,這裏的方法選擇是基於方法接收者的不同和參數不同兩個因素而造成的結果,也就是我們說的宗量。這裏實際上涉及兩個階段,第一個階段是靜態分派的過程,方法接收者類型是 Father 還是 Son,方法參數是 Rice 還是 Chocolate,產生的兩條 invokevirtual 指令的參數分別指向常量池中 Father::eat(Rice) 和 Father::eat(Chocolate) 方法的符號引用。因爲是根據兩個宗量進行選擇,所以 Java 中的靜態分派屬於靜態多分派。再看動態分派階段,此時唯一可以影響虛擬機選擇的因素只有方法接收者的實際類型了,即實際類型是 Father 還是 Son,因爲只有一個宗量作爲選擇依據,所以 Java 的動態分派屬於單分派類型

4. 虛擬機動態分派的實現

動態分派是執行非常頻繁的動作,動態分派的方法版本選擇過程需要運行時在接收者類型的方法元數據中搜索合適的目標方法,比如實際類型是 Father,那麼就要在 Father 類型的方法元數據中尋找 eat 方法。爲了提高運行效率,Java 虛擬機爲類型在方法區中建立了一個虛方法表,虛方法表存放着各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那麼子類的虛方法表和父類的虛方法表中相同方法的地址入口是一致的,都指向父類的實現。如果子類重寫了這個方法,子類虛方法表中的地址就會被替換爲指向子類實現版本的方法入口地址

爲了程序實現方便,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一致的索引序號,這樣當類型轉換時,只需要變更要查找的虛方法表即可。虛方法表一般在類加載的連接階段初始化完成。


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