深入解析多型和方法呼叫在JVM中的實現

pedro7 發表於 2021-08-24
JVM

深入解析多型和方法呼叫在JVM中的實現

1. 什麼是多型

多型(polymorphism)是物件導向程式設計的三大特性之一,它建立在繼承的基礎之上。在《Java核心技術卷》中這樣定義:

一個物件變數可以指示多種實際型別的現象稱為多型。

在面嚮物件語言中,多型性允許你將一個子型別的實際物件賦予給一個父型別的變數。在這樣的賦值完成之後,父類變數就可以根據實際賦予它的子類物件的不同,而以不同的方式工作。

在下面的示例中,Son類繼承了Father類並重寫了f()方法,又將Son型別的物件賦值給Father型別的變數,再用它呼叫f()方法,稍微有點Java基礎的程式設計師都知道,此時會使用的是Son類中的f(),這種重寫就是一種典型的多型的體現。

class Father{
    f(){ ... }
}

class Son extends Father{
    f(){ ... }
}

// 呼叫程式碼
Father object = new Son();
object.f();

在一些資料中,也把過載稱為一種多型的表現形式,本文也將過載視為多型的一種進行講解,但這種說法確實尚存爭議。

2. 一些知識準備

2.1 執行時棧幀結構

Java虛擬機器規範中,為所有的Java虛擬機器位元組碼執行引擎規定了統一的輸入輸出:

  • 輸入為位元組碼形式的二進位制流。
  • 輸出為執行結果。

在解釋執行階段,JVM以方法作為最基本的執行單元棧幀是用於支援虛擬機器進行方法呼叫和執行的資料結構,每一個方法從呼叫開始至執行結束的過程,都對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。處於棧頂的棧幀就是當前棧幀,對應的方法就是正在執行的當前方法

在這裡我們以服務解釋方法呼叫為前提,簡單說明JVM的執行時棧幀結構

image-20210824125525386
  • 區域性變數表。用於存放方法引數和方法內部定義的區域性變數。
  • 運算元棧。一個後入先出的LIFO棧,輔助方法執行中的運算操作。
  • 動態連線。動態連線是一個指向執行時常量池中該棧幀所屬方法的引用,指向的顯然是一個符號引用。它的存在主要是支援方法呼叫過程中的動態連線。
    • 方法呼叫中,符號引用一部分在類載入或者第一次使用時被轉化成直接引用,這種轉化稱為靜態解析
    • 另外一部分符號引用在每一次執行期間都轉化為直接引用,這種轉化稱為動態連線
  • 方法返回地址。
    • 正常退出方法時,方法返回地址指向主調方法的PC計數器。
    • 異常退出方法時,方法返回地址指向異常處理表。
  • 附加資訊。服務於除錯、效能收集等等。

2.2 方法呼叫位元組碼指令

針對不同型別的方法,Java虛擬機器支援以下五種方法呼叫位元組碼指令

  • invokestatic。用於呼叫靜態方法。
  • invokespecial。用於呼叫例項構造器<init>()方法、私有方法和父類中的方法。
    • 在Java11以後,invokespecial已經常常不被用來呼叫私有方法,詳見下文的實驗和說明。
  • invokevirtual。用於呼叫所有的虛方法。
  • invokeinterface。用於呼叫介面方法。在執行時確定實現該介面的物件。
  • invokedynamic。先在執行時動態解析出呼叫點限定符所引用的方法,然後執行該方法。
    • 詳見《深入理解Java虛擬機器》p321

非虛方法指那些能夠在解析階段確定唯一的呼叫版本的方法,即上面由invokestaticinvokespecial呼叫的那些方法。而其他那些屬於類的,需要在執行時動態確定呼叫版本的方法,我們稱之為虛方法,最常見的虛方法就是普通的例項方法。

下面我們用位元組碼的形式看看這些方法呼叫指令。

// Java程式碼
public class Test {
    public static void staticMethod() {
        System.out.println("static method");
    }

    private void privateMethod() {
        System.out.println("private method");
    }


    public static void main(String[] args) {
        Test.staticMethod();

        new Test().privateMethod();
    }
}

javac Test.java
javap -verbose Test
    
// javap工具得到的main部分的位元組碼檔案
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: invokestatic  #23                 // Method staticMethod:()V
         3: new           #24                 // class Test
         6: dup
         7: invokespecial #28                 // Method "<init>":()V
        10: invokevirtual #29                 // Method privateMethod:()V
        13: return
      LineNumberTable:
        line 12: 0
        line 14: 3
        line 15: 13

在上面的程式碼中,我們顯然可以看到,staticMethod使用invokestatic來進行呼叫,"<init>"構造方法使用了invokespecial來呼叫,這些都符合上面的約定。

