深刻探索.NET內部瞭解CLR如何建立運行時對象

前言

  • SystemDomain, SharedDomain, and DefaultDomain。
  • 對象佈局和內存細節。
  • 方法表佈局。
  • 方法分派(Method dispatching)。

由於公共語言運行時(CLR)即將成爲在Windows上建立應用程序的主角級基礎架構, 多掌握點關於CLR的深度認識會幫助你構建高效的, 工業級健壯的應用程序. 在這篇文章中, 咱們會瀏覽,調查CLR的內在本質, 包括對象實例佈局, 方法表的佈局, 方法分派, 基於接口的分派, 和各類各樣的數據結構.程序員

咱們會使用由C#寫成的很是簡單的代碼示例, 因此任何對編程語言的隱式引用都是以C#語言爲目標的. 討論的一些數據結構和算法會在Microsoft® .NET Framework 2.0中改變, 可是絕大多數的概念是不會變的. 咱們會使用Visual Studio® .NET 2003 Debugger和debugger extension Son of Strike (SOS)來窺視一些數據結構. SOS可以理解CLR內部的數據結構, 可以dump出有用的信息. 通篇, 咱們會討論在Shared Source CLI(SSCLI)中擁有相關實現的類, 你能夠從 http://msdn.microsoft.com/net/sscli 下載到它們.web

圖表1 會幫助你在搜索一些結構的時候到SSCLI中的信息.算法

ITEM SSCLI PATH
AppDomain sscliclrsrcvmappdomain.hpp
AppDomainStringLiteralMap sscliclrsrcvmstringliteralmap.h
BaseDomain sscliclrsrcvmappdomain.hpp
ClassLoader sscliclrsrcvmclsload.hpp
EEClass sscliclrsrcvmclass.h
FieldDescs sscliclrsrcvmfield.h
GCHeap sscliclrsrcvmgc.h
GlobalStringLiteralMap sscliclrsrcvmstringliteralmap.h
HandleTable sscliclrsrcvmhandletable.h
InterfaceVTableMapMgr sscliclrsrcvmappdomain.hpp
Large Object Heap sscliclrsrcvmgc.h
LayoutKind sscliclrsrcbclsystemruntimeinteropserviceslayoutkind.cs
LoaderHeaps sscliclrsrcincutilcode.h
MethodDescs sscliclrsrcvmmethod.hpp
MethodTables sscliclrsrcvmclass.h
OBJECTREF sscliclrsrcvmtypehandle.h
SecurityContext sscliclrsrcvmsecurity.h
SecurityDescriptor sscliclrsrcvmsecurity.h
SharedDomain sscliclrsrcvmappdomain.hpp
StructLayoutAttribute sscliclrsrcbclsystemruntimeinteropservicesattributes.cs
SyncTableEntry sscliclrsrcvmsyncblk.h
System namespace sscliclrsrcbclsystem
SystemDomain sscliclrsrcvmappdomain.hpp
TypeHandle sscliclrsrcvmtypehandle.h

在咱們開始前,請注意:本文提供的信息只對在X86平臺上運行的.NET Framework 1.1有效(對於Shared Source CLI 1.0也大部分適用,只是在某些交互操做的狀況下必須注意例外),對於.NET Framework 2.0會有改變,因此請不要在構建軟件時依賴於這些內部結構的不變性。sql

CLR啓動程序(Bootstrap)建立的域

在CLR執行託管代碼的第一行代碼前,會建立三個應用程序域。其中兩個對於託管代碼甚至CLR宿主程序(CLR hosts)都是不可見的。它們只能由CLR啓動進程建立,而提供CLR啓動進程的是shim——mscoree.dll和mscorwks.dll (在多處理器系統下是mscorsvr.dll)。正如 圖2 所示,這些域是系統域(System Domain)和共享域(Shared Domain),都是使用了單件(Singleton)模式。第三個域是缺省應用程序域(Default AppDomain),它是一個AppDomain的實例,也是惟一的有命名的域。對於簡單的CLR宿主程序,好比控制檯程序,默認的域名由可執行映象文件的名字組成。其它的域能夠在託管代碼中使用AppDomain.CreateDomain方法建立,或者在非託管的代碼中使用ICORRuntimeHost接口建立。複雜的宿主程序,好比 ASP.NET,對於特定的網站會基於應用程序的數目建立多個域。編程

圖 2 由CLR啓動程序建立的域 ↓bootstrap

系統域(System Domain)

系統域負責建立和初始化共享域和默認應用程序域。它將系統庫mscorlib.dll載入共享域,而且維護進程範圍內部使用的隱含或者顯式字符串符號。數組

字符串駐留(string interning)是 .NET Framework 1.1中的一個優化特性,它的處理方法顯得有些笨拙,由於CLR沒有給程序集機會選擇此特性。儘管如此,因爲在全部的應用程序域中對一個特定的符號只保存一個對應的字符串,此特性能夠節省內存空間。緩存

