開發兩年!JVM方法調用都玩不明白,你離被炒魷魚不遠了! 前言 解析 分派 最後

前言

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

解析

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

在java中符合編譯期可知,運行期不可變的方法,主要有靜態方法和私有方法,前者與類型關聯,後者在外部不可訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫出其他版本,因此它們都適合在類加載階段進行解析。

Java中的靜態方法、私有方法、實例構造器、父類方法,再加上被final修飾的方法,這5種方法調用會在類加載的時候就可以把符號引用轉換爲直接引用。這些方法統稱爲“非虛方法” 。與之相反,其他的方法被稱爲“虛方法”。

解析調用一定是一個靜態過程 ,在編譯期就完全確定,在類加載解析階段就會把涉及的符號引用全部轉變爲明確的直接引用,不必延遲到運行期再去完成。而另一種主要的方法調用形式:分派(Dispatch)調用,可能是靜態的也可能是動態的。按照分派依據的宗量數可分爲單分派和多分派。這兩類分派方式兩兩組合就構成了靜態單分派,靜態多分派,動態單分派,動態多分派。

分派

分派調用將會解釋多態性特徵的一些最基本的體現。

靜態分派

/**
 * 靜態分派
 */
public class StaticDispatch {

    static abstract class Human{

    }
    static class Man extends Human{

    }
    static class Woman extends Human{

    }

    public void say(Human human){
        System.out.println("Human say");
    }

    public void say(Man man){
        System.out.println("Man say");
    }

    public void say(Woman woman){
        System.out.println("Woman say");
    }

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

運行結果如上,要解決這個問題,首先需要定義兩個關鍵概念:

Human man=new Man();

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

//實際類型變化
Human human=(new Random()).nextBoolean() ? new Man() : new Woman();

//靜態類型變化
sd.say((Man)human);
sd.say((Woman)human);

而上面的代碼中,human的實際類型是可變的,編譯期完全不確定到底是man還是woman,必須等到程序運行時才知道。而human的靜態類型是Human,也可以在使用時強制轉型臨時改變這個類型,但這個改變仍是在編譯期可知。

回到上面靜態分派的案例中,兩次say方法的調用,在方法接收者已經確定是對象sd的前提下,使用哪個重載版本,完全取絕於傳入參數的數量和數據類型。代碼中故意定義了兩個靜態類型相同,而實際類型不同的變量,但編譯器在重載時是通過參數的靜態類型而不是實際類型作爲判定依據的。由於靜態類型在編譯期可知,因此選擇了say(Human man)進行調用。所有依賴靜態類型來決定方法執行版本的分派動作,稱爲靜態分派。靜態分派最典型應用表現就是重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的。

需要注意Javac編譯期雖然能確定出方法重載的版本,但在很多情況下這個重載版本並不是唯一的,往往只能確定一個相對更適合的版本。

/**
 * 重載方法匹配優先級
 */
public class OverLoad {
    public static void say(Object obj){
        System.out.println("Object");
    }
    public static void say(int obj){
        System.out.println("int");
    }
    public static void say(long obj){
        System.out.println("long");
    }
    public static void say(Character obj){
        System.out.println("Character");
    }
    public static void say(char obj){
        System.out.println("char");
    }
    public static void say(char... obj){
        System.out.println("char...");
    }
    public static void say(Serializable obj){
        System.out.println("Serializable");
    }

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

運行結果爲:char。

這很好理解’a’就是char類型,自然選擇char的重載方法,如果去掉char的重載方法,那輸出會變爲:int。這時候發生了一次自動類型轉換,‘a’除了可以代表一個字符,還可以代表數字97,因此會選擇int的重載方法。如果繼續去掉int的方法,那麼輸出會變爲:long。這時發生了兩次自動轉向,先轉爲整數97後,進一步轉爲長整型97L,匹配了long 的重載。實際上自動轉型還能發生多次,按照char > int > long > float > double的順序進行匹配,但不會匹配到byte和short的重載,因爲char 到這兩個類型是不安全的。繼續去掉long的方法,輸出會變爲:Character,這時發生了一次自動裝箱,'a’變爲了它的包裝類。繼續去掉Character方法,輸出變爲:Serializable。這個輸出可能會讓大家有點疑惑,字符或數字與序列化有什麼關係?其實是Character是Serializable接口的一個實現類,當自動裝箱後還是找不到裝箱類,但是找到了裝箱類所實現的接口類型,所以又發生一次自動轉型。char可以轉爲int,但Character不會轉爲Integer,它只能安全地轉型爲它實現的接口或父類。Character還實現了另外一個接口java.lang.Comparable< Character>,如果同時出現這兩個接口類型地重載方法,那優先級是一樣的,但編譯器會拒絕編譯。繼續去掉Serializable,輸出會變爲Object。這是char裝箱後轉型爲父類了。如果有多個父類,將在繼承關係中從下往上開始搜索,越上層優先級越低。繼續去掉Object,輸出會變爲char…。可見不定長數組地重載優先級最低。但要注意,char 轉型爲int,在不定長數組是不成立的。

動態分派

動態分派與java多態性的重寫有密切的關係。

/**
 * 動態分派
 */
public class DynamicDispatch {
    static abstract class Human{
        protected abstract void say();
    }

    static class Man extends Human{
        @Override
        protected void say() {
            System.out.println("man");
        }
    }

