虛函數表和虛函數指針是什么/在哪里


為什么bs虛函數表的地址(int*)(&bs)與虛函數地址(int*)*(int*)(&bs) 不是同一個?

class base { virtual void f1() {} };
 
作者:RednaxelaFX
鏈接:https://www.zhihu.com/question/27459122/answer/36736246
來源:知乎
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。

題主的問題在 《Inside the C++ Object Model》 里有完美解答。這本書必讀。
另外放個相關問題的例子的傳送門:一道阿里實習生筆試題的疑惑? - RednaxelaFX 的回答

C++規范並沒有規定虛函數的實現方式。不過大部分C++實現都用虛函數表(vtable)來實現虛函數的分派。特別是對單繼承的情況,大家的實現都比較接近;對多繼承的情況可能需要多層虛函數表,這個大家有不少發揮空間。

下面給個單繼承情況下常見C++實現的布局的例子。

(代碼用了C++11的語法,不影響內容)
#include <string> #include <iostream> class Object { int identity_hash_; public: Object(): identity_hash_(std::rand()) { } int IdentityHashCode() const { return identity_hash_; } virtual int HashCode() { return IdentityHashCode(); } virtual bool Equals(Object* rhs) { return this == rhs; } virtual std::string ToString() { return "Object"; } }; class MyObject : public Object { int dummy_; public: int HashCode() override { return 0; } std::string ToString() override { return "MyObject"; } }; int main() { Object o1; MyObject o2; std::cout << o2.ToString() << std::endl << o2.IdentityHashCode() << std::endl << o2.HashCode() << std::endl; } /*  Object vtable  -16 [ offset to top ] __si_class_type_info  -8 [ typeinfo Object ] --> +0 [ ... ] --> +0 [ vptr ] --> +0 [ &Object::HashCode ]  +8 [ identity_hash_ ] +8 [ &Object::Equals ]  +12 [ (padding) ] +16 [ &Object::ToString ]  MyObject vtable  -16 [ offset to top ] __si_class_type_info  -8 [ typeinfo MyObject ] --> +0 [ ... ] --> +0 [ vptr ] --> +0 [ &MyObject::HashCode ]  +8 [ identity_hash_ ] +8 [ &Object::Equals ]  +12 [ dummy_ ] +16 [ &MyObject::ToString ] */ 

這個布局是在64位(LP64)的Mac OS X上Clang++用的。我沒有禁用RTTI,所以在vtable的開頭還有一個隱藏字段存着類型的typeinfo指針。C++的RTTI雖然畢竟弱,但好歹也算是一種反射的實現;每個編譯器會自己實現藏在std::type_info背后的反射用數據結構。
“offset-to-top”在多繼承的情況下有用,不過編譯器為了方便實現也可以在單繼承的時候用同樣的結構,把值填為0就不影響語義了。

即便在這種超級簡單的單繼承的情況下,不同C++實現可以在細節上發揮的空間還是相當多。
例如:
  • 對象的vptr是位於對象的+0偏移量,還是位於別的(例如負偏移量,-8之類)
  • vtable里是否存在typeinfo。如果關掉RTTI功能的話就沒特別的必要存typeinfo了。
  • 如果vtable里有存typeinfo,它位於什么偏移量,是+0還是別的(例如負偏移量,-8之類)
  • 還有一個很微妙的:一般C++實現vtable里放發是虛函數的入口地址,該地址直接可調用;但也不排除奇葩實現從vtable項出發要再經過幾層間接才能訪問到真正的入口地址…這種做法在C++實現中不常見,但在VM實現中卻挺常見的。下面再舉例說。

在多繼承和虛擬繼承的情況下虛函數表要如何組織就有趣了…這里不想展開,題主請讀《Inside the C++ Object Model》吧。
對象內有多個vptr、使用多層vtable是常見做法;有些實現把這種多層vtable叫做VTT(vtable table)。

===============================================================

GCC的文檔寫道:
Most platforms have a well-defined ABI that covers C code, but ABIs that cover C++ functionality are not yet common.
Starting with GCC 3.2, GCC binary conventions for C++ are based on a written, vendor-neutral C++ ABI that was designed to be specific to 64-bit Itanium but also includes generic specifications that apply to any platform. This C++ ABI is also implemented by other compiler vendors on some platforms, notably GNU/Linux and BSD systems. We have tried hard to provide a stable ABI that will be compatible with future GCC releases, but it is possible that we will encounter problems that make this difficult. Such problems could include different interpretations of the C++ ABI by different vendors, bugs in the ABI, or bugs in the implementation of the ABI in different compilers. GCC's -Wabi switch warns when G++ generates code that is probably not compatible with the C++ ABI.