系統域還負責產生進程範圍的接口ID,並用來建立每一個應用程序域的接口虛表映射圖(InterfaceVtableMaps)的接口。系統域在進程中保持跟蹤全部域,並實現加載和卸載應用程序域的功能。安全

共享域(Shared Domain)

全部不屬於任何特定域的代碼被加載到系統庫SharedDomain.Mscorlib,對於全部應用程序域的用戶代碼都是必需的。它會被自動加載到共享域中。系統命名空間的基本類型,如Object, ValueType, Array, Enum, String, and Delegate等等,在CLR啓動程序過程當中被預先加載到本域中。用戶代碼也能夠被加載到這個域中,方法是在調用CorBindToRuntimeEx時使用由CLR宿主程序指定的LoaderOptimization特性。控制檯程序也能夠加載代碼到共享域中,方法是使用System.LoaderOptimizationAttribute特性聲明Main方法。共享域還管理一個使用基地址做爲索引的程序集映射圖,此映射圖做爲管理共享程序集依賴關係的查找表,這些程序集被加載到默認域(DefaultDomain)和其它在託管代碼中建立的應用程序域。非共享的用戶代碼被加載到默認域。網絡

默認域(Default Domain)

默認域是應用程序域(AppDomain)的一個實例,通常的應用程序代碼在其中運行。儘管有些應用程序須要在運行時建立額外的應用程序域(好比有些使用插件,plug-in,架構或者進行重要的運行時代碼生成工做的應用程序),大部分的應用程序在運行期間只建立一個域。全部在此域運行的代碼都是在域層次上有上下文限制。若是一個應用程序有多個應用程序域,任何的域間訪問會經過.NET Remoting代理。額外的域內上下文限制信息可使用System.ContextBoundObject派生的類型建立。每一個應用程序域有本身的安全描述符(SecurityDescriptor),安全上下文(SecurityContext)和默認上下文(DefaultContext),還有本身的加載器堆(高頻堆,低頻堆和代理堆),句柄表,接口虛表管理器和程序集緩存。

加載器堆(Loader Heaps)

加載器堆的做用是加載不一樣的運行時CLR部件和優化在域的整個生命期內存在的部件。這些堆的增加基於可預測塊,這樣可使碎片最小化。加載器堆不一樣於垃圾回收堆(或者對稱多處理器上的多個堆),垃圾回收堆保存對象實例,而加載器堆同時保存類型系統。常常訪問的部件如方法表,方法描述,域描述和接口圖,分配在高頻堆上,而較少訪問的數據結構如EEClass和類加載器及其查找表,分配在低頻堆。代理堆保存用於代碼訪問安全性(code access security, CAS)的代理部件,如COM封裝調用和平臺調用(P/Invoke)。

從高層次瞭解域後,咱們準備看看它們在一個簡單的應用程序的上下文中的物理細節,見 圖3。咱們在程序運行時停在mc.Method1(),而後使用SOS調試器擴展命令DumpDomain來輸出域的信息。(請查看 Son of Strike瞭解SOS的加載信息)。這裏是編輯後的輸出:

圖3 Sample1.exe

!DumpDomain
System Domain: 793e9d58, LowFrequencyHeap: 793e9dbc, HighFrequencyHeap: 793e9e14, StubHeap: 793e9e6c, Assembly: 0015aa68 [mscorlib], ClassLoader: 0015ab40 Shared Domain: 793eb278, LowFrequencyHeap: 793eb2dc, HighFrequencyHeap: 793eb334, StubHeap: 793eb38c, Assembly: 0015aa68 [mscorlib], ClassLoader: 0015ab40 Domain 1: 149100, LowFrequencyHeap: 00149164, HighFrequencyHeap: 001491bc, StubHeap: 00149214, Name: Sample1.exe, Assembly: 00164938 [Sample1], ClassLoader: 00164a78 
using System; public interface MyInterface1 { void Method1(); void Method2(); } public interface MyInterface2 { void Method2(); void Method3(); } class MyClass : MyInterface1, MyInterface2 { public static string str = "MyString"; public static uint ui = 0xAAAAAAAA; public void Method1() { Console.WriteLine("Method1"); } public void Method2() { Console.WriteLine("Method2"); } public virtual void Method3() { Console.WriteLine("Method3"); } } class Program { static void Main() { MyClass mc = new MyClass(); MyInterface1 mi1 = mc; MyInterface2 mi2 = mc; int i = MyClass.str.Length; uint j = MyClass.ui; mc.Method1(); mi1.Method1(); mi1.Method2(); mi2.Method2(); mi2.Method3(); mc.Method3(); } }

咱們的控制檯程序,Sample1.exe,被加載到一個名爲"Sample1.exe"的應用程序域。Mscorlib.dll被加載到共享域,不過由於它是核心系統庫,因此也在系統域中列出。每一個域會分配一個高頻堆,低頻堆和代理堆。系統域和共享域使用相同的類加載器,而默認應用程序使用本身的類加載器。