但是!作為私有方法的privateMethod方法,卻在位元組碼中被編譯為使用invokevirtrual指令來呼叫。這是為什麼呢?

筆者查閱資料後,發現在JEP181中,對方法呼叫位元組碼指令進行了一定程度上的修改。在Java11版本及以後,巢狀類之間的私有方法的訪問許可權控制,就從編譯期轉移到了執行時,從而這樣的私有方法也被使用invokevirtual指令來呼叫,

總而言之,在Java11及以後,類中的私有方法往往用invokevirtual來呼叫,介面中的私有方法往往用invokeinterface呼叫,invokespecial往往僅用於例項構造器方法和父類中的方法。

2.3 位元組碼方法解析過程

解析過程是JVM將常量池內的符號引用替換為直接引用的過程。

  • 符號引用以一組符號來描述所引用的目標,符號可以是任意形式的字面量,只要使用時能無歧義地定位到目標即可。
  • 直接引用是可以直接指向目標的指標、相對偏移量或一個能間接定位到目標的控制程式碼。

《Java虛擬機器規範》中明確要求在執行方法呼叫位元組碼指令之前,必須先對它們使用的符號引用進行解析。即所有invoke...指令之前。由於對同一個符號引用收到多次解析請求是很常見的事,虛擬機器實現可以對第一次解析的結果進行快取,譬如在執行時直接引用常量池中的記錄,並把常量標識為已解析狀態,從而避免解析動作重複進行。(invokedynamic有一些特殊性質,這裡不做解釋)。

方法解析第一步需要解析出方法表的class_index項中索引的方法所屬的類或介面的符號引用,如果解析成功,那麼用C表示這個類,接下來虛擬機器將按照以下步驟進行後續的方法搜尋。

  • 如果我們在解析一個類方法,但C是一個介面,直接丟擲java.lang.IncompatibleClassChangeError異常。

    • 如果我們在解析的是介面方法,但C是一個類,也丟擲java.lang.IncompatibleClassChangeError異常。
  • 如果通過了第一步,在C中查詢是否有簡單名稱和描述符都與目標匹配的方法,有則返回直接引用。

  • 否則,依次在C的父類、介面列表、父介面中進行查詢。如果找到則根據情況返回直接引用或者丟擲java.lang.AbstractMethodError異常。

  • 如果都找不到,說明方法查詢失敗。丟擲java.lang.NoSuchMethodError

  • 最後,如果成功返回了直接引用,就對這個方法進行許可權驗證,如果發現不具備對此方法的訪問許可權,則丟擲java.lang.IllegalAccessError異常。

2.4 靜態型別和實際型別

已知有類FatherSon,且Son類繼承了Father類。假設我們以以下方式初始化變數。

class Father{}
class Son extends Father{}

Father object = new Son();

那我們把上面程式碼中的Father稱為變數object的靜態型別外觀型別,將Son稱為object的實際型別執行時型別

當變數被定義的時候,它的靜態型別就已經確定,而實際型別可能會在執行過程中不斷變化,例如下面給出一個例子。

class Father{}
class Son extends Father{}
class Daughter extends Father{}

Father object = new Random().nextBoolean() ? new Son() : new Daughter();

這個例子中,object的靜態型別始終是Father,而實際型別就只有到執行時才知道了。

3.方法呼叫

3.1 解析

非虛方法,即使用invokespecialinvokestatic指令呼叫的方法,由於無法被覆蓋,不可能存在其他版本,所以可以在類載入的解析階段直接進行方法解析,將符號引用全部轉變為明確的直接引用,不必延遲到執行期完成。

解析呼叫一定是一個靜態的過程,在編譯期間就完全確定。

值得說明的一點是,《Java虛擬機器規範》明確地將final方法定義為非虛方法,但final方法是使用invokevirtual呼叫的,故使用下面講的分派機制,而非解析。

3.2 靜態分派

靜態分派用於解釋過載的場景,下面給出一個簡單的例子

public class Test {
    public void overLoad(Father father){
        System.out.println("get father method");
    }

    public void overLoad(Son father){
        System.out.println("get son method");
    }


    public static void main(String[] args) {
        Test test = new Test();

        Father object = new Son();

        test.overLoad(object);
    }
}

class Father{}
class Son extends Father{}

//執行結果
get father method

顯然,JVM選擇了引數型別為Father的過載方法。

在虛擬機器處理過載的情況時,是通過引數的靜態型別而不是實際型別作為判斷依據的。由於靜態型別在編譯期可知,所以在編譯階段Javac編譯器就根據引數的靜態型別決定了會使用哪個過載版本。比如上面會選擇overload(Father)作為呼叫目標,並把這個方法的符號引用寫入到main()方法的invokevirtual指令的引數中,後續在解釋階段執行invokevirtual時,這個選好的方法就會直接被使用。這個操作是在Javac前端編譯的語法分析階段直接完成的。