    static class Woman extends Human{
        @Override
        protected void say() {
            System.out.println("woman");
        }
    }

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

這個結果相信沒什麼太大疑問。這裏選擇調用的方法不可能再根據靜態類型來決定的,因爲靜態類型同樣是Human的兩個變量,man和woman在調用時產生了不同行爲,甚至man在兩次調用中還執行了兩個不同的方法。導致這個的原因,是因爲兩個變量的實際類型不同,實際執行方法的第一步就是在運行期間確定接收者的實際類型,所以並不是把常量池中方法的符號引用解析到直接引用上就結束,還會根據方法接收者的實際類型來選擇方法版本,這個過程就是方法重寫的本質。這種在運行期根據實際類型確定方法執行版本的分派過程稱爲動態分派。

注意,字段永不參與多態。

/**
 * 字段沒有多態
 */
public class FieldTest {
    static class Father{
        public int money=1;
        public Father(){
            money=2;
            show();
        }
        public void show(){
            System.out.println("Father 有"+money);
        }
    }

    static class Son extends Father{
        public int money=3;
        public Son(){
            money=4;
            show();
        }
        public void show(){
            System.out.println("Son 有"+money);
        }
    }

    public static void main(String[] args) {
        Father obj=new Son();
        System.out.println(obj.money);
    }
}
//Son 有0
//Son 有4
//2

上面的輸出都是son,這是因爲son在創建的時候,首先隱式調用father的構造,而father構造中堆show的調用是一次虛方法調用,實際執行的是son類的show方法,所以輸出son。而這時候雖然父類的money已經被初始化爲2了,但是show訪問的是子類的money,這時money爲0,因爲它要在子類的構造中才能被初始化。main的最後一句時通過靜態類型訪問到父類的money,所以爲2。

單分派與多分派

方法的接收者與方法的參數統稱爲方法的宗量。根據分派基於多少種宗量,可以將分派劃分爲單分派和多分派。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。

/**
 * 單分派、多分派
 */
public class Dispatch {
    static class A{}
    static class B{}

    public static class Father{
        public void show(A a){
            System.out.println("Father A");
        }
        public void show(B b){
            System.out.println("Father B");
        }
    }

    public static class Son extends Father{
        public void show(A a){
            System.out.println("Son A");
        }
        public void show(B b){
            System.out.println("Son B");
        }
    }

    public static void main(String[] args) {
        Father f=new Father();
        Father son=new Son();
        f.show(new A());
        son.show(new B());
    }
}
//Father A
//Son B

在main中調用了兩次show,這兩次的選擇結果已經在輸出中顯示的很清楚了。首先關注的是編譯階段中編譯器的選擇,也就是靜態分派的過程。這時候選擇方法的依據有兩點:一是靜態類型是Father還是Son,二是方法參數是A還是B。這次的選擇結果可以通過查看字節碼文件得知,生成的兩條指令的參數分別爲常量池中指向Father::show(A)和Father::show(B)的方法。(查看字節碼的常量池得知,#8和#11分別指向參數爲A和B的方法)。

因爲是根據兩個宗量進行選擇,所以Java的靜態分派屬於多分派類型。

再看看運行階段中虛擬機的選擇,也就是動態分派的過程。在執行son.show(B)的方法時,由於編譯器已經決定目標方法的簽名是show(B),虛擬機此時不會關係傳遞過來的參數是什麼,因爲這時候參數的靜態類型、實際類型都對方法的選擇不會構成影響,唯一可以影響虛擬機選擇的因素只有該方法的接收者的實際類型是Father還是Son。因爲只有一個宗量作爲選擇依據,所以Java的動態分派爲單分派類型。

由上可知,java是一門靜態多分派、動態單分派的語言。

虛擬機動態分派的實現

動態分派是執行非常頻繁的動作,而且動態分派的方法版本選擇過程需要運行時再接收者類型的方法元數據中搜索合適的目標方法,因此,Java虛擬機實現基於執行性能的考慮,真正運行時一般不會如此頻繁地去反覆搜索類型元數據。這種情況下,一種基礎而且常見的優化手段是爲類型在方法區中建立一個虛方法表,使用虛方法表索引來代替元數據查找以提高性能。

虛方法表中存放着各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表中的地址和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類虛方法表中的地址也會被替換爲指向子類實現版本的入口地址。如圖,Son重寫了來自Father的全部方法,因此Son的方法表中沒有指向Father類型數據的箭頭。但是Son和Father都沒有重寫來自Object的方法,所以它們的方法表中所有從Object繼承來的方法都指向了Object的數據類型。

爲了程序實現方便,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一樣的索引序號,這樣當類型變換時,僅需要變更查找的虛方法表,就可以從不同的虛方法表中按索引轉換出所需的入口地址。虛方法表一般在類加載的連接階段進行初始化,準備了類的變量初始值後,虛擬機會把該類的虛方法表也一同初始完畢。

最後

在文章的最後作者爲大家整理了很多資料!包括java核心知識點+全套架構師學習資料和視頻+一線大廠面試寶典+面試簡歷模板+阿里美團網易騰訊小米愛奇藝快手嗶哩嗶哩面試題+Spring源碼合集+Java架構實戰電子書等等!
全部免費分享給大家,有需要的朋友歡迎關注公衆號:前程有光,領取!

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