輸出沒有顯示加載器堆的保留尺寸和已提交尺寸。高頻堆的初始化大小是32KB,每次提交4KB。SOS的輸出也沒有顯示接口虛表堆(InterfaceVtableMap)。每一個域有一個接口虛表堆(簡稱爲IVMap),由本身的加載器堆在域初始化階段建立。IVMap保留大小是4KB,開始時提交4KB。咱們將會在後續部分研究類型佈局時討論IVMap的意義。

圖2 顯示默認的進程堆,JIT代碼堆,GC堆(用於小對象)和大對象堆(用於大小等於或者超過85000字節的對象),它說明了這些堆和加載器堆的語義區別。即時(just-in-time, JIT)編譯器產生x86指令而且保存到JIT代碼堆中。GC堆和大對象堆是用於託管對象實例化的垃圾回收堆。

類型原理

類型是.NET編程中的基本單元。在C#中,類型可使用class,struct和interface關鍵字進行聲明。大多數類型由程序員顯式建立,可是,在特別的交互操做(interop)情形和遠程對象調用(.NET Remoting)場合中,.NET CLR會隱式的產生類型,這些產生的類型包含COM和運行時可調用封裝及傳輸代理(Runtime Callable Wrappers and Transparent Proxies)。

咱們經過一個包含對象引用的棧開始研究.NET類型原理(典型地,棧是一個對象實例開始生命期的地方)。 圖4中顯示的代碼包含一個簡單的程序,它有一個控制檯的入口點,調用了一個靜態方法。Method1建立一個SmallClass的類型實例,該類型包含一個字節數組,用於演示如何在大對象堆建立對象。儘管這是一段無聊的代碼,可是能夠幫助咱們進行討論。

圖4 Large Objects and Small Objects

using System; class SmallClass { private byte[] _largeObj; public SmallClass(int size) { _largeObj = new byte[size]; _largeObj[0] = 0xAA; _largeObj[1] = 0xBB; _largeObj[2] = 0xCC; } public byte[] LargeObj { get { return this._largeObj; } } } class SimpleProgram { static void Main(string[] args) { SmallClass smallObj = SimpleProgram.Create(84930,10,15,20,25); return; } static SmallClass Create(int size1, int size2, int size3, int size4, int size5) { int objSize = size1 + size2 + size3 + size4 + size5; SmallClass smallObj = new SmallClass(objSize); return smallObj; } }

圖5 顯示了中止在Create方法"return smallObj;" 代碼行斷點時的fastcall棧結構(fastcall時.NET的調用規範,它說明在可能的狀況下將函數參數經過寄存器傳遞,而其它參數按照從右到左的順序入棧,而後由被調用函數完成出棧操做)。本地值類型變量objSize內含在棧結構中。引用類型變量如smallObj以固定大小(4字節DWORD)保存在棧中,包含了在通常GC堆中分配的對象的地址。對於傳統C++,這是對象的指針;在託管世界中,它是對象的引用。無論怎樣,它包含了一個對象實例的地址,咱們將使用術語對象實例(ObjectInstance)描述對象引用指向地址位置的數據結構。

圖5 SimpleProgram的棧結構和堆

通常GC堆上的smallObj對象實例包含一個名爲 _largeObj 的字節數組(注意,圖中顯示的大小爲85016字節,是實際的存貯大小)。CLR對大於或等於85000字節的對象的處理和小對象不一樣。大對象在大對象堆(LOH)上分配,而小對象在通常GC堆上建立,這樣能夠優化對象的分配和回收。LOH不會壓縮,而GC堆在GC回收時進行壓縮。還有,LOH只會在徹底GC回收時被回收。

smallObj的對象實例包含類型句柄(TypeHandle),指向對應類型的方法表。每一個聲明的類型有一個方法表,而同一類型的全部對象實例都指向同一個方法表。它包含了類型的特性信息(接口,抽象類,具體類,COM封裝和代理),實現的接口數目,用於接口分派的接口圖,方法表的槽(slot)數目,指向相應實現的槽表。

方法表指向一個名爲EEClass的重要數據結構。在方法表建立前,CLR類加載器從元數據中建立EEClass。 圖4中,SmallClass的方法表指向它的EEClass。這些結構指向它們的模塊和程序集。方法表和EEClass通常分配在共享域的加載器堆。加載器堆和應用程序域關聯,這裏提到的數據結構一旦被加載到其中,就直到應用程序域卸載時纔會消失。並且,默認的應用程序域不會被卸載,因此這些代碼的生存期是直到CLR關閉爲止。

對象實例

