1. 程式人生 > 實用技巧 >虛擬機器位元組碼執行引擎(二)

虛擬機器位元組碼執行引擎(二)

方法呼叫

方法呼叫並不等同於方法中的程式碼被執行,方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法),暫時還未涉及方法內部的具體執行過程。在程式執行時,進行方法呼叫是最普遍頻繁的操作之一,之前已經講過,Class檔案的編譯過程中不包含傳統程式語言編譯的連線步驟,一切方法呼叫在Class檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體佈局中的入口地址(也就是之前說的直接引用)。這個特性給Java帶來了更強大的動態擴充套件能力,但也使得Java方法呼叫過程變得相對複雜,某些呼叫需要在類載入期間,甚至到執行期間才能確定目標方法的直接引用。

所有方法呼叫的目標方法在Class檔案裡面都是一個常量池中的符號引用,在類載入的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能夠成立的前

提是: 方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。換句話說,呼叫目標在程式程式碼寫好、 編譯器進行編譯那一刻就已經確定下來。這類方法的呼叫被稱為解析(Resolution)。

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

呼叫不同型別的方法,位元組碼指令集裡設計了不同的指令。在Java虛擬機器支援以下5條方法呼叫位元組碼指令,分別是:

  • invokestatic:用於呼叫靜態方法。
  • invokespecial:用於呼叫例項構造器<init>()方法、 私有方法和父類中的方法。
  • invokevirtual:用於呼叫所有的虛方法。
  • invokeinterface:用於呼叫介面方法,會在執行時再確定一個實現該介面的物件。
  • invokedynamic:先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法。前面4條呼叫指令,分派邏輯都固化在Java虛擬機器內部,而invokedynamic指令的分派邏輯是由使用者設定的引導方法來決定的。

只要能被invokestatic和invokespecial指令呼叫的方法,都可以在解析階段中確定唯一的呼叫版本,Java語言裡符合這個條件的方法共有靜態方法、 私有方法、 例項構造器、 父類方法4種,再加上被final修飾的方法(儘管它使用invokevirtual指令呼叫),這5種方法呼叫會在類載入的時候就可以把符號引用解析為該方法的直接引用。這些方法統稱為“非虛方法”(Non-Virtual Method),與之相反,其他方法就被稱為“虛方法”(Virtual Method)。

程式碼8-5演示了一種常見的解析呼叫的例子,該樣例中,靜態方法sayHello()只可能屬於型別StaticResolution,沒有任何途徑可以覆蓋或隱藏這個方法。

程式碼8-5

