天天看點

從JVM層面對Java多态機制深入探尋

前言

我們都知道多态是Java中最重要的特性之一,而是什麼讓我對于深入探尋多态的機制起了好奇之心?

我們先來看下面的一段代碼:

class People {
    protected void test() {
        System.out.println("This is People.test().");
    }
}
public class Girl extends People {

    @Override
    protected void test() {
        System.out.println("This is Girl.test().");
    }

    public static void main(String[] args) {
        People people = new Girl();
        people.test();
    }
} /* Output
This is Girl.test().
*/
           

這是多态的基本應用,父類引用指向子類對象,調用的是子類覆寫的方法。

可是如果我們加上構造方法呢?

當執行子類對象的構造方法時,将會先執行其父類的構造方法,那麼如果我在父類的構造方法中調用子類覆寫的方法,效果會是怎樣?

class People {
    People()  {
        System.out.println("This is People init().");
        test();
    }
    protected void test() {
        System.out.println("This is People.test().");
    }
}
public class Girl extends People {

    Girl() {
        System.out.println("This is Girl init().");
    }

    @Override
    protected void test() {
        System.out.println("This is Girl.test().");
    }

    public static void main(String[] args) {
        People people = new Girl();
        people.test();
    }
} /* Output
This is People init().
This is Girl.test().
This is Girl init().
This is Girl.test().
*/
           

我們發現,結果似乎沒有什麼變化。

當我們在基類的構造方法中調用被子類覆寫的方法時,在main中通過向上轉型建立子類對象後,其父類中構造方法中執行的仍舊是子類對象的方法。

基于此,出現了一個很大的問題:如果父類中存在着一個屬性,并且這個屬性在被子類覆寫的方法中得到應用,那麼,這個屬性的值究竟是子類中的值,還是父類中的值?

同樣,通過程式來驗證一下:

class People {
    protected Integer testNum = 10;

    People()  {
        System.out.println("This is People init().");
        test();
    }
    protected void test() {
        System.out.println("This is People.test().");
        System.out.println("People.testNum = " + testNum);
    }
}
public class Girl extends People {
    protected Integer testNum = 20;

    Girl() {
        System.out.println("This is Girl init().");
    }

    @Override
    protected void test() {
        System.out.println("This is Girl.test().");
        System.out.println("Girl.testNum = " + testNum);
    }

    public static void main(String[] args) {
        People people = new Girl();
    }
} /* Output
This is People init().
This is Girl.test().
Girl.testNum = null
This is Girl init().
*/
           

咦,程式輸出結果中,

testNum

的值既不是People中的10,也不是Girl中的20,而是…null?

這究竟是為什麼?Java中的多态機制到底是怎樣的?

這就是今天我要探尋的主要目的。

注:讀本篇文章需要一定的JVM基礎,對記憶體區域尤其是方法區(元空間)、堆和棧有一定的了解。

綁定

了解多态,有一個名詞的意思必須了解,那就是綁定。

綁定,簡單來說就是程式在運作時調用何種方法的操作,也就是将方法的調用與方法所在的類“綁”起來。

在Java中,綁定主要分為靜态綁定和動态綁定。

而我們所說的多态,就是通過動态綁定來實作的。

靜态綁定

靜态綁定是指,在程式運作前就已經被綁定了,也就是編譯的時候就知道方法是哪個類的方法。

而在Java中,隻有

private

static

final

修飾的方法以及構造方法是靜态綁定,這些方法都是不能被重寫的。

  • private

    關鍵字标明的方法不能被繼承,自然不存在覆寫,是以其一開始就與定義該方法的類綁定在一起。
  • static

    關鍵字标明的方法是靜态方法,其同樣不可被繼承,且不依賴對象而存在,調用的時候就是定義它的類的方法。
  • final

    關鍵字标明的方法是無法被覆寫的,同樣與定義它的類綁定在一起。

是以,靜态綁定其實就是那些不可被覆寫的方法采用的綁定機制。

動态綁定

動态綁定是指,在程式運作過程中執行的綁定,在程式開始前是不知道方法屬于哪個類的。

也就是說,動态綁定是在運作時根據具體對象的類型進行綁定。

方法表

在說動态綁定的過程之前,我們先要明白一個概念,那就是方法表。

方法表是動态綁定的核心,其存放在方法區(JDK 1.7 及以前稱為方法區,有些人也将其稱作永久代,JDK 1.8 稱作元空間,這裡以JDK 1.7 的說法為準)的類型資訊中。

也可以這麼說,方法區的類型資訊中存有一個指向記錄該類方法的方法表的指針,而方法表中的每一項都是對應方法的指針。

方法表在類加載的連接配接階段進行初始化,以數組的形式記錄了目前類及其所有超類的可見方法位元組碼在記憶體中的直接位址。

