1. 程式人生 > >Java呼叫過載方法(invokevirtual)和介面方法(invokeinterface)的解析

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(解析具體的類方法,獲取虛表索引)。