public class StaticResolution {

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

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

  

使用javap命令檢視這段程式對應的位元組碼,會發現的確是通過invokestatic命令來呼叫sayHello()方法,而且其呼叫的方法版本已經在編譯時就明確以常量池項的形式固化在位元組碼指令的引數之中:

>javap -verbose StaticResolution
……
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=1, args_size=1
         0: invokestatic  #5                  // Method sayHello:()V
         3: return
      LineNumberTable:
        line 9: 0
        line 10: 3

      

Java中的非虛方法除了使用invokestatic、invokespecial呼叫的方法之外還有一種,就是被final修飾的例項方法。雖然由於歷史設計的原因,final方法是使用invokevirtual指令來呼叫的,但是因為它也無法被覆蓋,沒有其他版本的可能,所以也無須對方法接收者進行多型選擇,又或者說多型選擇的結果肯定是唯一的。在《Java語言規範》中明確定義了被final修飾的方法是一種非虛方法。

解析呼叫一定是個靜態的過程,在編譯期間就完全確定,在類載入的解析階段就會把涉及的符號引用全部轉變為明確的直接引用,不必延遲到執行期再去完成。而另一種主要的方法呼叫形式:分派(Dispatch)呼叫則要複雜許多,它可能是靜態的也可能是動態的,按照分派依據的宗量數可分為單分派和多分派。這兩類分派方式兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派4種分派組合情況,下面我們來看看虛擬機器中的方法分派是如何進行的。

為了解釋靜態分派和過載(Overload) ,我們來看程式碼8-6。

程式碼8-6

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的過載版本呢?在解決這個問題之前,我們先通過如下程式碼來定義兩個關鍵概念:

Human man = new Man();

  

我們把上面程式碼中的“Human”稱為變數的“靜態型別”(Static Type),或者叫“外觀型別”(Apparent Type),後面的“Man”則被稱為變數的“實際型別”(Actual Type)或者叫“執行時型別”(Runtime Type)。靜態型別和實際型別在程式中都可能會發生變化,區別是靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的; 而實際型別變化的結果在執行期才可確定,編譯器在編譯程式的時候並不知道一個物件的實際型別是什麼。如果還有人不太理解,我們不妨通過一段實際例子來解釋,譬如有下面的程式碼:

// 實際型別變化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 靜態型別變化
sr.sayHello((Man) human)
sr.sayHello((Woman) human)

  

物件human的實際型別是可變的,編譯期間無從得知實際型別到底是Man還是Woman,必須等到程式執行到這行的時候才能確定。而human的靜態型別是Human,也可以在使用時(如sayHello()方法中的強制轉型)臨時改變這個型別,但這個改變仍是在編譯期是可知的,兩次sayHello()方法的呼叫,在編譯期完全可以明確轉型的是Man還是Woman。

解釋清楚了靜態型別與實際型別的概念,我們就把話題再轉回到程式碼8-6的樣例程式碼中。main()裡面的兩次sayHello()方法呼叫,在方法接收者已經確定是物件“sr”的前提下,使用哪個過載版本,就完全取決於傳入引數的數量和資料型別。程式碼中故意定義了兩個靜態型別相同,而實際型別不同的變數,但虛擬機器(或者準確地說是編譯器)在過載時是通過引數的靜態型別而不是實際型別作為判定依據的。由於靜態型別在編譯期可知,所以在編譯階段,Javac編譯器就根據引數的靜態型別決定了會使用哪個過載版本,因此選擇了sayHello(Human)作為呼叫目標,並把這個方法的符號引用寫到main()方法裡的兩條invokevirtual指令的引數中。

所有依賴靜態型別來決定方法執行版本的分派動作,都稱為靜態分派。靜態分派的最典型應用表現就是方法過載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機器來執行的,這點也是為何一些資料選擇把它歸入“解析”而不是“分派”的原因。需要注意Javac編譯器雖然能確定出方法的過載版本,但在很多情況下這個過載版本並不是“唯一”的,往往只能確定一個“相對更合適的”版本。這種模糊的結論在由0和1構成的計算機世界中算是個比較稀罕的事件,產生這種模糊結論的主要原因是字面量天生的模糊性,它不需要定義,所以字面量就沒有顯式的靜態型別,它的靜態型別只能通過語言、 語法的規則去理解和推斷。程式碼8-7演示了何謂“更加合適的”版本。

程式碼8-7

import java.io.Serializable;

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(字元'a'的Unicode數值為十進位制數字97),因此引數型別為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,所以匹配到了引數型別為Character的過載,繼續註釋掉sayHello(Character arg)方法,那輸出會變為:

hello Serializable

  

出現hello Serializable,是因為java.lang.Serializable是java.lang.Character類實現的一個介面,當自動裝箱之後發現還是找不到裝箱類,但是找到了裝箱類所實現的介面型別,所以緊接著又發生一次自動轉型。char可以轉型成int,但是Character是絕對不會轉型為Integer的,它只能安全地轉型為它實現的介面或父類。Character還實現了另外一個介面java.lang.Comparable<Character>,如果同時出現兩個引數分別為Serializable和Comparable<Character>的過載方法,那它們在此時的優先順序是一樣的。編譯器無法確定要自動轉型為哪種型別,會提示“型別模糊”(Type Ambiguous),並拒絕編譯。程式必須在呼叫時顯式地指定字面量的靜態型別,如: sayHello((Comparable<Character>)'a'),才能編譯通過。但是如果有人願意花費一點時間,繞過Javac編譯器,自己去構造出表達相同語義的位元組碼,將會發現這是能夠通過Java虛擬機器的類載入校驗,而且能夠被Java虛擬機器正常執行的,但是會選擇Serializable還是Comparable<Character>的過載方法則並不能事先確定,這是《Java虛擬機器規範》 所允許的。

下面繼續註釋掉sayHello(Serializable arg)方法,輸出會變為:

hello Object

  

這時是char裝箱後轉型為父類了,如果有多個父類,那將在繼承關係中從下往上開始搜尋,越接上層的優先順序越低。即使方法呼叫傳入的引數值為null時,這個規則仍然適用。我們把sayHello(Objectarg)也註釋掉,輸出將會變為:

hello char ...

  

7個過載方法已經被註釋得只剩1個了,可見變長引數的過載優先順序是最低的,這時候字元'a'被當作了一個char[]陣列的元素。這裡使用的是char型別的變長引數,大家在驗證時還可以選擇int型別、Character型別、Object型別等的變長引數過載來把上面的過程重新折騰一遍。但是要注意的是,有一些在單個引數中能成立的自動轉型,如char轉型為int,在變長引數中是不成立的。

程式碼8-7演示了編譯期間選擇靜態分派目標的過程,這個過程也是Java語言實現方法過載的本質。演示所用的這段程式無疑是屬於很極端的例子,除了用作面試題為難求職者之外,在實際工作中幾乎不可能存在任何有價值的用途,這裡僅僅是用於講解過載時目標方法選擇的過程。無論對過載的認識有多麼深刻,一個合格的程式設計師都不應該在實際應用中寫這種晦澀的過載程式碼。

這裡講述的解析與分派這兩者之間的關係並不是二選一的排他關係,它們是在不同層次上去篩選、確定目標方法的過程。例如前面說過靜態方法會在編譯期確定、在類載入期就進行解析,而靜態方法顯然也是可以擁有過載版本的,選擇過載版本的過程也是通過靜態分派完成的。

瞭解了靜態分派,我們接下來看一下Java語言裡動態分派的實現過程,它與Java語言多型性的另外一個重要體現——重寫(Override)有著很密切的關聯。我們還是用前面的Man和Woman一起sayHello的例子來講解動態分派,請看程式碼8-8中所示的程式碼。

程式碼8-8

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();
    }
}

  

顯然這裡選擇呼叫的方法版本是不可能再根據靜態型別來決定的,因為靜態型別同樣都是Human的兩個變數man和woman在呼叫sayHello()方法時產生了不同的行為,甚至變數man在兩次呼叫中還執行了兩個不同的方法。導致這個現象的原因很明顯,是因為這兩個變數的實際型別不同,Java虛擬機器是如何根據實際型別來分派方法執行版本的呢? 我們使用javap命令輸出這段程式碼的位元組碼,嘗試從中尋找答案,輸出結果如程式碼8-9所示。

程式碼8-9

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

  

0~15行的位元組碼是準備動作,作用是建立man和woman的記憶體空間、 呼叫Man和Woman型別的例項構造器,將這兩個例項的引用存放在第1、 2個區域性變量表的變數槽中,這些動作實際對應了Java原始碼中的這兩行:

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

  

接下來的16~21行是關鍵部分,16和20行的aload指令分別把剛剛建立的兩個物件的引用壓到棧頂,這兩個物件是將要執行的sayHello()方法的所有者,稱為接收者(Receiver);17和21行是方法呼叫指令,這兩條呼叫指令單從位元組碼角度來看,無論是指令(都是invokevirtual)還是引數(都是常量池中第6項的常量,註釋顯示了這個常量是Human.sayHello()的符號引用)都完全一樣,但是這兩句指令最終執行的目標方法並不相同。那看來解決問題的關鍵還必須從invokevirtual指令本身入手,要弄清楚它是如何確定呼叫方法版本、 如何實現多型查詢來著手分析才行。根據《Java虛擬機器規範》,invokevirtual指令的執行時解析過程大致分為以下幾步:

  1. 找到運算元棧頂的第一個元素所指向的物件的實際型別,記作C。
  2. 如果在型別C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查詢過程結束;不通過則返回java.lang.IllegalAccessError異常。
  3. 否則,按照繼承關係從下往上依次對C的各個父類進行第二步的搜尋和驗證過程。
  4. 如果始終沒有找到合適的方法,則丟擲java.lang.AbstractMethodError異常。

正是因為invokevirtual指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中的invokevirtual指令並不是把常量池中方法的符號引用解析到直接引用上就結束了,還會根據方法接收者的實際型別來選擇方法版本,這個過程就是Java語言中方法重寫的本質。我們把這種在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。

既然這種多型性的根源在於虛方法呼叫指令invokevirtual的執行邏輯,那自然我們得出的結論就只會對方法有效,對欄位是無效的,因為欄位不使用這條指令。事實上,在Java裡面只有虛方法存在,欄位永遠不可能是虛的,換句話說,欄位永遠不參與多型,哪個類的方法訪問某個名字的欄位時,該名字指的就是這個類能看到的那個欄位。當子類聲明瞭與父類同名的欄位時,雖然在子類的記憶體中兩個欄位都會存在,但是子類的欄位會遮蔽父類的同名欄位。請閱讀程式碼8-10,思考執行後會輸出什麼結果。

程式碼8-10

public class FieldHasNoPolymorphic {
    static class Father {
        public int money = 1;

        public Father() {
            money = 2;
            showMeTheMoney();
        }

        public void showMeTheMoney() {
            System.out.println("I am Father, i have $" + money);
        }
    }

    static class Son extends Father {
        public int money = 3;

        public Son() {
            money = 4;
            showMeTheMoney();
        }

        public void showMeTheMoney() {
            System.out.println("I am Son, i have $" + money);
        }
    }

    public static void main(String[] args) {
        Father gay = new Son();
        System.out.println("This gay has $" + gay.money);
    }
}

  

執行結果:

I am Son, i have $0
I am Son, i have $4
This gay has $2

  

輸出兩句都是“I am Son”,這是因為Son類在建立的時候,首先隱式呼叫了Father的建構函式,而Father建構函式中對showMeTheMoney()的呼叫是一次虛方法呼叫,實際執行的版本是Son::showMeTheMoney()方法,所以輸出的是“I am Son”。 而這時候雖然父類的money欄位已經被初始化成2了,但Son::showMeTheMoney()方法中訪問的卻是子類的money欄位,這時候結果自然還是0,因為它要到子類的建構函式執行時才會被初始化。main()的最後一句通過靜態型別訪問到了父類中的money,輸出了2。

方法的接收者與方法的引數統稱為方法的宗量, 這個定義最早應該來源於著名的《Java與模式》一書。 根據分派基於多少種宗量, 可以將分派劃分為單分派和多分派兩種。 單分派是根據一個宗量對目標方法進行選擇, 多分派則是根據多於一個宗量對目標方法進行選擇。程式碼8-11中舉了一個Father和Son一起來做出“一個艱難的決定”的例子。

程式碼8-11

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

  

在main()裡呼叫了兩次hardChoice()方法,這兩次hardChoice()方法的選擇結果在程式輸出中已經顯示得很清楚了。我們關注的首先是編譯階段中編譯器的選擇過程,也就是靜態分派的過程。這時候選擇目標方法的依據有兩點:一是靜態型別是Father還是Son,二是方法引數是QQ還是360。這次選擇結果的最終產物是產生了兩條invokevirtual指令,兩條指令的引數分別為常量池中指向Father::hardChoice(360)及Father::hardChoice(QQ)方法的符號引用。因為是根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派型別。

再看看執行階段中虛擬機器的選擇,也就是動態分派的過程。在執行“son.hardChoice(new QQ())”這行程式碼時,更準確地說,是在執行這行程式碼所對應的invokevirtual指令時,由於編譯期已經決定目標方法的簽名必須為hardChoice(QQ),這時候引數的靜態型別、實際型別都對方法的選擇不會構成任何影響,唯一可以影響虛擬機器選擇的因素只有該方法的接受者的實際型別是Father還是Son。因為只有一個宗量作為選擇依據,所以Java語言的動態分派屬於單分派型別。

根據上述論證的結果,我們可以總結一句:如今Java語言是一門靜態多分派、動態單分派的語言。強調“如今的Java語言”是因為這個結論未必會恆久不變,C#在3.0及之前的版本與Java一樣是動態單分派語言,但在C#4.0中引入了dynamic型別後,就可以很方便地實現動態多分派。JDK 10時Java語法中新出現var關鍵字,但請讀者切勿將其與C#中的dynamic型別混淆,事實上Java的var與C#的var才是相對應的特性,它們與dynamic有著本質的區別:var是在編譯時根據宣告語句中賦值符右側的表示式型別來靜態地推斷型別,這本質是一種語法糖; 而dynamic在編譯時完全不關心型別是什麼,等到執行的時候再進行型別判斷。Java語言中與C#的dynamic型別功能相對接近(只是接近,並不是對等的)的應該是在JDK 9時通過JEP 276引入的jdk.dynalink模組,使用jdk.dynalink可以實現在表示式中使用動態型別,Javac編譯器會將這些動態型別的操作翻譯為invokedynamic指令的呼叫點。

按照目前Java語言的發展趨勢,它並沒有直接變為動態語言的跡象,而是通過內建動態語言(如JavaScript)執行引擎、加強與其他Java虛擬機器上動態語言互動能力的方式來間接地滿足動態性的需求。但是作為多種語言共同執行平臺的Java虛擬機器層面上則不是如此,早在JDK 7中實現的JSR-292裡面就已經開始提供對動態語言的方法呼叫支援了,JDK 7中新增的invokedynamic指令也成為最複雜的一條方法呼叫的位元組碼指令。

動態分派是執行非常頻繁的動作,而且動態分派的方法版本選擇過程需要執行時在接收者型別的方法元資料中搜索合適的目標方法,因此,Java虛擬機器實現基於執行效能的考慮,真正執行時一般不會如此頻繁地去反覆搜尋型別元資料。面對這種情況,一種基礎而且常見的優化手段是為型別在方法區中建立一個虛方法表(Virtual Method Table,也稱為vtable,與此對應的,在invokeinterface執行時也會用到介面方法表——Interface Method Table,簡稱itable),使用虛方法表索引來代替元資料查詢以提高效能。我們先看看程式碼清單8-11所對應的虛方法表結構示例,如圖8-3所示。

圖8-3 方法表結構

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

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

上文中提到了查虛方法表是分派呼叫的一種優化手段,由於Java物件裡面的方法預設(即不使用final修飾)就是虛方法,虛擬機器除了使用虛方法表之外,為了進一步提高效能,還會使用型別繼承關係分析(Class Hierarchy Analysis,CHA)、守護內聯(Guarded Inlining)、內聯快取(InlineCache)等多種非穩定的激進優化來爭取更大的效能空間,關於這幾種優化技術的原理和運作過程,會在後續介紹。