天天看點

深入解析多态和方法調用在JVM中的實作

深入解析多态和方法調用在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的運作時棧幀結構。

深入解析多态和方法調用在JVM中的實作
  • 局部變量表。用于存放方法參數和方法内部定義的局部變量。
  • 操作數棧。一個後入先出的LIFO棧,輔助方法執行中的運算操作。
  • 動态連接配接。動态連接配接是一個指向運作時常量池中該棧幀所屬方法的引用,指向的顯然是一個符号引用。它的存在主要是支援方法調用過程中的動态連接配接。
    • 方法調用中,符号引用一部分在類加載或者第一次使用時被轉化成直接引用,這種轉化稱為靜态解析。
    • 另外一部分符号引用在每一次運作期間都轉化為直接引用,這種轉化稱為動态連接配接。
  • 方法傳回位址。
    • 正常退出方法時,方法傳回位址指向主調方法的PC計數器。
    • 異常退出方法時,方法傳回位址指向異常處理表。
  • 附加資訊。服務于調試、性能收集等等。

2.2 方法調用位元組碼指令

針對不同類型的方法,Java虛拟機支援以下五種方法調用位元組碼指令。

  • invokestatic。用于調用靜态方法。
  • invokespecial。用于調用執行個體構造器

    <init>()

    方法、私有方法和父類中的方法。
    • 在Java11以後,invokespecial已經常常不被用來調用私有方法,詳見下文的實驗和說明。
  • invokevirtual。用于調用所有的虛方法。
  • invokeinterface。用于調用接口方法。在運作時确定實作該接口的對象。
  • invokedynamic。先在運作時動态解析出調用點限定符所引用的方法,然後執行該方法。
    • 詳見《深入了解Java虛拟機》p321

非虛方法指那些能夠在解析階段确定唯一的調用版本的方法,即上面由

invokestatic

invokespecial

調用的那些方法。而其他那些屬于類的,需要在運作時動态确定調用版本的方法,我們稱之為虛方法,最常見的虛方法就是普通的執行個體方法。

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

// 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 靜态類型和實際類型

已知有類

Father

Son

,且

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 解析

非虛方法,即使用

invokespecial

invokestatic

指令調用的方法,由于無法被覆寫,不可能存在其他版本,是以可以在類加載的解析階段直接進行方法解析,将符号引用全部轉變為明确的直接引用,不必延遲到運作期完成。

解析調用一定是一個靜态的過程,在編譯期間就完全确定。

值得說明的一點是,《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

      作為參數
  • 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

指令,也有接口方法表,它們的結構如下所示。

深入解析多态和方法調用在JVM中的實作

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

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

虛方法表一般在類加載的連接配接階段進行初始化,準備了類的變量初始值後,虛拟機就會為該類的虛方法表進行初始化。

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;
}
           

方法内聯有兩個重要功能

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