Java調用重載方法(invokevirtual)和接口方法(invokeinterface)的解析

    多態,作爲面向對象的重要概念之一,是多數的高級語言都有的特性。C++利用編譯期間確定的虛表的offset來進行虛函數的調用,從而實現多態。雖然性能高效,但在升級時很容易造成二進制兼容性的問題。Java則在編譯期確定的函數簽名,通過全局符號表的定位,從而在運行期間再確定真正的虛表索引,來實現多態。經過解析後會把index存放到cache裏爲下次調用加速。這樣就減少了由於索引的更改帶來的二進制兼容的問題。

 

C++

    在C++裏,大多數的編譯器,對於調用重載的虛方法,都保留在類的一個叫做虛表(vtable)的地方,這個虛表其實是一個函數指針數組,函數是從父類的虛方法到子類的虛方法按照順序排序,如果子類重載了基類的虛方法,則會覆蓋父類的虛函數的指針。如:

Class BBase
{
    virtual f1() { printf("Base::f1!"); }
    virtual f2(int i) = 0;
}
 
Class B : publicBBase
{
    Virtual f2(int i) { printf(“f2!”); }
   
    Virtual e1() { printf(“e1”); }
}

    類B的內存大致爲:


    當編譯器遇到調用虛方法的代碼時,是通過vtable指針以及對應方法在虛表裏的offset,然後獲取對應的函數指針實現的,由於offset在編譯過程就已經固定了,這樣在執行過程中幾乎沒有產生任何額外的計算就實現了多態調用,效率相當高。

    但凡事都有兩面性,這樣的做法就有較大的缺陷,如組件升級時的二進制兼容性帶來了很大的麻煩。假設在A.dll的類A調用了在B.dll裏的類B的一個虛方法,如果此時由於需求更改,我們需要對類B增加了虛方法,但不幸地,我們的修改不小心導致了原來的虛方法的offset產生了變化(如果是VS編譯器,則有一些修改的原則可以避免),那麼此時在運行A.dll裏的類A則會產生無法預知的後果(有可能調用了類B的其它虛方法,但此時堆棧會被破壞,最終還是會崩潰,而且崩潰堆棧會很讓人費解)。

 

Java

    在Java裏,則可以不用擔心像C++的虛方法修改所帶來offset影響的問題(除非你把原來的虛方法刪除或者修改了簽名)。首先,Java同樣也有vtable和offset的概念,並且最終也是通過在虛表的索引來獲取最終調用函數的地址,但不同的是,Java並不是在編譯過程中就確定了vtable的offset(暫時忽略非重載方法的調用invokestatic/invokespecial)。

    假設有這樣的調用:

BBase base = BBase.getBase();
base.f1();

    Java每個class文件都有一個常量池的概念,主要是關於類、方法、接口等中的常量,也包括字符串常量和符號引用。Java在調用虛函數的地方都保留了調用函數簽名字符值,包括函數的返回值、函數名、參數列表。這些字符值都存放到class文件的常量池中。然後生成對應的字節碼,對於普通的虛方法,則是invokevirtual。另外class文件裏的類本身定義的虛函數的函數簽名也會保留到常量池中。

    加載類

   


    在加載該類的時候,常量池的所有虛函數的簽名(包括調用的以及自身定義的)都會添加到全局的符號表(事實上是一個HashTable)。首先對字符值進行Hash值計算,然後在全局HashTable進行查找,如果發現已經存在對應的Hash值,則返回對應的符號指針Symbol *,否則創建新的Symbol並添加到HashTable中,然後返回新創建的Symbol *。這樣常量池就把字符串的引用轉換成符號的引用。另外這個過程可以確保所有字符串在jvm只存有一個引用。

    第一次調用方法



    然後當在某個類對象調用虛方法的時候,通過調用函數的符號和自身定義的符號進行比較(由於這裏都是引用全局符號表的唯一符號,因此可以通過內存地址進行快速比較),就會解析出調用虛函數的信息,通過信息就可以獲取虛表的索引,然後調用對應的虛函數字節碼。另外,爲了提高調用時的性能,Java採用的是Lazy解析,第一次解析出虛表的索引後,則會保留到cache裏面,這樣下次調用就可以從緩存直接獲取索引。

    不過,如果是調用接口,則需要每次都要進行解析來獲取索引。這是由於Java可以實現多個接口,不同的類可能會實現了多個或者不同的接口,在虛表裏該接口所實現方法的索引會不一致。這樣每次解析的虛表索引都可能會不同,因此不能進行緩存,需要每次都進行重新的解析。因此,接口的方法調用會比普通的子類繼承的虛函數調用要慢。另外,爲了表現接口調用的不同解析做法,JVM會插入另外的字節碼invokeinterface來指示需要每次調用解析。

    源碼路徑

 有興趣查看具體實現的同學可以下載openjdk的源碼查看jvm的具體C++實現。路徑在openjdk/hotspot/src下。

 從BytecodeInterpreter::run()方法的CASE(_invokevirtual):開始。重點是LinkResolver::resolve_invokevirtual()方法,具體裏面會調用到resolve_pool(在常量池解析索引到Symbol*)和resolve_virtual_call(解析具體的類方法,獲取虛表索引)。

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