Java虛擬機知識整理——方法調用

方法調用

方法調用不等同與方法執行,方法調用階段唯一的任務就是確定被調用方法的版本,暫時還不涉及方法內部的具體運行過程。在程序運行時,進項方法調用時最普遍、最頻繁的操作。Class文件的編譯過程中不包含傳統編譯中的連接不走,一切方法調用在Class文件裏面存儲的都只是符號引用,而不是方法在史記運行時內部佈局中的入口地址。這個特性給Java帶來了更強大的動態擴展能力,但也使得Java方法調用過程變得相對複雜起來,需要在類加載期間,甚至到運行期間才能確定目標方法的直接引用。

解析

前面說到,所有方法調用中的目標方法在Class文件裏面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號轉化爲直接引用,這種解析能成立的前提是:方法在程序真正運行之前就有一個確定的調用版本,並且這個方法的調用保本在運行期間是不可改變的。話句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來,這類方法的調用成爲解析。
在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要包括靜態方法和私有方法兩個大類,前者與類型直接關聯,或者在外部不可被訪問,這兩種方法各自的特點決定了他們都不可能通過繼承活別的方式重寫其他版本,因此它們都適合在類加載階段進行解析。
在Java虛擬機裏面提供了5條方法調用字節碼指令

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

只要能被invokestatic和invokesspecial指令調用方法,都可以在解析階段中確定唯一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法四類,它們在類加載的時候就會吧符號引用解析爲該方法的直接引用。這些方法可以稱爲非虛方法,與之相反,其他方法都稱爲虛方法(除去final方法)。雖然final方法一是用invokevirtual指令來調用,但是由於它無法被覆蓋,沒有其他版本,所以也無需對方法接受者進行多態選擇,又或者說多態選擇的結果肯定是唯一的。在Java語言規範中明確說明了final方法是一種非虛方法。
解析調用一定是個靜態的過程,在編譯期間就完全確定,在類裝在的解析階段就會吧設計的符號引用區別腦補轉變爲可確定的直接引用,不會延遲到運行期再去完成。而分派調用則可能是靜態的也可能是動態的,根據分派依據的宗量數可分爲單分配和多分派。這兩類分派方式的亮亮組合就構成了靜態單分派、靜態多分配、動態單分派、動態多分派4種分派組合情況。

分派

Java是一門面向對象的程序語言,因爲Java具備面向對象的3個基本特徵:繼承、封裝和多態。這裏所說的分配調用過程將會解釋多態性特徵的一些最基本的體現,如“重載”和“重寫”在Java虛擬機之中是如何實現的,這裏的實現當然不是語法上該如何寫,我們關心的仍然是虛擬機如何確定正確的目標方法。
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,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);
    }
}

上述代碼中的輸出是
hello,guy!
hello,guy!
這段代碼是在考察閱讀者對重載的理解程度,但是,這是怎麼實現的呢?
上面代碼中的Human成爲變量的靜態類型,或者叫做外觀類型,靜態類型和實際類型在程序中都可以發生一些變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會改變,並且最終的靜態類型是在編譯期可知的;而且實際類型變化的結果在運行期纔可確定,編譯期在編譯程序的時候並不知道一個對象的實際類型是什麼。
有了這兩個概念的解釋,再開始說明代碼,main()裏面的兩次sayHello()方法調用,在方法接收這已經確定是對象“sr”的前提下,使用哪個重載版本,就完全取決於傳入參數的數量和數據類型。代碼中可以的定義了兩個靜態類型相同但是實際類型不同的變量,但虛擬機在重載時是通過參數的靜態類型而不是實際類型作爲判定依據的。並且靜態類型是編譯期可知的,因此在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪個重載版本,所以選擇了相應的調用目標。
所有依賴靜態類型來定位方法執行版本的分配動作成爲靜態分派。靜態分派的的典型應用是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的。另外,編譯器雖然能確定出方法的重載版本,但在很多情況下這種重載版並不是唯一的,往往只能確定一個“更加合適的”版本。
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在調用sayHello()方法時執行了不同的行爲,並且變量man在兩次調用中執行了不同的方法。導致這個現象的原因很明顯,是這兩個變量的實際類型不同,Java虛擬機是如何根據根據實際類型來分派方法執行版本的呢?
invokevirtual指令的運行時解析過程大致分爲以下幾個步奏

  • 找到操作數棧頂的第一個元素鎖只想的對象的實際類型,記作C。
  • 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問時間校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
  • 否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
  • 如果始終沒有找到合適的方法,則拋出java.langAbstractMethodError異常
    如果invokevirtual指令的第一步就是在運行期間確定接受者的實際類型,所以兩次調用中的invokevitual指令把常量池中類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。
    1. 單分派與多分派
      方法的接受者與方法的參數統稱爲方法的宗量。這個定義最早應該來源於《Java與模式》藝術,根據分配基於多少宗量,可以將分派劃分爲單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派是根據多於一個宗量對目標方法進行選擇。
public class Dispatch {
    static class QQ{}
    static class _360{}
    public static class Father{
        public void hardChoice(QQ arg){
            System.out.println("father choose qq");
        }
        public void hardChoice(_360 arg){
            System.out.println("father choose 360");
        }
    }
    public static class Son extends Father{
        public void hardChoice(QQ arg){
            System.out.println("son choose qq");
        }
        public void hardChoice(_360 arg){
            System.out.println("son choose 360");
        }
    }

    public static void main(String[] args){
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

運行結果
father choose 360
son choose qq
這個時候選擇目標的方法的依據有兩點:一是靜態類型是Father還是Son,二是方法參數是QQ還是360.這次選擇的結果的最終產物是產生了兩條invokvirtual指令,兩條指令的參數分別爲常量池中指向兩個方法的符號調用。因爲是根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派類型。
再看看運行階段虛擬機的選擇,也就是動態分派的過程。在執行方法調用的時候,更準確的說,就是在執行這句話鎖對應的invokevirtual指令時,由於編譯期已經決定目標方法的簽名必須爲hardChoise(QQ),虛擬機此時不會關心傳遞過來的參數到底是什麼,因爲這時參數的靜態類型,實際類型都對方法的選擇不會構成任何影響,唯一可以影響虛擬機選擇的因素只有此方法的接收者的實際類型是Father還是Son。因爲只有一個宗量作爲選擇依據,所以Java語言的動態分派屬於單分派類型。

本文總結

上述論證的結果,可以總結說:今天的Java語言是一門靜態多分派的語言。強調“今天的Java語言”是因爲這個結論未必會恆久不變。
前面介紹的分派過程,作爲對虛擬機概念模型的解析基本告一段落,它基本解決了虛擬機在分派中“會做什麼”這個問題。但是虛擬機“具體是如何做到的”,可能各種虛擬機的實現都會有些差別。

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