值得注意的是Javac編譯器確定的過載版本並非確定的某一個,而是在現有的選擇中選擇的“最合適的”一個。下面給出一個示例。

public class Overload {
	// 從上到下,優先順序遞減
    public static void sayHello(char arg) {
        System.out.println("hello char");
    }
    
    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(Object arg) {
        System.out.println("hello Object");
    }
    
    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) {
        sayHello('a');
    }
}

假如按照上面的程式碼執行,那麼會被呼叫的是sayHello(char arg)方法,這就是Javac認為的最合適的方法。但假如我們將sayHello(char arg)註釋掉,那麼會被呼叫的是sayHello(int arg)方法,以此類推。

當然,一個腦子正常的程式設計師,不應該在自己的任何工程中寫出上述這樣的過載程式碼。

3.3 動態分派

靜態分派用於解釋重寫的場景,下面給出一個簡單的例子

public class Test {
    public static void main(String[] args) {
        Father object = new Son();

        object.override();
    }
}

class Father{
    public void override(){
        System.out.println("get father method");
    }
}

class Son extends Father{
    public void override(){
        System.out.println("get son method");
    }
}

//執行結果
get son method

顯然,JVM選擇了子類Son的重寫方法。顯然,在進行動態分派的時候,選擇方法的依據是呼叫方法的變數的實際型別。為了解釋清楚invokevirtual的作用方式,我們使用javap命令輸出這段程式碼中main部分的位元組碼。

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #7                  // class Son
         3: dup
         4: invokespecial #9                  // Method Son."<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #10                 // Method Father.override:()V
        12: return
      LineNumberTable:
        line 3: 0
        line 5: 8
        line 6: 12

0 ~ 7 行的位元組碼是一些準備工作。建立了用於存放變數object的記憶體空間,呼叫了對應的構造器,並將物件例項存放在了區域性變數表的第一個槽中。實際上對應程式碼中下面這行。

Father object = new Son();

第 8 行 的aload_1指令將剛剛建立的object物件引用壓到了運算元棧頂,這個物件即將呼叫override()方法。

第 9 行,正式使用了方法呼叫位元組碼指令invokevirtual。根據《Java虛擬機器規範》,invokevirtual指令的執行時解析過程分為以下幾步。

  • 找到運算元棧頂第一個元素指向的物件的實際型別並記作C。
  • 在C中查詢是否有簡單名稱和描述符都與目標匹配的方法,有則返回直接引用。
    • 這裡所謂的“目標”,是目標方法的簡單外觀,在編譯階段就已經傳遞給invokevirtual作為引數
  • 否則,依次在C的父類、介面列表、父介面中進行查詢。如果找到則根據情況返回直接引用或者丟擲java.lang.AbstractMethodError異常。
  • 如果都找不到,說明方法查詢失敗。丟擲java.lang.NoSuchMethodError
  • 最後,如果成功返回了直接引用,就對這個方法進行許可權驗證,如果發現不具備對此方法的訪問許可權,則丟擲java.lang.IllegalAccessError異常。

你應該可以看出來,其實就是我們在2.3節中講的位元組碼方法解析。重點就是我們從運算元棧頂找到了第一個元素指向的實際型別,並用它為基礎來做接下來的方法查詢。這種執行期根據實際型別確定方法執行版本的分派過程稱為動態分派

這裡再給出一個示例,幫助讀者更深入地瞭解動態分派。

public class FieldHasNoPolymorphic {

    static class Father {
        public int money = 1;

        public Father() {
            money = 2;
            showMeTheMoney();
        }

        public void showMeTheMoney() {
            System.out.println("I am Father, i have $" + money);
        }
    }

    static class Son extends Father {
        public int money = 3;

        public Son() {
            money = 4;
            showMeTheMoney();
        }

        public void showMeTheMoney() {
            System.out.println("I am Son,  i have $" + money);
        }
    }

    public static void main(String[] args) {
        Father gay = new Son();
        System.out.println("This gay has $" + gay.money);
    }
}

// 輸出結果
I am Son, i have $0
I am Son, i have $4
This gay has $2

應該不難理解,第一行的輸出來自父類Father構造器呼叫子類的showmeTheMoney()方法,此時子類尚未初始化,所以結果為0。

第二行的輸出來自子類呼叫showmeTheMoney()方法,此時子類已經初始化,結果為4。

第三行的輸出,使用gay.money直接取值,注意這個時候通過靜態型別訪問變數,自然沒有類似invokevirtual的東西來找所謂的實際型別。所以使用的是變數 gay 的靜態型別,那麼就從Father類中取值,取到money的值為2。

所以,動態分派僅限於方法!

4. 知識補充

4.1 單分派與多分派

方法的接收者和方法的引數統稱為方法的宗量。選擇方法時使用一種宗量稱為單分派,使用多種宗量稱為多分派。那麼顯而易見的,我們可以總結出Java是一種靜態多分派,動態單分派的語言。

  • 靜態多分派:在靜態分派的過程中,即過載的過程中,我們同時將方法的接收者和方法的引數作為選擇方法的依據,所以是多分派。
  • 動態單分派:在動態分派的過程中,方法的引數模式在編譯階段就已經確定,唯一動態決定的是方法接收者的實際型別,所以是單分派。

注:方法的接收者指呼叫方法的物件。如object.f(),那麼object就是方法的接收者。

4.2 虛擬機器動態分派的優化實現

我們可以想見的是,在程式碼執行過程中,一個虛方法可能會被大量多次地呼叫。所以一種在現代JVM中常見的優化手段是建立一個虛方法表,同理對於invokeinterface指令,也有介面方法表,它們的結構如下所示。

image-20210824222018120

虛方法表中存放的是各種方法的實際入口地址。如果父類的方法在子類中沒有重寫,那麼子類虛方法表中的地址入口和父類虛方法表中的入口地址是一致的,都指向父類的實現。否則子類的地址入口就會指向自己的實現。這樣可以節省大量的,動態分派過程中搜尋方法的開銷。

同時要求在父類和子類的虛方法表中,具有相同簽名的方法應該具有相同的索引序號,這樣當型別動態發生變化的時候,只需要動態改變要查詢的虛方法表,而不需要重新考慮在表中的位置。

虛方法表一般在類載入的連線階段進行初始化,準備了類的變數初始值後,虛擬機器就會為該類的虛方法表進行初始化。

4.2 虛方法的方法內聯

方法內聯是編譯器最重要的優化手段!簡單說就是把目的碼以類似複製的方式替換到呼叫方法的位置,避免發生真實的方法呼叫。下面是一個示例。

// 內聯前的程式碼
static class C {
    int val;
    final int get(){
        return val;
    }
}
  
public void f(){
    C c = new C();
    int x = c.get();
    int y = c.get();
    int sum = x + y;
}

// 內聯後的程式碼
public void f(){
    C c = new C();
    int x = c.val;
    int y = c.val;
    int sum = x + y;
}

方法內聯有兩個重要功能

  • 去除方法呼叫的成本,包括查詢方法版本和建立棧幀等。
  • 為建立其他優化打好基礎。

所以我們稱方法內聯為最重要的優化手段。然而在Java虛擬機器中,方法內聯卻有著一些天生的問題存在。對於Java中的虛方法,在將Java程式碼翻譯為位元組碼的編譯階段,很多情況下編譯器根本不可能確定該使用哪個方法版本。而Java作為物件導向的語言,在Java程式設計中絕大多數的方法都是虛方法,絕大多數的方法呼叫都是invokevirtualinvokeinterface負責的。

但是方法內聯對於優化來說又過於重要,所以Java虛擬機器的設計者們想了很多辦法來儘量解決問題。

Java虛擬機器引入了一種名為型別繼承關係分析(CHA)的技術,它用於確定在目前已經載入的類中,那些虛方法是否存在多個版本。根據分析結果的不同,Java虛擬機器可以採取不同的處理方法。

  • 假如只有一個方法,那麼就可以直接進行內聯,即假設整個應用程式也只有這一個版本。這種內聯被稱為守護內聯。當然我們知道,並不是所有的類都被載入,保不齊未來就會有這個方法的新版本出現,所以我們預留好了逃生門,當假設不成立時就通過逃生門拋棄掉已經編譯的程式碼,退回到解釋狀態進行執行,或者重新進行編譯。
  • 假如有多個方法版本可供選擇,那麼編譯器會嘗試使用內聯快取的方式來減少方法呼叫的開銷。內聯快取的基本原理很好理解,就是當方法第一次呼叫發生後,快取下方法接收者的版本資訊和對應的方法呼叫點。
    • 每次方法呼叫時都比較接收者的版本,如果版本不變,那麼就是一種單態內聯快取。通過該快取進行呼叫就解除了方法搜尋帶來的開銷,而僅僅多了一個比較版本的微小開銷。
    • 如果版本發生改變,說明程式用到了虛方法的多型特性,這時候會退化成超多型內聯快取,這裡說是一種內聯快取,其實就是不要快取了,直接正常進行動態分派操作。
    • 當快取未命中的時候,大多數JVM的實現時退化成超多型內聯快取,也有一些JVM選擇重寫單態內聯快取,就是更新快取為新的版本。這樣做的好處是以後還可能會命中,壞處是可能白白浪費一個寫的開銷。
image-20210824231434777