深入理解JVM:從JVM層面來講講Java多態

首先來一道筆試題:

對多態理解不夠深入的,多半都會答錯;如果能記住口訣:“變量多態看左邊,方法多態看右邊,靜態多態看左邊”的話,肯定就知道答案,但是JVM是如何確定具體調用哪個方法的,有小夥伴思考過嗎?

1、方法調用:

方法調用並不等於方法執行,方法調用階段唯一的任務就是確定被調用方法的版本(就是具體調用哪個方法),暫時還不涉及到方法內部的具體運行過程。Class文件的編譯過程中不包含傳統編譯中的連接步驟,一切方法調用在Class文件裏面存儲的都只是符號引用,而不是方法在實際運行時內存佈局中的入口地址。這個特性給Java帶來了更強大的動態拓展能力,也使得Java方法調用過程變得相對複雜起來,需要在類加載期間,甚至到運行時才能確定目標方法的直接引用。

2、解析:

所有方法調用中的目標方法在Class文件裏面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉換爲直接引用(相當於指針)。這種解析能成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可改變的。換句話說,調用目標在程序代碼在編譯時就必須確定下來。這類方法的調用稱之爲解析。

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

與之對應的是,在JVM裏面提供了5條方法調用字節碼指令,分別如下:

invokestatic:調用靜態方法
invokespecial:調用實例構造器<init>方法、私有方法和父類方法。
invokevirtual:調用所有的虛方法
invokeinterface:調用接口方法,會在運行時再確定一個實現此接口的對象。
invokedynamic:先在運行時動態解析出調用點限定符所引用的方法,然後再執行該方法,在此之前的4條調用指令,分派邏輯是固化在JVM內部的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。

只要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段中唯一確定調用版本,符合這個條件的有:靜態方法、私有方法、實例構造器、父類方法。它們在類加載的時候就會把符號引用解析爲該方法的直接引用。這些方法也稱爲“非虛方法”。

代碼示例:

public class StaticResolution {
    /**
     * 此方法只能屬於StaticResolution類,沒有任何手段可以重寫或隱藏這個方法
     */
    public static void sayHello() {
        System.out.println("hello world");
    }

    public static void main(String[] args) {
        StaticResolution.sayHello();
    }
}

 使用javac編譯,然後在編譯好的class文件所在目錄打開cmd輸入命令:

javap -c StaticResolution.class

可以查看該程序的字節碼:

可以發現,的確是通過invokestatic命令來調用sayHello()方法的。

Java中的非虛方法除了使用invokestatic、invokevirtual調用的方法之外還有一種,就是被final修飾的方法,雖然final方法是使用invokevirtual指令來調用的,但是由於它無法被覆蓋,沒有其他版本,所以也無須對方法接收者進行多態選擇,又或者說多態選擇的結果肯定是唯一的。在Java語言規範中明確說明了final方法是一種非虛方法。

解析調用一定是個靜態的過程,在編譯期間就完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變爲可確定的直接引用,不會延遲到運行期再去完成。而分派調用則可能是靜態的也可能是動態的,根據分派依據的宗量數可分爲單分派和多分派。這兩類分派方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派這4種分派組合情況。

3、分派

1)靜態分派

先看一段代碼:

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 human");
    }

    public void sayHello(Man guy) {
        System.out.println("hello man");
    }

    public void sayHello(Woman guy) {
        System.out.println("hello woman");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch dispatch = new StaticDispatch();
        dispatch.sayHello(man);
        dispatch.sayHello(woman);
    }
}

輸出結果是什麼呢,很多人會想按照參數類型,肯定是輸出:

hello man
hello woman

 然而事實卻是輸出:

hello human
hello human

爲什麼會選擇參數類型是Human的重載呢?解答這個問題之前,先按如下代碼定義兩個重要的概念。

Human man = new Man();

我們把上面代碼中的“Human” 成爲變量的靜態類型(Static Type),或者叫做外觀類型(Apparent Type),後面的“Man” 則稱爲變量的實際類型(Actual Type),靜態類型和實際類型在程序中都可以發生變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變,並且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期纔可確定,編譯期在編譯程序的時候並不知道一個對象的實際類型是什麼,例如:

// 實際類型變化
Human man = new Man();
man=new Woman();
// 靜態類型變化
dispatch.sayHello((Man) man);
dispatch.sayHello((Woman) man);

main()中的兩次調用sayHello(),在方法接收者已經確定是對象“dispatch”的前提下,使用哪個重載版本,就完全取決於傳入參數的數量和數據類型。代碼中刻意定義了兩個靜態類型相同但實際類型不同的變量,但編譯器在重載時是通過參數的靜態類型而不是實際類型作爲判定依據的。並且靜態類型是編譯器可知的,因此在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪個重載版本,所以選擇了sayHello(Human)作爲調用目標,並把這個方法的符號引用寫到main()方法裏的兩條invokevirtual指令的參數中。

所有依賴靜態類型來定位方法執行版本的分派動作成爲靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段。因此確定靜態分派的動作實際上不是有虛擬機來執行。另外雖然編譯器能確定出方法的重載版本,但在很多情況下這個重載版本並不是“唯一的”,往往只能確定一個“更加合適”的版本。主要原因是字面量不需要定義,所以字面量沒有顯式的靜態類型,它的靜態類型只能通過語言上的規則去理解和推斷。下面演示一個經典代碼

public class Overload {

    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(Character arg) {
        System.out.println("hello Character");
    }

    public static void sayHello(char arg) {
        System.out.println("hello char");
    }

    public static void sayHello(char... arg) {
        System.out.println("hello char...");
    }

    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }

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

輸出結果當然是:

hello char

因爲‘a’是char類型,自然會尋找參數類型爲char的方法,如果註釋掉sayHello(char arg)方法,那輸出將是:

hello int

這時發生了一次自動類型轉換,‘a’ 除了代表一個字符,還可以代表數字97(Unicode),因此參數類型爲int的重載也是合適的。繼續註釋sayHello(int arg)方法,那輸出將是:

hello long

這時發生了兩次自動類型轉換,‘a’轉型爲整數97之後,進一步轉型爲長整型97L,匹配了參數類型爲long的方法,如果還有參數類型爲float,double等的重載方法,自動轉型還會發生多次,按照char > int > long > float > double 的順序轉型進行匹配。但是不會匹配byte和short類型的重載。因爲char到byte和short的轉型是不安全的,繼續註釋掉sayHello(long arg)方法,輸出是:

hello Character

這時候發生了一次自動裝箱,‘a’被包裝爲它的包裝類型java.lang.Character,所以匹配到sayHello(Character arg),繼續註釋sayHello(Character arg),輸出就會變爲:

hello Serializable

這個輸出結果是因爲java.lang.Character實現了Serializable接口,當自動裝箱後發現還是找不到包裝類,但是找到了包裝類實現的接口,所以又發生了一次自動轉型。char可以轉型爲int,但是Character絕對不會轉型爲Integer,它只能安全的轉型爲它實現的接口或父類。Character還實現了Comparable接口,如果同時出現兩個參數分別爲Serializable和Comparable的重載方法,那它們在此時的優先級是一樣的,但編譯器就無法確定自動轉型爲哪種類型了,會提示編譯錯誤,比如

需要調用的時候顯式的指定字面量的靜態類型sayHello((Comparable<Character>)'a'); 才能編譯通過

繼續註釋sayHello(Serializable arg),輸出爲:

hello object

這時是char裝箱後轉型爲父類了,如果有多個父類,那將在繼承關係中從下往上開始搜索,越接近上層優先級越低,即使方法調用傳入的參數爲null,這個規則仍然使用

繼續註釋掉sayHello(Object arg),輸出爲:

hello char...

由此可見,可變參數方法的重載優先級是最低的。這時候參數‘a’被當做數組,還可以選擇int類型、Character、Object類型等的可變參數重載來重新演示。

2)動態分派

動態分派與重寫有着密切聯繫,演示代碼:

public class DynamicDispatch {
    
    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends Human {

        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human {

        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}

運行結果:

man say hello
woman say hello
woman say hello

這個運行結果相信不會出乎任何人的意料,現在的問題還是和前面的一樣,虛擬機是如何知道要調用哪個方法的?顯然這裏不可能再根據靜態類型來決定,因爲靜態類型同樣都是Human的兩個變量,man和woman在調用sayHello0方法時執行了不同的行爲,並且變量man在兩次調用中執行了不同的方法。導致這個現象的原因很明顯,這兩個變量的實際類型不同,Java虛擬機
是如何根據實際類型來分派方法執行版本的呢?我們使用javap -c DynamicDispatch.class 命令輸出這段代碼的字節碼:

Compiled from "DynamicDispatch.java"
public class jvm.DynamicDispatch {
  public jvm.DynamicDispatch();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class jvm/DynamicDispatch$Man
       3: dup
       4: invokespecial #3                  // Method jvm/DynamicDispatch$Man."<init>":()V
       7: astore_1
       8: new           #4                  // class jvm/DynamicDispatch$Woman
      11: dup
      12: invokespecial #5                  // Method jvm/DynamicDispatch$Woman."<init>":()V
      15: astore_2
      16: aload_1
      17: invokevirtual #6                  // Method jvm/DynamicDispatch$Human.sayHello:()V
      20: aload_2
      21: invokevirtual #6                  // Method jvm/DynamicDispatch$Human.sayHello:()V
      24: new           #4                  // class jvm/DynamicDispatch$Woman
      27: dup
      28: invokespecial #5                  // Method jvm/DynamicDispatch$Woman."<init>":()V
      31: astore_1
      32: aload_1
      33: invokevirtual #6                  // Method jvm/DynamicDispatch$Human.sayHello:()V
      36: return
}

0-15行的字節碼是準備動作,作用是建立man和woman的內存空間、調用Man和Woman類型的實例構造器,將這兩個實例的引用存放在第1、2個局部變量表Slog之中,這個動作也對應了代碼中的這兩句:

Human man = new Man();
Human woman = new Woman();

接下來的16-21是關鍵部分,16、20兩句分別把剛剛創建的兩個對象的引用壓到棧頂,這兩個對象是將要執行的sayHello()方法的所有者,成爲接收者;17和21句是方法調用指令,這兩條調用指令單從字節碼角度看,無論是指令(都是invokevirtual)還是參數(都是常量池中第22項的常量,註釋顯示了這個常量是Human.sayHello()的符號引用)完全一樣的,但是這兩句指令最終執行的目標方法並不相同。原因就需要從invokevirtual指令的多態查找過程說起,invokevirtual指令的運行時解析過程大致分爲以下幾個步驟:

1)找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C。
2)如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限
校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.
IHlegalAccessError異常。
3)否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
4)如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
  由於invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調