如果某個方法在子類中沒有被重寫,那麼子類的方法表中該方法的位址和父類保持一緻。

方法表的實作

父類的方法會比子類的方法先得到解析,相比子類的方法位于表的前列。

而如果子類重寫了父類中某個方法的代碼,則該方法在方法表中的指向更換到子類的實作代碼上,而不會在方法表中出現新的項。

那麼方法調用的具體過程是怎樣的呢?

JVM首先根據class檔案找到調用方法的符号引用,然後在靜态類型的方法表中找到偏移量,根據

this

指針确定對象的實際類型,使用實際類型的方法表。

如果在實際類型的方法表中找到該方法,則直接調用,否則按照繼承關系從下往上搜尋。

這麼說可能有點抽象,我們根據實際的例子來一步步分析其過程:

class Father {

    protected void test() {
        System.out.println("This is Father." );
    }

}
public class Son extends Father {

    @Override
    protected void test() {
        System.out.println("This is Son.");
    }

    public static void main(String[] args) {

        Father s = new Son();
        s.test();

    }
}/* Output
This is Son.
*/
           

程式通過編譯後,我們可以用

javap -verbose Son.class

指令得到這個類的位元組碼指令(因篇幅緣故隻截取部分):

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

這裡簡單解釋一下關于靜态綁定和動态綁定的指令:

在Java虛拟機中提供了5條方法調用的位元組碼指令,分别是:

  • invokestatic

    :調用靜态方法;
  • invokespecial

    :調用執行個體構造器方法、私有方法和父類方法;
  • invokevirtual

    :調用所有的虛方法(簡單來說就是涉及到多态的方法);
  • invokeinterface

    :調用接口方法,會在運作時再确定一個實作此接口的對象;
  • invokedynamic

    :先在運作時動态解析出調用點限定符所引用的方法,然後再執行該方法,在此之前的4條調用指令,分派邏輯都是固化在Java虛拟機中的,而

    invokedynamic

    指令的分派邏輯是由使用者所設定的引導方法決定的。

顯而易見,前兩個位元組碼指令是屬于靜态綁定的指令,而其餘則是動态綁定的指令。

在類加載過程中,解析階段就是将符号引用轉換為直接引用的過程。

回歸正題,上述程式的解析過程又是什麼樣的?

我們先來看一張圖,上面說過,父類的方法比子類的方法先得到解析,是以在方法表中,父類的方法位于前面。

如果父類的方法被子類重寫,則在子類方法表中其指向為子類重寫的方法代碼。