正如咱們說過的,全部值類型的實例或者包含在線程棧上,或者包含在 GC 堆上。全部的引用類型在 GC 堆或者 LOH 上建立。圖 6 顯示了一個典型的對象佈局。一個對象能夠經過如下途徑被引用:基於棧的局部變量,在交互操做或者平臺調用狀況下的句柄表,寄存器(執行方法時的 this 指針和方法參數),擁有終結器( finalizer )方法的對象的終結器隊列。 OBJECTREF 不是指向對象實例的開始位置,而是有一個 DWORD 的偏移量( 4 字節)。此 DWORD 稱爲對象頭,保存一個指向 SyncTableEntry 表的索引(從 1 開始計數的 syncblk 編號。由於經過索引進行鏈接,因此在須要增長表的大小時, CLR 能夠在內存中移動這個表。 SyncTableEntry 維護一個反向的弱引用,以便 CLR 能夠跟蹤 SyncBlock 的全部權。弱引用讓 GC 能夠在沒有其它強引用存在時回收對象。 SyncTableEntry 還保存了一個指向 SyncBlock 的指針,包含了不多須要被一個對象的全部實例使用的有用的信息。這些信息包括對象鎖,哈希編碼,任何轉換層 (thunking) 數據和應用程序域的索引。對於大多數的對象實例,不會爲實際的 SyncBlock 分配內存,並且 syncblk 編號爲 0 。這一點在執行線程遇到如 lock(obj) 或者 obj.GetHashCode 的語句時會發生變化,以下所示:

SmallClass obj = new SmallClass()
// Do some work here lock(obj) { /* Do some synchronized work here */ } obj.GetHashCode();

圖 6 對象實例佈局

在以上代碼中, smallObj 會使用 0 做爲它的起始的 syncblk 編號。 lock 語句使得 CLR 建立一個 syncblk 入口並使用相應的數值更新對象頭。由於 C# 的 lock 關鍵字會擴展爲 try-finally 語句並使用 Monitor 類,一個用做同步的 Monitor 對象在 syncblk 上建立。堆 GetHashCode 的調用會使用對象的哈希編碼增長 syncblk 。
在 SyncBlock 中有其它的域,它們在 COM 交互操做和封送委託( marshaling delegates )到非託管代碼時使用,不過這和典型的對象用處無關。
類型句柄緊跟在對象實例中的 syncblk 編號後。爲了保持連續性,我會在說明實例變量後討論類型句柄。實例域( Instance field )的變量列表緊跟在類型句柄後。默認狀況下,實例域會之內存最有效使用的方式排列,這樣只須要最少的用做對齊的填充字節。圖 7 的代碼顯示了 SimpleClass 包含有一些不一樣大小的實例變量。

圖 7 SimpleClass with Instance Variables

class SimpleClass { private byte b1 = 1; // 1 byte private byte b2 = 2; // 1 byte private byte b3 = 3; // 1 byte private byte b4 = 4; // 1 byte private char c1 = 'A'; // 2 bytes private char c2 = 'B'; // 2 bytes private short s1 = 11; // 2 bytes private short s2 = 12; // 2 bytes private int i1 = 21; // 4 bytes private long l1 = 31; // 8 bytes private string str = "MyString"; // 4 bytes (only OBJECTREF) //Total instance variable size = 28 bytes static void Main() { SimpleClass simpleObj = new SimpleClass(); return; } }

圖 8 顯示了在 Visual Studio 調試器的內存窗口中的一個 SimpleClass 對象實例。咱們在圖 7 的 return 語句處設置了斷點,而後使用 ECX 寄存器保存的 simpleObj 地址在內存窗口顯示對象實例。前 4 個字節是 syncblk 編號。由於咱們沒有用任何同步代碼使用此實例(也沒有訪問它的哈希編碼), syncblk 編號爲 0 。保存在棧變量的對象實例,指向起始位置的 4 個字節的偏移處。字節變量 b1,b2,b3 和 b4 被一個接一個的排列在一塊兒。兩個 short 類型變量 s1 和 s2 也被排列在一塊兒。字符串變量 str 是一個 4 字節的 OBJECTREF ,指向 GC 堆中分配的實際的字符串實例。字符串是一個特別的類型,由於全部包含一樣文字符號的字符串,會在程序集加載到進程時指向一個全局字符串表的同一實例。這個過程稱爲字符串駐留( string interning ),設計目的是優化內存的使用。咱們以前已經提過,在 NET Framework 1.1 中,程序集不能選擇是否使用這個過程,儘管將來版本的 CLR 可能會提供這樣的能力。

圖 8 Debugger Memory Window for Object Instance

因此默認狀況下,成員變量在源代碼中的詞典順序沒有在內存中保持。在交互操做的狀況下,詞典順序必須被保存到內存中,這時可使用 StructLayoutAttribute 特性,它有一個 LayoutKind 的枚舉類型做爲參數。 LayoutKind.Sequential 能夠爲被封送( marshaled )數據保持詞典順序,儘管在 .NET Framework 1.1 中,它沒有影響託管的佈局(可是 .NET Framework 2.0 可能會這麼作)。在交互操做的狀況下,若是你確實須要額外的填充字節和顯示的控制域的順序, LayoutKind.Explicit 能夠和域層次的 FieldOffset 特性一塊兒使用。

看完底層的內存內容後,咱們使用 SOS 看看對象實例。一個有用的命令是 DumpHeap ,它能夠列出全部的堆內容和一個特別類型的全部實例。無需依賴寄存器, DumpHeap 能夠顯示咱們建立的惟一一個實例的地址。

!DumpHeap -type SimpleClass Loaded Son of Strike data table version 5 from "C:WINDOWSMicrosoft.NETFrameworkv1.1.4322mscorwks.dll" Address MT Size 00a8197c 00955124 36 Last good object: 00a819a0 total 1 objects Statistics: MT Count TotalSize Class Name 955124 1 36 SimpleClass

對象的總大小是 36 字節,無論字符串多大, SimpleClass 的實例只包含一個 DWORD 的對象引用。 SimpleClass 的實例變量只佔用 28 字節,其它 8 個字節包括類型句柄( 4 字節)和 syncblk 編號( 4 字節)。找到 simpleObj 實例的地址後,咱們可使用 DumpObj 命令輸出它的內容,以下所示:

!DumpObj 0x00a8197c Name: SimpleClass MethodTable x00955124 EEClass x02ca33b0 Size 36(x24) bytes FieldDesc*: 00955064 MT Field Offset Type Attr Value Name 00955124 00000a 4 System.Int64 instance 31 l1 00955124 00000b c CLASS instance 0a819a0 str << some fields omitted from the display for brevity >> 00955124 4000003 e System.Byte instance 3 b3 00955124 4000004 f System.Byte instance 4 b4

正如以前說過, C# 編譯器對於類的默認佈局使用 LayoutType.Auto (對於結構使用 LayoutType.Sequential );所以類加載器從新排列實例域以最小化填充字節。咱們可使用 ObjSize 來輸出包含被 str 實例佔用的空間,以下所示:

!ObjSize 0x00a8197c sizeof(00a8197c) = 72 ( 0x48) bytes (SimpleClass)

若是你從對象圖的全局大小( 72 字節)減去 SimpleClass 的大小( 36 字節),就能夠獲得 str 的大小,即 36 字節。讓咱們輸出 str 實例來驗證這個結果:

!DumpObj 0x00a819a0 Name: System.String MethodTable x009742d8 EEClass x02c4c6c4 Size 36(x24) bytes

若是你將字符串實例的大小(36字節)加上SimpleClass實例的大小(36字節),就能夠獲得ObjSize命令報告的總大小72字節。

請注意ObjSize不包含syncblk結構佔用的內存。並且,在.NET Framework 1.1中,CLR不知道非託管資源佔用的內存,如GDI對象,COM對象,文件句柄等等;所以它們不會被這個命令報告。

指向方法表的類型句柄在syncblk編號後分配。在對象實例建立前,CLR查看加載類型,若是沒有找到,則進行加載,得到方法表地址,建立對象實例,而後把類型句柄值追加到對象實例中。JIT編譯器產生的代碼在進行方法分派時使用類型句柄來定位方法表。CLR在須要史能夠經過方法表反向訪問加載類型時使用類型句柄。

Son of Strike
SOS調試器擴展程序用於本文化的顯示CLR數據結構的內容,它是 .NET Framework 安裝程序的一部分,位於 %windir%\Microsoft.NET\Framework\v1.1.4322。SOS加載到進程以前,在 Visual Studio 中啓用託管代碼調試。 添加 SOS.dll 所在的文件夾到PATH環境變量中。 加載 SOS.dll, 而後設置一個斷點, 打開 Debug|Windows|Immediate。而後在 Immediate 窗口中執行 .load sos.dll。使用 !help 獲取調試相關的一些命令,關於SOS更多信息,參考這裏

方法表

每一個類和實例在加載到應用程序域時,會在內存中經過方法表來表示。這是在對象的第一個實例建立前的類加載活動的結果。對象實例表示的是狀態,而方法表表示了行爲。經過EEClass,方法表把對象實例綁定到被語言編譯器產生的映射到內存的元數據結構(metadata structures)。方法表包含的信息和外掛的信息能夠經過System.Type訪問。指向方法表的指針在託管代碼中能夠經過Type.RuntimeTypeHandle屬性得到。對象實例包含的類型句柄指向方法表開始位置的偏移處,偏移量默認狀況下是12字節,包含了GC信息。咱們不打算在這裏對其進行討論。

圖 9 顯示了方法表的典型佈局。咱們會說明類型句柄的一些重要的域,可是對於徹底的列表,請參看此圖。讓咱們從基實例大小(Base Instance Size)開始,由於它直接關係到運行時的內存狀態。

圖 9 方法表佈局

基實例大小

基實例大小是由類加載器計算的對象的大小,基於代碼中聲明的域。以前已經討論過,當前GC的實現須要一個最少12字節的對象實例。若是一個類沒有定義任何實例域,它至少包含額外的4個字節。其它的8個字節被對象頭(可能包含syncblk編號)和類型句柄佔用。再說一次,對象的大小會受到StructLayoutAttribute的影響。

看看圖3中顯示的MyClass(有兩個接口)的方法表的內存快照(Visual Studio .NET 2003內存窗口),將它和SOS的輸出進行比較。在圖9中,對象大小位於4字節的偏移處,值爲12(0x0000000C)字節。如下是SOS的DumpHeap命令的輸出:

!DumpHeap -type MyClass Address MT Size 0a819ac 09552a0 12 total 1 objects Statistics: MT Count TotalSize Class Name 552a0 1 12 MyClass

方法槽表(Method Slot Table)

在方法表中包含了一個槽表,指向各個方法的描述(MethodDesc),提供了類型的行爲能力。方法槽表是基於方法實現的線性鏈表,按照以下順序排列:繼承的虛方法,引入的虛方法,實例方法,靜態方法。

類加載器在當前類,父類和接口的元數據中遍歷,而後建立方法表。在排列過程當中,它替換全部的被覆蓋的虛方法和被隱藏的父類方法,建立新的槽,在須要時複製槽。槽複製是必需的,它可讓每一個接口有本身的最小的vtable。可是被複制的槽指向相同的物理實現。MyClass包含接口方法,一個類構造函數(.cctor)和對象構造函數(.ctor)。對象構造函數由C#編譯器爲全部沒有顯式定義構造函數的對象自動生成。由於咱們定義並初始化了一個靜態變量,編譯器會生成一個類構造函數。圖10顯示了MyClass的方法表的佈局。佈局顯示了10個方法,由於Method2槽爲接口IVMap進行了複製,下面咱們會進行討論。圖11顯示了MyClass的方法表的SOS的輸出。

圖10 MyClass MethodTable Layout

圖11 SOS Dump of MyClass Method Table

!DumpMT -MD 0x9552a0 Entry MethodDesc Return Type Name 0097203b 00972040 String System.Object.ToString() 009720fb 00972100 Boolean System.Object.Equals(Object) 00972113 00972118 I4 System.Object.GetHashCode() 0097207b 00972080 Void System.Object.Finalize() 00955253 00955258 Void MyClass.Method1() 00955263 00955268 Void MyClass.Method2() 00955263 00955268 Void MyClass.Method2() 00955273 00955278 Void MyClass.Method3() 00955283 00955288 Void MyClass..cctor() 00955293 00955298 Void MyClass..ctor()

任何類型的開始4個方法老是ToString, Equals, GetHashCode, and Finalize。這些是從System.Object繼承的虛方法。Method2槽被進行了複製,可是都指向相同的方法描述。代碼顯示定義的.cctor和.ctor會分別和靜態方法和實例方法分在一組。

方法描述(MethodDesc)

方法描述(MethodDesc)是CLR知道的方法實現的一個封裝。有幾種類型的方法描述,除了用於託管實現,分別用於不一樣的交互操做實現的調用。在本文中,咱們只考察圖3代碼中的託管方法描述。方法描述在類加載過程當中產生,初始化爲指向IL。每一個方法描述帶有一個預編譯代理(PreJitStub),負責觸發JIT編譯。圖12顯示了一個典型的佈局,方法表的槽實際上指向代理,而不是實際的方法描述數據結構。對於實際的方法描述,這是-5字節的偏移,是每一個方法的8個附加字節的一部分。這5個字節包含了調用預編譯代理程序的指令。5字節的偏移能夠從SOS的DumpMT輸出從看到,由於方法描述老是方法槽表指向的位置後面的5個字節。在第一次調用時,會調用JIT編譯程序。在編譯完成後,包含調用指令的5個字節會被跳轉到JIT編譯後的x86代碼的無條件跳轉指令覆蓋。

圖 12方法描述

圖12的方法表槽指向的代碼進行反彙編,顯示了對預編譯代理的調用。如下是在 Method2 被JIT編譯前的反彙編的簡化顯示。

Method2:

!u 0x00955263 Unmanaged code 00955263 call 003C3538 ;call to the jitted Method2() 00955268 add eax,68040000h ;ignore this and the rest ;as !u thinks it as code

如今咱們執行此方法,而後反彙編相同的地址:

!u 0x00955263 Unmanaged code 00955263 jmp 02C633E8 ;call to the jitted Method2() 00955268 add eax,0E8040000h ;ignore this and the rest ;as !u thinks it as code

在此地址,只有開始5個字節是代碼,剩餘字節包含了Method2的方法描述的數據。「!u」命令不知道這一點,因此生成的是錯亂的代碼,你能夠忽略5個字節後的全部東西。

CodeOrIL在JIT編譯前包含IL中方法實現的相對虛地址(Relative Virtual Address ,RVA)。此域用做標誌,表示是否IL。在按要求編譯後,CLR使用編譯後的代碼地址更新此域。讓咱們從列出的函數中選擇一個,而後用DumpMT命令分別輸出在JIT編譯先後的方法描述的內容:

!DumpMD 0x00955268 Method Name : [DEFAULT] [hasThis] Void MyClass.Method2() MethodTable 9552a0 Module: 164008 mdToken: 06000006 Flags : 400 IL RVA : 00002068

編譯後,方法描述的內容以下:

!DumpMD 0x00955268 Method Name : [DEFAULT] [hasThis] Void MyClass.Method2() MethodTable 9552a0 Module: 164008 mdToken: 06000006 Flags : 400 Method VA : 02c633e8

方法的這個標誌域的編碼包含了方法的類型,例如靜態,實例,接口方法或者COM實現。讓咱們看方法表另一個複雜的方面:接口實現。它封裝了佈局過程全部的複雜性,讓託管環境以爲這一點看起來簡單。而後,咱們將說明接口如何進行佈局和基於接口的方法分派的確切工做方式。

接口虛表圖和接口圖(Interface Vtable Map and Interface Map)

在方法表的第12字節偏移處是一個重要的指針,接口虛表(IVMap)。如圖9所示,接口虛表指向一個應用程序域層次的映射表,該表以進程層次的接口ID做爲索引。接口ID在接口類型第一次加載時建立。每一個接口的實現都在接口虛表中有一個記錄。若是MyInterface1被兩個類實現,在接口虛表表中就有兩個記錄。該記錄會反向指向MyClass方法表內含的子表的開始位置,如圖9所示。這是接口方法分派發生時使用的引用。接口虛表是基於方法表內含的接口圖信息建立,接口圖在方法表佈局過程當中基於類的元數據建立。一旦類型加載完成,只有接口虛表用於方法分派。

第28字節位置的接口圖會指向內含在方法表中的接口信息記錄。在這種狀況下,對MyClass實現的兩個接口中的每個都有兩條記錄。第一條接口信息記錄的開始4個字節指向MyInterface1的類型句柄(見圖9圖10)。接着的WORD(2字節)被一個標誌佔用(0表示從父類派生,1表示由當前類實現)。在標誌後的WORD是一個開始槽(Start Slot),被類加載器用來佈局接口實現的子表。對於MyInterface2,開始槽的值爲4(從0開始編號),因此槽5和6指向實現;對於MyInterface2,開始槽的值爲6,因此槽7和8指向實現。類加載器會在須要時複製槽來產生這樣的效果:每一個接口有本身的實現,然而物理映射到一樣的方法描述。在MyClass中,MyInterface1.Method2和MyInterface2.Method2會指向相同的實現。

基於接口的方法分派經過接口虛表進行,而直接的方法分派經過保存在各個槽的方法描述地址進行。如以前說起,.NET框架使用fastcall的調用約定,最早2個參數在可能的時候通常經過ECX和EDX寄存器傳遞。實例方法的第一個參數老是this指針,因此經過ECX寄存器傳送,能夠在「mov ecx,esi」語句看到這一點:

mi1.Method1();
mov    ecx,edi                 ;move "this" pointer into ecx mov eax,dword ptr [ecx] ;move "TypeHandle" into eax mov eax,dword ptr [eax+0Ch] ;move IVMap address into eax at offset 12 mov eax,dword ptr [eax+30h] ;move the ifc impl start slot into eax call dword ptr [eax] ;call Method1 mc.Method1(); mov ecx,esi ;move "this" pointer into ecx cmp dword ptr [ecx],ecx ;compare and set flags call dword ptr ds:[009552D8h];directly call Method1

這些反彙編顯示了直接調用MyClass的實例方法沒有使用偏移。JIT編譯器把方法描述的地址直接寫到代碼中。基於接口的分派經過接口虛表發生,和直接分派相比須要一些額外的指令。一個指令用來得到接口虛表的地址,另外一個獲取方法槽表中的接口實現的開始槽。並且,把一個對象實例轉換爲接口只須要拷貝this指針到目標的變量。在圖2中,語句「mi1=mc」使用一個指令把mc的對象引用拷貝到mi1。

虛分派(Virtual Dispatch)

如今咱們看看虛分派,而且和基於接口的分派進行比較。如下是圖3中MyClass.Method3的虛函數調用的反彙編代碼:

mc.Method3();
Mov    ecx,esi               ;move "this" pointer into ecx Mov eax,dword ptr [ecx] ;acquire the MethodTable address Call dword ptr [eax+44h] ;dispatch to the method at offset 0x44

虛分派老是經過一個固定的槽編號發生,和方法表指針在特定的類(類型)實現層次無關。在方法表佈局時,類加載器用覆蓋的子類的實現代替父類的實現。結果,對父對象的方法調用被分派到子對象的實現。反彙編顯示了分派經過8號槽發生,能夠在調試器的內存窗口(如圖10所示)和DumpMT的輸出看到這一點。

靜態變量(Static Variables)

靜態變量是方法表數據結構的重要組成部分。做爲方法表的一部分,它們分配在方法表的槽數組後。全部的原始靜態類型是內聯的,而對於結構和引用的類型的靜態值對象,通在句柄表中建立的對象引用來指向。方法表中的對象引用指向應用程序域的句柄表的對象引用,它引用了堆上建立的對象實例。一旦建立後,句柄表內的對象引用會使堆上的對象實例保持生存,直到應用程序域被卸載。在圖9 中,靜態字符串變量str指向句柄表的對象引用,後者指向GC堆上的MyString。

EEClass

EEClass在方法表建立前開始生存,它和方法表結合起來,是類型聲明的CLR版本。實際上,EEClass和方法表邏輯上是一個數據結構(它們一塊兒表示一個類型),只不過由於使用頻度的不一樣而被分開。常用的域放在方法表,而不常用的域在EEClass中。這樣,須要被JIT編譯函數使用的信息(如名字,域和偏移)在EEClass中,可是運行時須要的信息(如虛表槽和GC信息)在方法表中。

對每個類型會加載一個EEClass到應用程序域中,包括接口,類,抽象類,數組和結構。每一個EEClass是一個被執行引擎跟蹤的樹的節點。CLR使用這個網絡在EEClass結構中瀏覽,其目的包括類加載,方法表佈局,類型驗證和類型轉換。EEClass的子-父關係基於繼承層次創建,而父-子關係基於接口層次和類加載順序的結合。在執行託管代碼的過程當中,新的EEClass節點被加入,節點的關係被補充,新的關係被創建。在網絡中,相鄰的EEClass還有一個水平的關係。EEClass有三個域用於管理被加載類型的節點關係:父類(Parent Class),相鄰鏈(sibling chain)和子鏈(children chain)。關於圖4中的MyClass上下文中的EEClass的語義,請參考圖13

圖13只顯示了和這個討論相關的一些域。由於咱們忽略了佈局中的一些域,咱們沒有在圖中確切顯示偏移。EEClass有一個間接的對於方法表的引用。EEClass也指向在默認應用程序域的高頻堆分配的方法描述塊。在方法表建立時,對進程堆上分配的域描述列表的一個引用提供了域的佈局信息。EEClass在應用程序域的低頻堆分配,這樣操做系統能夠更好的進行內存分頁管理,所以減小了工做集。

圖13 EEClass 佈局

圖13中的其它域在MyClass(圖3)的上下文的意義不言自明。咱們如今看看使用SOS輸出的EEClass的真正的物理內存。在mc.Method1代碼行設置斷點後,運行圖3的程序。首先使用命令Name2EE得到MyClass的EEClass的地址。

!Name2EE C:WorkingtestClrInternalsSample1.exe MyClass

MethodTable: 009552a0 EEClass: 02ca3508 Name: MyClass

Name2EE的第一個參數時模塊名,能夠從DumpDomain命令獲得。如今咱們獲得了EEClass的地址,咱們輸出EEClass:

!DumpClass 02ca3508 Class Name : MyClass, mdToken : 02000004, Parent Class : 02c4c3e4 ClassLoader : 00163ad8, Method Table : 009552a0, Vtable Slots : 8 Total Method Slots : a, NumInstanceFields: 0, NumStaticFields: 2,FieldDesc*: 00955224 MT Field Offset Type Attr Value Name 009552a0 4000001 2c CLASS static 00a8198c str 009552a0 4000002 30 System.UInt32 static aaaaaaaa ui

圖13和DumpClass的輸出看起來徹底同樣。元數據令牌(metadata token,mdToken)表示了在模塊PE文件中映射到內存的元數據表的MyClass索引,父類指向System.Object。從相鄰鏈指向名爲Program的EEClass,能夠知道圖13顯示的是加載Program時的結果。

MyClass有8個虛表槽(能夠被虛分派的方法)。即便Method1和Method2不是虛方法,它們能夠在經過接口進行分派時被認爲是虛函數並加入到列表中。把.cctor和.ctor加入到列表中,你會獲得總共10個方法。最後列出的是類的兩個靜態域。MyClass沒有實例域。其它域不言自明。

結論

咱們關於CLR一些最重要的內在的探索旅程終於結束了。顯然,還有許多問題須要涉及,並且須要在更深的層次上討論,可是咱們但願這能夠幫助你看到事物如何工做。這裏提供的許多的信息可能會在.NET框架和CLR的後來版本中改變,不過儘管本文提到的CLR數據結構可能改變,概念應該保持不變。

相關文章
相關標籤/搜索