java虛擬機之方法調用(上)——靜態分派與動態分派

前言

我們都知道,面象對象的幾大特性:封裝,繼承,多態。

其實在面試過程中也是經常被問到這個問題的。那麼問題來了,java虛擬機是如何實現多態的?

其實在java虛擬機中,說到多態的實現,就不得不說說方法調用了。

方法調用概念

方法調用並不等於方法執行,方法調用階段唯一的任務是確定被調用方法的版本(其實就是調用哪一個方法)。我們都知道,Class文件的編譯過程中不包含c語言編譯中的連接步驟,一切方法調用在Class文件裏面都是符號引用,並不是java運行時的入口地址(這裏也側面印證了上一篇文章裏面java虛擬機之類加載機制解析工作要做的事)。其實正是因爲這樣,纔給java帶來了強大的擴展功能。

然而,到底什麼時候纔會確定方法目標方法的直接引用呢?

其實這個問題的答案得從類加載期間貫穿到運行期間。

解析

上一篇博客講了,在類加載的解析階段,會將部分符號引用轉化成直接引用,這個解析階段解析的部分“符號引用”必須滿足下面的條件:

  • 方法在程序真正運行前就有一個確定的版本。
  • 這個方法的調用版本在運行期間是不可改變的。

當滿足這兩個方法時,那麼在類加載的解析階段,就會轉換這個方法的符號引用到直接引用。

其實,滿足這兩具要求的方法主要有兩種:

1、靜態方法
2、私有方法

其實這兩種方法有一個共同點,那就是它們在繼承的時候都不可以被重寫,所以它們可以在類加載的解析階段就能被確定唯一版本。

與上面對應,在java虛擬機中,提供了5種方法調用字節碼命令,分別如下:

  • invokestatic 調用靜態方法
  • invokespecial 調用構造方法,私有方法和父類方法。
  • invokevirtual 調用虛方法
  • invokeinterface 調用接口方法,它會在運行的時候確定一個實現該接口的對象
  • invokedynamic 這貨是動態解析再調用方法。有點複雜,我們先不管它

通過上面的總結,我們發現invokestaticinvokespecial 修飾的方法似乎都能被唯一確定。所以他們都可以在類加載的時候唯一確定版本,就是說他們都能在類加載的時候被解析。

我們都知道靜態方法不能被繼承,那麼我們來看看下面這個栗子:

public class Main {
    public static void go(){
        
        System.out.println("go");
        
    }
    public static void main(String[] args) {
        
        Main.go();
    }
    
}

用javap來看一下字節碼,由於字節碼有點長,我就截取了部分重要代碼:


重點在白色框框住的代碼裏面,invokestatic #30

我們再看看#30是什麼鬼:


原來是Main.go();程序裏面直接就調用了Main.go();方法,可見go()方法是唯一確定的。

其實java裏面除了invokestaticinvokespecial ,其實還有一種情況是可以在java類加載的解析階段被唯一確定的,那就是final修飾的方法。因爲final修飾的方法不能被覆蓋,因此方法的接收者不會進行多態的選擇,所以在解析的時候是可以被唯一確定的。

由於解析是一個靜態的過程,編譯期間即可確定,在解析的時候就能把符號引用直接轉化成直接引用。

其實在java中,還存在一種調用(分派調用),它既可以靜態也可以動態。其實在這裏,java多態的性質實現會得到體現。

分派調用

其實分派分爲兩種,即動態分派和靜態分派。我們在瞭解分派的時候,通常把它們與重寫和重載結合到一起。

重載(overload)與靜態分派

我們先看一個題:

public class Main {
    static abstract class Father {

    }

    static class Son extends Father {

    }

    static class Daughter extends Father {

    }

    public void getSex(Daughter daughter) {
        System.out.println("i am a girl");
    }

    public void getSex(Son son) {
        System.out.println("i am a boy");
    }

    public void getSex(Father son) {
        System.out.println("i am a father");
        
    }
    public static void main(String[] args) {

        Father son = new Son();
        Father daughter = new Daughter();
        Main main = new Main();
        
        main.getSex(son);
        main.getSex(daughter);
    }

}

大家憑自己的經驗看能不能猜出會輸出什麼?

其實這個栗子就體現了重載。

要是我們在代碼裏改一下:

main.getSex((Son)son);

就會輸出i am a boy

其實這裏也體現出了java的靜態分派,我們都可以看到main對象已經確認了,那麼main在運行main.getSex(son);時選擇方法的時候,到底是選擇getSex(Son son)還是getSex(Father son)呢?

我們在代碼中son的引用類型是Father,但是它的實際類型卻是Son
我們再來看看生成的字節碼:

字節碼裏面0-23我們直接跳過,因爲0-23對用的代碼是

        Father son = new Son();
        Father daughter = new Daughter();
        Main main = new Main();

這裏的字節碼的作用是創建內存空間,然後把son 、daughter 和main 實例放到第1、2、3個實例變量表Slot中,這裏其實還有第0個實例,是this指針,放到的第0個slot中,這個超出了本文要講解的內容,故跳過。

我們從24看起,aload_x是把剛剛創建的實例放到操作數棧中,然後才能對其操作。後面第26行可以看到:

invokevirtual #50 //Method getSex:(LMain$Father;)

這裏相信大家都可以看出,字節碼中已經確定了方法的接收者是main和方法的版本是getSex(Father father),所以我們在運行代碼的時候,會輸出i am a father

其實java編譯器在重載的時候是通過參數的靜態類型而不是實際類型來確定使用哪個重載的版本的。所以這裏在字節碼中,選擇了getSex(Father father)作爲調用目標並把這個方法的符號引用寫到main方法的幾個invokevirtual指令的參數裏面。

所以依賴靜態類型來定位方法執行的版本的分派動作成爲靜態分派。靜態分派的典型應用是方法重載,而且靜態分派發生在編譯期間,因此,靜態分派的動作是由編譯器發出的。

另外,編譯器能確定出方法的重載版本,但在很多的時候,這個版本並不一定是唯一的,比如我把上面的代碼改一下:

public class Main {
    static abstract class Father {

    }

    static class Son extends Father {

    }

    public void getSex(Son son) {
        System.out.println("i am a boy");
    }

    public void getSex(Father son) {
        System.out.println("i am a father");
        
    }
    public static void main(String[] args) {

        Son son = new Son();
        Main main = new Main();
        
        main.getSex(son);
    }

}

然後再輸出:

QQ截圖20160724221829.png

這是很正常的執行結果,要是我們把getSex(Son son)註釋掉,然後再運行試試:

發現,編譯器並找不到getSex(Son son)這個方法,只有作出適當的妥協,把son向上轉型爲Father,然後選擇了getSex(Father son)方法。

要是我們再把getSex(Father son)註釋掉,會發現:

這裏又選擇了妥協並向上繼續轉型成Object。

綜上所述:靜態分派是選擇的最合適的一個方法版本來重載,然而這個版本並不是唯一確定的。我們在寫代碼的時候,要儘量避免這種情況發生,雖然這似乎能顯示出你知識的很淵博,但這並不是一個明智的選擇。

重寫(override)與動態分派

看完了靜態分派,我們再來看看動態分派。動態分派經常與重寫緊密聯繫在一起,那麼我們就先來看一個重寫的栗子:

public class Main {
    static  class Father {
        public void say(){
            System.out.println("i am fasther");
        }
    }

    static class Son extends Father {

        @Override
        public void say() {
            System.out.println("i am son");
        }

        
    }

    static class Daughter  extends Father {

        @Override
        public void say() {
            System.out.println("i am daughter ");
        }

    }
    public static void main(String[] args) {

        Father son = new Son();
        Father daughter = new Daughter();
        
        son.say();
        daughter.say();
        
    }

}

output:

i am son
i am daughter 

相信大家都知道輸出結果是什麼,三個類都有say()方法,但是虛擬機是怎樣知道調用哪個方法的呢? 別急,我們還是按照慣例,看看字節碼:

現在相信大家大概都能看懂裏面字節碼是怎樣回事了吧?

我們發現第17行和第21行對應的java代碼應該是:

        
        son.say();
        daughter.say()

從字節碼來看,這兩行代碼是一樣的。調用了同一個類的同一個方法,都是Father.say(),那爲什麼他們最後的輸出卻不一樣??

這裏的原因其實要從invokevirtual的多態查找開始說起,invokevirtual指令運行時的解析過程大概如下:

  • 找到操作數棧的棧頂元素所指向的對象的實際類型,記作C
  • 如果在類型C中找到與描述符和簡單名稱都相符的方法,則進行訪問權限校驗。通過則放回這個方法的直接引用,否則返回illegalaccesserror
  • 否則,則按照繼承關係從下住上依次對C的父類進行步驟2的查找。
  • 如果始終沒有找到合適的方法,則跑出AbstractMethodError異常。

由於invokevirtual指令在執行的第一步就對運行的時候的接收者的實際類型進行查找,所以上面兩次調用的invokevirtual指令都能成功找到實際類型的say()方法,然後把類方法的符號引用解析到不同的直接引用上面,這也是重寫的體現。

然後這種運行期根據實際類型來判斷方法的執行版本的分派過程叫作動態分派。

對於重寫與重載就先告一段落,後面還會補上分派的其他內容,歡迎關注。

參考資料

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