用中的invokevirtual指令把常量池中的類方法符號引用解柝到了不同的直接引用上,這個過
程就是Java語言中方法重寫的本質。我們把這種在運行期根據實際類型確定方法執行版本的
分派過程稱爲動態分派。
  • 再插入一個《Java編程思想》中的一個經典例子,加深理解:
public class Shape {

    public void draw(){}

    static class Circle extends Shape{
        @Override
        public void draw() {
            System.out.println("Circle.draw()");
        }
    }

    static class Square extends Shape {
        @Override
        public void draw() {
            System.out.println("Square.draw()");
        }
    }

    static class Triangle extends Shape {
        @Override
        public void draw() {
            System.out.println("Triangle.draw()");
        }
    }

    static class RandomShape{
        private Random random = new Random(47);
        public Shape next() {
            switch (random.nextInt(3)) {
                default:
                case 0:
                    return new Circle();
                case 1:
                    return new Square();
                case 2:
                    return new Triangle();
            }
        }
    }

    private static RandomShape randomShape = new RandomShape();

    public static void main(String[] args) {
        Shape[] s = new Shape[9];
        for (int i = 0; i < s.length; i++) {
            s[i] = randomShape.next();
        }
        for (Shape shape : s) {
            shape.draw();
        }
    }
}

輸出結果:

Triangle.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Circle.draw()

上面的例子中,Shape類爲所有的子類建立了一個公共方法,子類通過重寫這個方法,來爲每種特殊形狀提供單獨的行爲。

RandomShape類,其實像一個“工廠”,每次調用next()方法時,它可以爲隨機選擇的Shape對象產生一個引用。注意:向上轉型是發生在return語句中。每個return語句都取得一個指向某個Circle、Square、Triangle的引用,並將其以Shape類型從next()方法中發送出去。所以無論我們在什麼時候調用next()方法,是絕不可能知道具體類型到底是什麼的,因爲我們總是隻能獲得一個通用的Shape引用。

main()包含了一個Shape引用組成的數組,通過調用RandomShape.next()來填入數據。此時,我們只知道自己擁有一些Shape,除此之外不會知道更具體的情況(編譯器也不知道)。然而,當我們遍歷這個數組,併爲每個數組元素調用draw()方法時,與類型有關的特定行爲會神奇般的發生,從輸出結果不難看出。

3)JVM動態分派的實現

動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要運行時在類的方法元數據中搜索合適的目標方法,因此在虛擬機的實際實現中基於性能的考慮,大部分實現都不會真正的進行如此頻繁的搜索。最常見的“穩定優化”手段就是爲類在方法區中建立一個虛方法表(Virtual Method Table,也稱爲vtable,與此對應的在invokeinterface執行時也會用到接口方法表——Interface Method Table,簡稱itable),使用虛方法表索引來代替元數據查找以提高性能。

虛方法表存放着各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裏面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實際入口地址。如果子類重寫了這個方法,子類方法表中的地址將會替換爲指向子類實現版本的入口地址。

爲了程序實現的方便,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一樣的索引序號,這樣當類型轉換時,僅需要變更查找的方法表,就可以從不同的虛方法表中按索引轉換出所需的入口地址。

方法表一般在類加載的連接階段進行初始化,準備了類的變量初始值後,虛擬機會把該類的方法表也初始化完畢。

下一篇文章,將分享javac編譯器和JIT編譯器。

以上知識點都總結來自於周志明的《深入理解Java虛擬機》和《Java編程思想第4版》。

發佈了24 篇原創文章 · 獲贊 38 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章