開發兩年!JVM方法調用都玩不明白,你離被炒魷魚不遠了!

前言

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

解析

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

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

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

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

分派

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

靜態分派

/**
 * 靜態分派
 */
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

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

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架構實戰電子書等等!所有免費分享給你們,有須要的朋友歡迎關注公衆號:前程有光,領取!

相關文章
相關標籤/搜索