從JVM層面對Java多态機制深入探尋
  1. 通過上面的位元組碼指令:

    9: invokevirtual #7 // Method Father.test:()V

    我們找到常量池中的第7個常量表索引項(

    #7

    指的就是

    Father

    類常量池中的第7個常量表的索引項):

    #7 = Methodref #8.#25 // Father.test:()V

    (因篇幅緣故不列出全部的常量池資訊)

    發現這裡記錄的是方法

    test()

    的符号引用
  2. JVM會根據這個符号引用找到方法

    test()

    所在的全限定名:

    Father

    (我這裡沒有設定

    package

    ,若有的話則隻需将

    .

    替換為

    /

    即可)

    (例如:

    com.jvm.Son

    ,全限定名為

    com/jvm/Son

  3. Father

    類型的方法表中查找方法

    test

    ,如果找到,則将方法

    test

    在方法表中的索引項(就是圖中的9,也被稱作偏移量)記錄到

    Son

    類的常量池的第

    7

    個常量表中。

    (這裡為的是進行安全檢查,JVM會首先将

    Father

    的方法表加載,之後從

    Father

    方法表中查找對應方法,如果方法不存在,那麼即使

    Son

    類型中方法表有,編譯也無法通過)

這時常量池解析結束,可是我們能确定調用

test

方法執行的是哪一塊位元組碼嗎?顯然是不能的,引用雖然是父類類型,但它的指向程式還是不清楚的,那麼如何确定呢?

  1. 我們看

    invokevirtual

    上一條位元組碼指令:

    8: aload_1

    簡單解釋一下,

    aload

    的意思是從局部變量表的相應位置裝載一個對象引用到操作數棧的棧頂。

    這裡将開始建立在堆中的

    Son

    對象的引用(也就是引用類型

    LFather

    )壓入操作數棧,

    invokevirtual

    會根據這個

    Son

    對象的引用找到堆中的

    Son

    對象,繼而找到

    Son

    對象所屬類型的方法表。

t i p s : 在 編 譯 時 加 入 ‘ − g ‘ 生 成 所 有 調 試 信 息 , 在 反 編 譯 的 時 候 利 用 ‘ − l ‘ 就 可 以 查 看 本 地 變 量 表 \color{#FF0000}{tips: 在編譯時加入`-g`生成所有調試資訊,在反編譯的時候利用`-l`就可以檢視本地變量表} tips:在編譯時加入‘−g‘生成所有調試資訊,在反編譯的時候利用‘−l‘就可以檢視本地變量表

例如編譯時:

javac -g Son.java

,反編譯指令:

javap -c -l Son.java

,就可以觀察到

LocalVariableTable

LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0      13     0  args   [Ljava/lang/String;
          8       5     1     s   LFather;
           

可以清楚的看到

slot=1

的位置存放的是引用類型

LFather

當我們在向上轉型的時候:

記憶體中是什麼樣的呢,請看下面這張圖:

從JVM層面對Java多态機制深入探尋

這樣就能很清楚的解釋父類引用與子類對象的關系,就如上文所說,通過

Father

引用找到堆中的

Son

對象執行個體。

Son

對象執行個體中持有着指向方法區的類型資訊的引用(在資料區裡),方法區的類型資訊中存有一個指向記錄該類方法的方法表的指針,這樣通過執行個體通路方法區,繼而找到

Son

的方法表。

(堆中執行個體并不隻有局部變量的定義,這裡隻是列出一個架構作為參考)

  1. 通過

    #7

    常量表中的方法表的索引項(就是第三步裡說的

    9

    )定位到

    Son

    類型方法表中的方法

    test

    ,通過直接位址找到該方法位元組碼所在的記憶體空間。

    (因為子類和父類相同方法的索引相同,這個下面會提到,是以通過父類的索引就能找到子類中重寫方法的位置)

這裡有幾個要注意的點:

  1. JVM是根據父類

    Father

    來解析常量池的,用

    Father

    方法表中的索引項來代替常量池中的符号引用。
  2. 方法表在類加載的連結階段進行初始化,存放着各個方法的實際入口位址,如果某個方法在子類中沒有被重寫,那麼子類的方法表中該方法的入口位址與父類保持一緻。
  3. 相同的方法(相同的方法簽名:方法名和參數清單)在所有類的方法表中的索引相同,如果

    test

    位于

    Father

    類方法表的第9項,那麼其在

    Son

    類的方法表中也位于第9項。

    (這裡是為了當類型變換時,僅需要變更查找的方法表,就可以按索引轉換出需要的入口位址)

  4. 父類的方法表永遠比子類的方法表先加載,當子類的方法表生成時,方法表首先會繼承一份自父類(類似于複制),父類有的方法子類都會獲得且索引項(偏移量)完全相同。

    此時根據子類的方法調整方法表,如果子類重寫了父類的方法,那麼指針就會修改為指向那條重寫後的方法。

    如果子類新增了一個方法,那麼就會在方法表某處添加一個指針,指向新增的方法。

問題解決

對于一開始講述的問題,我們一步一步來分析,首先看

main

方法的位元組碼部分:

Code:
      stack=2, locals=2, args_size=1
         0: new           #14                 // class Girl
         3: dup
         4: invokespecial #15                 // Method "<init>":()V
         ...
--------------------------------------------------------------------------------------------
 #15 = Methodref          #14.#35        // Girl."<init>":()V
           

可以看出,程式首先

new

了一個

Girl

執行個體,對其進行預設初始化,并且将指向該執行個體的一個引用壓入操作數棧頂。

dup

的意思是把棧頂複制一份入棧,為什麼要這麼做?

因為程式要對

Girl

進行初始化,執行

invokespecial

指令,而

invokespecial

會消耗掉操作數棧頂的引用作為傳給構造器的

this

參數。

是以如果我們希望在

invokespecial

調用後在操作數棧頂還維持有一個指向建立對象的引用,就得在

invokespecial

之前先複制一份引用以供

invokespecial

來消耗。

接下來我們來看

Girl

初始化部分的位元組碼指令:

Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method People."<init>":()V
         ...
           

發現,在将

this

入棧後,進行了父類的初始化,一步步向上追蹤,看

People

類的初始化過程:

Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        10
         7: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        10: putfield      #3                  // Field testNum:Ljava/lang/Integer;
        13: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: ldc           #5                  // String This is People init().
        18: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        21: aload_0
        22: invokevirtual #7                  // Method test:()V
        25: return
           

主要看調用順序,程式是先将

10

入棧(

5: bipush 10

),并進行一系列的初始化操作(此時在父類中

testNum

的值為10),之後才執行的構造方法,根據上文我們知道,

22: invokevirtual #7

執行的是子類的

test

方法(因為new的是子類執行個體),是以接下來我們看子類中

test

方法是如何執行的:

Code:
      stack=3, locals=1, args_size=1
         0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #7                  // String This is Girl.test().
         5: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        11: new           #8                  // class java/lang/StringBuilder
        14: dup
        15: invokespecial #9                  // Method java/lang/StringBuilder."<init>":()V
        18: ldc           #10                 // String Girl.testNum =
        20: invokevirtual #11                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        23: aload_0
        24: getfield      #3                  // Field testNum:Ljava/lang/Integer;
        27: invokevirtual #12                 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
        30: invokevirtual #13                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        33: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        36: return
           

主要看這一個指令:

24: getfield #3 // Field testNum:Ljava/lang/Integer;

getfield

的意思是:擷取指定類的執行個體變量,将結果壓入棧頂

也就是說,這一步将

testNum

壓入棧頂,可是此時Girl 這個類的

testNum

還沒有進行初始化!

因為現在仍處于父類初始化的階段,是以

testNum

的值列印出來就是預設的

null

執行完

test

方法後,父類初始化完畢,繼續子類的初始化過程:

...
         4: aload_0
         5: bipush        20
         7: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        10: putfield      #3                  // Field testNum:Ljava/lang/Integer;
        13: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: ldc           #5                  // String This is Girl init().
        18: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        21: return
           

此時才為

testNum

進行初始化(

10: putfield #3

),之後執行

Girl

的構造方法。

問題總結

通過上面的例子,我們知道了當一個子類被new出,其實際的初始化過程順序為:

  1. 父類static成員初始化
  2. 子類static成員初始化
  3. 父類普通成員初始化
  4. 父類構造方法
  5. 子類普通成員初始化
  6. 子類構造方

(例子中沒有描述static成員初始化過程,但static成員要比普通成員先初始化)

當我們在父類的構造方法中調用了被子類重寫的方法,并new出一個子類執行個體時,你就要小心了!

因為父類的構造方法比子類普通成員初始化先執行,是以如果在子類重寫的方法中使用了子類中的屬性,那麼這個屬性的值往往會與你預期的有所偏差。

是以在構造方法中,唯一能安全調用的就是父類本身私有的方法(或者聲明為

final

的方法),如果調用子類重寫的方法,一定要小心小心再小心。

拓展(靜态多分派)

JVM中除了靜态配置設定和動态配置設定外,還分單分派和多分派。

根據一個宗量的類型進行方法的選擇稱為單分派。

根據多于一個宗量的類型對方法的選擇稱為多分派。

宗量又是什麼?

方法的接受者與方法的參數統稱為方法的宗量。

在Java中實行的是靜态多分派和動态單分派,上面介紹的就是動态單分派,主要應用為方法的重寫。

而靜态多分派的典型應用是方法的重載。

下面我們通過一個例子來簡單介紹一下靜态多分派的機制:

class Human{}
class Man extends Human{}
class Woman extends Human{}

public class StaticDispatch{

    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);
    }
}/* Output
hello,guy!
hello,guy!
*/
           

我們把上面的

Human

稱為變量的靜态類型,把

Man

稱為變量的實際類型。

可以很清楚的看到結果是由靜态類型來決定的。

在方法的調用者都為

sr

的前提下,使用哪個重載版本,完全取決于傳入參數的數量和資料類型。

而編譯器(不是虛拟機,因為如果是根據靜态類型做出的判斷,那麼在編譯期就确定了)在重載時是通過參數的靜态類型而不是實際類型作為判定依據的。

因為靜态類型是編譯期可知的,是以在編譯階段,Javac編譯器就根據參數的靜态類型決定使用哪個重載版本。

拓展2(關于屬性調用)

對于父類引用指向子類對象,對于方法調用的是子類中重寫的方法,那變量名相同的屬性呢?

我們先來看一段代碼:

class Father {
    protected Integer x = 10;
}

public class Son extends Father{

    protected Integer x = 20;

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

        System.out.println("This is x = " + s.x);
    }
}/* Output
This is x = 10
*/
           

很神奇對不對?對屬性的通路,竟然是基于變量的靜态類型的,這又是為什麼呢?

很簡單,屬性的綁定是在編譯期間完成的,編譯期間是父類的類型,是以在我們調用屬性時調用的也是父類的屬性值。

而如果改用方法去通路就不會出現這個問題:

class Father {
    private Integer x = 10;

    public Integer getX() {
        return this.x;
    }
}

public class Son extends Father{

    private Integer x = 20;

    public Integer getX() {
        return this.x;
    }


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

        System.out.println("This is x = " + s.getX());
    }
}/* Output
This is x = 20
*/
           

這裡根據多态調用了子類重寫的方法,這樣做也是推薦(為了安全性與私有性)的一種做法。

參考資料

  1. 方法的虛分派(virtual dispatch)和方法表(method table)
  2. 細說JVM(虛拟機實作多态)
  3. Java動态綁定機制的内幕
  4. Java多态的實作原理
  5. Java動态綁定和靜态綁定-多态
  6. 向上轉型底層原理分析
  7. Java重寫方法與初始化的隐患
  8. 多态性實作機制——靜态分派與動态分派
  9. 深入了解Jvm–Java靜态配置設定和動态配置設定完全解析