Clang也同樣在Linux和BSD系系統(包括Mac OS X的Darwin)實現Itanium C++ ABI,而在Windows上為了跟MSVC兼容實現MSVC C++ ABI

那么這個Itanium C++ ABI到底是怎樣的?這里有一份文檔草案:Itanium C++ ABI
其中這段描述了非POD類的實例的布局:2.4 Non-POD Class Types
而這段描述了vtable的局部,包括多繼承情況下的布局:2.5 Virtual Table Layout

只要把這篇文檔讀了就可以知道GCC和Clang的C++ ABI。足夠解答題主的疑問。

===============================================================

MSVC的C++ ABI我不知道有啥特別詳細的文檔。有時候好奇會去看Clang所實現的MSVC C++ ABI是怎樣的;Clang的開發者們肯定對這此有很多逆向經驗了。

===============================================================

順帶一提一些JVM以及CLR對單繼承虛方法的實現。

基於類的面向對象、類在運行時結構不可變、類繼承只有單繼承、虛函數只能單分派的編程語言里,利用vtable實現虛函數/虛方法分派是很常見的技巧(不過不一定是首選技巧)。

有些同學可能被忽悠過說Java啊C#之類的沒有虛函數表。實際上高性能的JVM和CLR實現都還是有用虛函數表來實現虛方法分派。畢竟主要是單繼承的類體系。
(也確實存在完全不使用vtable的JVM實現。通常這種是特別糾結空間開銷的JVM,例如為低端嵌入式設備設計的JVM。這些略奇葩嗯。)

HotSpot VM:(以JDK8的、64位、不開壓縮指針為例)

          instanceOopDesc
--> +0  [ _mark          ]          InstanceKlass
    +8  [ _klass         ] --> +0   [ ...       ]
    +16 [ ... fields ... ]     +8   [ ...       ]
                               ...  [ ...       ]
                               +n   [ vtable[0] ]
                               +n+8 [ vtable[1] ]
                               ...  [ vtable... ]
                               +m   [ itable[0] ]
                               +m+8 [ itable[1] ]
                               ...  [ itable... ]

對象的頭16字節是“對象頭”,對象頭的第二個字段是跟vptr等價的一個指針,指向InstanceKlass。
InstanceKlass的開頭是一大堆固定長度的元數據,主要記錄該類型的反射相關的信息;末尾包含該類型的vtable和itable(接口虛方法表)。具體結構這里就不說了,有興趣的同學歡迎另外開問題。

可以看到,HotSpot VM使用了vtable來實現單繼承虛方法分派,但是對應vptr的字段並不在對象的+0偏移量而在+4(32位)或+8(64位)偏移量上;對應vtable的數據結構也不在InstanceKlass的+0偏移量開始,而是在一大塊定長的數據之后掛在末尾。

HotSpot VM里,只有虛方法(非private的實例方法)會出現在vtable里;非虛方法(靜態方法或private實例方法)則不會出現在vtable里。

HotSpot VM的itable(Interface Table)跟C++一些實現的VTT思路相似,也是多層vtable。畢竟要解決的問題一樣——多繼承的虛方法分派。

HotSpot VM的vtable項不是指向“可調用的方法入口的指針”,而是一個Method*。Method對象含有Java方法的元數據,其中一個字段才是真正的“可調用的方法入口”。所以說HotSpot VM的vtable雖然作用跟C++類似,但訪問的間接層比C++要多一層。

CLR:(以32位CLRv2在x86上為例)

             Object
    -4 [ m_SyncBlockValue ]              MethodTable
--> +0 [ m_pMethTab       ] --> +0   [ ...            ]
    +4 [ ... fields ...   ]     +4   [ ...            ]
                                ...  [ ...            ]     EEClass
                                +n   [ m_pEEClass     ] --> [ ... ]
                                ...  [ ...            ]
                                +m   [ m_pDispatchMap ]
                                +m+4 [ vtable[0]      ]
                                +m+8 [ vtable[1]      ]
                                ...  [ vtable...      ]
                                +x   [ dispatchmap[0] ]
                                ...  [ dispatchmap... ]

