1. 程式人生 > >深入理解JVM之七:靜態分派與動態分派

深入理解JVM之七:靜態分派與動態分派

前言

這裡所謂的分派指的是在Java中對方法的呼叫。Java中有三大特性:封裝、繼承和多型。分派是多型性的體現,Java虛擬機器底層提供了我們開發中“重寫”和“過載”的底層實現。其中過載屬於靜態分派,而重寫則是動態分派的過程。除了使用分派的方式對方法進行呼叫之外,還可以使用解析呼叫,解析呼叫是在編譯期間就已經確定了,在類裝載的解析階段就會把符號引用轉化為直接引用,不會延遲到執行期間再去完成。而分派呼叫則既可以是靜態的也可以是動態(就是這裡的靜態分派和動態分派)的。

1.靜態分派

靜態分派只會涉及過載,而過載是在編譯期間確定的,那麼靜態分派自然是一個靜態的過程(因為還沒有涉及到Java虛擬機器)。靜態分派的最直接的解釋是在過載的時候是通過引數的靜態型別而不是實際型別作為判斷依據的

。比如建立一個類O,在O中建立了靜態類內部類A,O中又有兩個靜態類內部類B、C繼承了這個靜態內部類A,那麼實際上當編寫如下的程式碼:

public class O{
    static class A{}
    static class B extends A{}
    static class C extends A{}
    public void a(A a){
        System.out.println("A method");
    }
    public void a(B b){
        System.out.println("B method"
); } public void a(C c){ System.out.println("C method"); } public static void main(String[] args){ O o = new O(); A b = new B(); A c = new C(); o.a(b); o.a(c); } }

執行的結果是打印出連個“A method”。原因在於靜態型別的變化僅僅在使用時發生,變數本省的型別不會發生變化。比如我們這裡中A b = new B();雖然在建立的時候是B的物件,但是當呼叫o.a(b)的時候才發現是A的物件,所以會輸出“A method”。也就是說在發生過載的時候,Java虛擬機器是通過引數的靜態型別而不是實際引數型別作為判斷依據的。因此,在編譯階段,Javac編譯器選擇了a(A a)這個過載方法。

雖然編譯器能夠在編譯階段確定方法的版本,但是很多情況下過載的版本不是唯一的,在這種模糊的情況下,編譯器會選擇一個更合適的版本。

2.動態分派

動態分派的一個最直接的例子是重寫。對於重寫,我們已經很熟悉了,那麼Java虛擬機器是如何在程式執行期間確定方法的執行版本的呢?

解釋這個現象,就不得不涉及Java虛擬機器的invokevirtual指令了,這個指令的解析過程有助於我們更深刻理解重寫的本質。該指令的具體解析過程如下:

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

    從這個過程可以發現,在第一步的時候就在執行期確定接收物件(執行方法的所有者程稱為接受者)的實際型別,所以當呼叫invokevirtual指令就會把執行時常量池中符號引用解析為不同的直接引用,這就是方法重寫的本質。

3.虛擬機器動態分派的實現

其實上面的敘述已經把虛擬機器重寫與過載的本質講清楚了,那麼Java虛擬機器是如何做到這點的呢?

由於動態分派是非常頻繁的操作,實際實現中不可能真正如此實現。Java虛擬機器是通過“穩定優化”的手段——在方法區中建立一個虛方法表(Virtual Method Table),通過使用方法表的索引來代替元資料查詢以提高效能。虛方法表中存放著各個方法的實際入口地址(由於Java虛擬機器自己建立並維護的方法表,所以沒有必要使用符號引用,那不是跟自己過不去嘛),如果子類沒有覆蓋父類的方法,那麼子類的虛方法表裡面的地址入口與父類是一致的;如果重寫父類的方法,那麼子類的方法表的地址將會替換為子類實現版本的地址。

方法表是在類載入的連線階段(驗證、準備、解析)進行初始化,準備了子類的初始化值後,虛擬機器會把該類的虛方法表也進行初始化。