CLRv2這種對象布局方式跟前面提到的C++的例子非常相似。
對象的+0偏移量上存着跟vptr等價的類型指針,指向MethodTable。
MethodTable里主要存着一些涉及代碼執行、分派還有GC所需的數據;這些數據通常比較熱。它的開頭是一塊定長的數據,末尾掛着可變長的vtable和DispatchMap(相當於itable)。
EEClass存着類型的反射相關元數據;這些數據相對MethodTable里的相對來說比較冷,所以把類型數據分離為兩個對象。EEClass對應到前面C++的例子就是typeinfo,只不過前者包含的反射信息遠多於后者。

CLRv2的MethodTable + EEClass的作用等於HotSpot VM的InstanceKlass。
相比HotSpot VM,CLRv2的MethodTable稍微更純粹一些,更接近C++那種vtable,而把跟執行關系不大的、反射相關的元數據挪到一個單獨的對象里。

MethodTable內嵌的vtable可以看作兩部分:前半部分跟C++的常見實現類似,按順序排列虛方法;后半部分則排列着該類型所定義的非虛方法。也就是說一個類型所定義的所有方法都會在對應的MethodTable的vtable里出現,但只有前半部分參與虛方法分派。

MethodTable里的vtable項跟C++的類似,是方法的“可調用方法入口”。
JIT編譯過的方法就會有真正可調用的方法入口;但在CLRv2上,除非一個方法有被NGen或者是native方法,不然它得等到第一次被調用的時候才會被JIT編譯。某個方法在被JIT編譯前,其對應的MethodTable的vtable項會是一個pre-JIT stub,用於實現“JIT編譯的觸發”。
請參考另一個回答:什么是樁代碼(Stub)? - RednaxelaFX 的回答

CLRv4里對象布局有細微變化

Sun Classic VM:(以32位Sun JDK 1.0.2在x86上為例)

         HObject             ClassObject
                       -4 [ hdr            ]
--> +0 [ obj     ] --> +0 [ ... fields ... ]
    +4 [ methods ] \
                    \         methodtable            ClassClass
                     > +0  [ classdescriptor ] --> +0 [ ... ]
                       +4  [ vtable[0]       ]      methodblock
                       +8  [ vtable[1]       ] --> +0 [ ... ]
                       ... [ vtable...       ]

這是一種使用“句柄”(handle)的對象模型。Java的引用實現為指向handle的指針,通過handle進一步訪問對象的實例字段或虛方法表。除了handle和GC的實現外,所有對Java對象的訪問都必須通過handle這個間接層,而不能使用直接指向對象實例的指針。對象的字段內容存儲在ClassObject類型的結構體里。
上圖中的HObject是對java.lang.Object及大部分其它類實例的handle;數組實例和少量特殊類的實例有特殊的handle實現,這里不展開講。

所有實例方法都會出現在methodtable的vtable里。
ClassObject的對象頭hdr主要用於存儲這個ClassObject的大小,便於實現GC堆的前向線性遍歷。

這種做法下,handle是固定大小的,而包含對象實例字段的ClassObject是可變長的。兩者被分配在不同的區域:handle區與對象區。Handle區不會碎片化,因為所有handle都一樣大;這樣對handle的自動內存管理只需要用mark-sweep而不需要移動這些handle,那么handle的地址就是固定的,指向它的指針也就是穩定的。
反之,對象區里的對象不一定一樣大,有可能在長時間運行、反復分配和釋放內存之后出現碎片化,所以需要偶爾做compaction來消除碎片化,於是對象就有可能移動。

優點:
通過固定地址的handle去指向可變地址的對象實例數據,這個額外的間接層允許Sun Classic VM實現保守式的mark-sweep/compact GC,也就是說就算不能精確知道哪些數據是Java對象的引用,也可以安全地移動對象。這是一種相當偷懶的做法。

缺點:
顯然,相比使用直接指針,這種使用handle的做法多了一個間接層。它的效率實在不太好,無論是空間效率(handle占用了額外的空間)還是時間效率(訪問對象字段和虛方法分派等操作)都比使用直接指針的做法差。
具體到Sun Classic VM的handle的具體設計,有趣的一點是它把methodtable的指針放在handle里而不是跟對象實例數據放在一起。這樣的“fat handle”設計至少執行效率上比只包含對象實例指針的handle要快一些——前者訪問虛方法表用兩次間接:handle->methods->vtable[index];后者則要三次間接:handle->obj->methods->vtable[index]。

這種布局跟《Inside the C++ Object Model》Section 1.1的“A Table-driven Object Model”(Fig. 1.2)方案非常相似。略奇葩。
有趣的是采用這種布局的還不只有這個Sun Classic VM,還有:

值得一提的是,CLR的對象模型追根溯源可以追到這個Sun Classic VM上。
微軟從Sun獲得了Java的授權,並通過授權得到了Sun JDK 1.0.x的源碼,以此為基礎開發了MSJVM。
MSJVM為了改善性能,把Sun Classic VM的HObject和ClassObject合並回到一起,改用直接指針實現Java對象的引用。對象模型被改造成了這樣:

             Object
    -4 [ sync block index ]          methodtable         ClassClass
--> +0 [ methods          ] --> +0  [ class desc ] --> +0 [ ... ]
    +4 [ ... fields ...   ]     +4  [ vtable[0]  ]
                                +8  [ vtable[1]  ]
                                ... [ vtable...  ]

是不是看起來跟前面的CLRv2的對象模型看起來非常相似了?

這個歷史的一些片段請參考另一個問題的回答:微軟當年的 J++ 究竟是什么?為什么 Sun 要告它? - RednaxelaFX 的回答

JRockit VM:(以32位JRockit R28在x86上為例)

JRockit x86
            Object                 ClassBlock           Class
--> +0 [ Class block    ] --> +0  [ clazz     ] --> +0 [ ... ]
    +4 [ Lock word      ]     +4  [ ...       ]
    +8 [ ... fields ... ]     ... [ ...       ]
                              +n  [ vtable[0] ]

《Oracle JRockit: The Definitive Guide》第4章第124頁提到了JRockit里的對象布局。
乍一看這跟前面提到的幾個例子很相似。所謂“Class block”指針跟C++的vptr作用類似,而且位於+0偏移量上。
實際上JRockit的ClassBlock里包含的vtable/itable設計有許多精妙的地方,采用了雙向布局,其itable實現了constant time lookup。可惜沒有任何公開文檔描述它,所以這里也沒辦法展開講。

JRockit VM:(以32位JRockit R28在SPARC上為例)

            Object
--> +0 [ Lock word      ]           ClassBlock           Class
    +4 [ Class block    ] -->  +0  [ clazz     ] --> +0 [ ... ]
    +8 [ ... fields ... ]      +4  [ ...       ]
                               ... [ ...       ]
                               +n  [ vtable[0] ]

上面提到JRockit的vtable/itable設計沒有公開文檔詳細描述,那這里為啥要舉這個例子?
因為JRockit在SPARC上實現的對象頭跟在x86上的順序相反,挺有趣的。這演示了就算是同一個名字的VM,在不同平台或者說不同條件下也可能有不同的實現。
SPARC上的JRockit的“Class block”字段就不是對象頭的第一個字段,而是第二個。

無獨有偶,類似的差異設計在Maxine VM里也存在:Objects - Maxine VM

Sable VM

SableVM是由加拿大的McGill大學研發的研究性JVM。它實現了許多有趣的概念。
這里相關的兩個概念是:雙向對象布局(bidirectional object layout)和稀疏接口方法分派表(sparse interface method dispatch table)。

Sable VM的雙向對象布局把所有引用類型字段放在對象頭的一側,而把原始類型字段放在對象頭的另一側。像這樣的感覺:

         _svmt_object_instance_struct
    -8  [ ... reference field ...     ]
    -4  [ reference field 0           ]
--> +0  [ lockword                    ]     _svmt_vtable  _svmt_type_info
    +4  [ vtable                      ] --> +0 [ type ] --> +0 [ ... ]
    +8  [ non-reference field 0       ]     +4 [ ...  ]
    +12 [ ... non-reference field ... ]

引用類型的字段全部在相對於對象頭的負偏移量上,而非引用類型(原始類型)字段則全部在正偏移量上。這樣,在實現類繼承的時候,可以保證以下兩點同時滿足:
  • 在同一個繼承鏈上,同一個字段總是在同一個偏移量上
  • 所有引用類型字段都在連續的內存塊里

於是GC掃描對象中的引用時就可以很高效地掃描連續的內存塊。
上圖更直觀。傳統設計可能如下:


而Sable VM的雙向對象布局則是:

(圖片引用自 SableVM: A Research Framework for the Efficient Execution of Java Bytecode

 

Sable VM的vtable設計也很精妙。跟對象布局相似也采用雙向布局,正向(正偏移量)上放的是跟C++實現類似的vtable,而負偏移量上放的是稀疏的接口方法分派表,如圖:

 

(圖片引用自 SableVM: A Research Framework for the Efficient Execution of Java Bytecode

 

先寫這么多。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM