深入探索.NET框架内部了解CLR如何创建运行时对象
為什么80%的碼農都做不了架構師?>>> ??
本文討論:
| ? | SystemDomain, SharedDomain, and DefaultDomain |
| ? | 對象布局和內存細節。 |
| ? | 方法表布局。 |
| ? | 方法分派(Method dispatching)。 |
本文使用下列技術:
.NET Framework, C#
本頁內容
| CLR啟動程序(Bootstrap)創建的域 | |
| 系統域(System Domain) | |
| 共享域(Shared Domain) | |
| 默認域(Default Domain) | |
| 加載器堆(Loader Heaps) | |
| 類型原理 | |
| 對象實例 | |
| 方法表 | |
| 基實例大小 | |
| 方法槽表(Method Slot Table) | |
| 方法描述(MethodDesc) | |
| 接口虛表圖和接口圖 | |
| 虛分派(Virtual Dispatch) | |
| 靜態變量 | |
| EEClass | |
| Conclusion結論 |
隨著通用語言運行時(CLR)即將成為在Windows?下開發應用程序的首選架構,對其進行深入理解會幫助你建立有效的工業強度的應用程序。在本文中,我們將探索CLR內部,包括對象實例布局,方法表布局,方法分派,基于接口的分派和不同的數據結構。
我們將使用C#編寫的簡單代碼示例,以便任何固有的語言語法含義是C#的缺省定義。某些此處討論的數據結構和算法可能會在Microsoft? .NET Framework 2.0中改變,但是主要概念應該保持不變。我們使用Visual Studio? .NET 2003調試器和調試器擴展Son of Strike (SOS)來查看本文討論的數據結構。SOS理解CLR的內部數據結構并輸出有用信息。請參考“Son of Strike”補充資料,了解如何將SOS.dll裝入Visual Studio .NET 2003調試器的進程空間。本文中,我們將描述在共享源代碼CLI(Shared Source CLI,SSCLI)中有相應實現的類,你可以從msdn.microsoft.com/net/sscli下載。圖1將幫助你在SSCLI的數以兆計的代碼中找到所參考的結構。
在我們開始前,請注意:本文提供的信息只對在X86平臺上運行的.NET Framework 1.1有效(對于Shared Source CLI 1.0也大部分適用,只是在某些交互操作的情況下必須注意例外),對于.NET Framework 2.0會有改變,所以請不要在構建軟件時依賴于這些內部結構的不變性。
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啟動程序創建的域
返回頁首系統域(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的加載信息)。這里是編輯后的輸出:
!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我們的控制臺程序,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的類型實例,該類型包含一個字節數組,用于演示如何在大對象堆創建對象。盡管這是一段無聊的代碼,但是可以幫助我們進行討論。
圖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();在以上代碼中,smallObj會使用0作為它的起始的syncblk編號。lock語句使得CLR創建一個syncblk入口并使用相應的數值更新對象頭。因為C#的lock關鍵字會擴展為try-finally語句并使用Monitor類,一個用作同步的Monitor對象在syncblk上創建。堆GetHashCode的調用會使用對象的哈希編碼增加syncblk。
在SyncBlock中有其它的域,它們在COM交互操作和封送委托(marshaling delegates)到非托管代碼時使用,不過這和典型的對象用處無關。
類型句柄緊跟在對象實例中的syncblk編號后。為了保持連續性,我會在說明實例變量后討論類型句柄。實例域(Instance field)的變量列表緊跟在類型句柄后。默認情況下,實例域會以內存最有效使用的方式排列,這樣只需要最少的用作對齊的填充字節。圖7的代碼顯示了SimpleClass包含有一些不同大小的實例變量。
圖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可能會提供這樣的能力。
所以默認情況下,成員變量在源代碼中的詞典順序沒有在內存中保持。在交互操作的情況下,詞典順序必須被保存到內存中,這時可以使用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:\WINDOWS\Microsoft.NET\Framework\v1.1.4322\mscorwks.dll"Address MT Size 00a8197c 00955124 36 Last good object: 00a819a0 total 1 objects Statistics:MT Count TotalSize Class Name955124 1 36 SimpleClass對象的總大小是36字節,不管字符串多大,SimpleClass的實例只包含一個DWORD的對象引用。SimpleClass的實例變量只占用28字節,其它8個字節包括類型句柄(4字節)和syncblk編號(4字節)。找到simpleObj實例的地址后,我們可以使用DumpObj命令輸出它的內容,如下所示:
!DumpObj 0x00a8197c Name: SimpleClass MethodTable 0x00955124 EEClass 0x02ca33b0 Size 36(0x24) bytes FieldDesc*: 00955064MT Field Offset Type Attr Value Name 00955124 400000a 4 System.Int64 instance 31 l1 00955124 400000b c CLASS instance 00a819a0 str<< some fields omitted from the display for brevity >> 00955124 4000003 1e System.Byte instance 3 b3 00955124 4000004 1f 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 0x009742d8 EEClass 0x02c4c6c4 Size 36(0x24) bytes如果你將字符串實例的大小(36字節)加上SimpleClass實例的大小(36字節),就可以得到ObjSize命令報告的總大小72字節。
請注意ObjSize不包含syncblk結構占用的內存。而且,在.NET Framework 1.1中,CLR不知道非托管資源占用的內存,如GDI對象,COM對象,文件句柄等等;因此它們不會被這個命令報告。
指向方法表的類型句柄在syncblk編號后分配。在對象實例創建前,CLR查看加載類型,如果沒有找到,則進行加載,獲得方法表地址,創建對象實例,然后把類型句柄值追加到對象實例中。JIT編譯器產生的代碼在進行方法分派時使用類型句柄來定位方法表。CLR在需要史可以通過方法表反向訪問加載類型時使用類型句柄。
返回頁首方法表
每個類和實例在加載到應用程序域時,會在內存中通過方法表來表示。這是在對象的第一個實例創建前的類加載活動的結果。對象實例表示的是狀態,而方法表表示了行為。通過EEClass,方法表把對象實例綁定到被語言編譯器產生的映射到內存的元數據結構(metadata structures)。方法表包含的信息和外掛的信息可以通過System.Type訪問。指向方法表的指針在托管代碼中可以通過Type.RuntimeTypeHandle屬性獲得。對象實例包含的類型句柄指向方法表開始位置的偏移處,偏移量默認情況下是12字節,包含了GC信息。我們不打算在這里對其進行討論。
圖9顯示了方法表的典型布局。我們會說明類型句柄的一些重要的域,但是對于完全的列表,請參看此圖。讓我們從基實例大小(Base Instance Size)開始,因為它直接關系到運行時的內存狀態。
返回頁首基實例大小
基實例大小是由類加載器計算的對象的大小,基于代碼中聲明的域。之前已經討論過,當前GC的實現需要一個最少12字節的對象實例。如果一個類沒有定義任何實例域,它至少包含額外的4個字節。其它的8個字節被對象頭(可能包含syncblk編號)和類型句柄占用。再說一次,對象的大小會受到StructLayoutAttribute的影響。
看看圖3中顯示的MyClass(有兩個接口)的方法表的內存快照(Visual Studio .NET 2003內存窗口),將它和SOS的輸出進行比較。在圖9中,對象大小位于4字節的偏移處,值為12(0x0000000C)字節。以下是SOS的DumpHeap命令的輸出:
!DumpHeap -type MyClassAddress MT Size 00a819ac 009552a0 12 total 1 objects Statistics:MT Count TotalSize Class Name 9552a0 1 12 MyClass 返回頁首方法槽表(Method Slot Table)
在方法表中包含了一個槽表,指向各個方法的描述(MethodDesc),提供了類型的行為能力。方法槽表是基于方法實現的線性鏈表,按照如下順序排列:繼承的虛方法,引入的虛方法,實例方法,靜態方法。
類加載器在當前類,父類和接口的元數據中遍歷,然后創建方法表。在排列過程中,它替換所有的被覆蓋的虛方法和被隱藏的父類方法,創建新的槽,在需要時復制槽。槽復制是必需的,它可以讓每個接口有自己的最小的vtable。但是被復制的槽指向相同的物理實現。MyClass包含接口方法,一個類構造函數(.cctor)和對象構造函數(.ctor)。對象構造函數由C#編譯器為所有沒有顯式定義構造函數的對象自動生成。因為我們定義并初始化了一個靜態變量,編譯器會生成一個類構造函數。圖10顯示了MyClass的方法表的布局。布局顯示了10個方法,因為Method2槽為接口IVMap進行了復制,下面我們會進行討論。圖11顯示了MyClass的方法表的SOS的輸出。
任何類型的開始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編譯前的反匯編的簡化顯示。
!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實現。讓我們看方法表另外一個復雜的方面:接口實現。它封裝了布局過程所有的復雜性,讓托管環境覺得這一點看起來簡單。然后,我們將說明接口如何進行布局和基于接口的方法分派的確切工作方式。
返回頁首接口虛表圖和接口圖
在方法表的第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的輸出看到這一點。
返回頁首靜態變量
靜態變量是方法表數據結構的重要組成部分。作為方法表的一部分,它們分配在方法表的槽數組后。所有的原始靜態類型是內聯的,而對于結構和引用的類型的靜態值對象,通在句柄表中創建的對象引用來指向。方法表中的對象引用指向應用程序域的句柄表的對象引用,它引用了堆上創建的對象實例。一旦創建后,句柄表內的對象引用會使堆上的對象實例保持生存,直到應用程序域被卸載。在圖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:\Working\test\ClrInternals\Sample1.exe MyClass MethodTable: 009552a0 EEClass: 02ca3508 Name: MyClassName2EE的第一個參數時模塊名,可以從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*: 00955224MT 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沒有實例域。其它域不言自明。
返回頁首Conclusion結論
我們關于CLR一些最重要的內在的探索旅程終于結束了。顯然,還有許多問題需要涉及,而且需要在更深的層次上討論,但是我們希望這可以幫助你看到事物如何工作。這里提供的許多的信息可能會在.NET框架和CLR的后來版本中改變,不過盡管本文提到的CLR數據結構可能改變,概念應該保持不變。
Hanu Kommalapati是微軟Gulf Coast區(休斯頓)的一名架構師。他在微軟現在的角色是幫助客戶基于.NET框架建立可擴展的組件框架。可以通過hanuk@microsoft.com聯系他。
Tom Christian是微軟開發支持高級工程師,使用ASP.NET和用于WinDBG的.NET調試器擴展(sos/ psscor)。他在北卡羅來州的夏洛特,可以通過tomchris@microsoft.com聯系他。
翻譯者Luke是微軟公司的軟件工程師,習慣使用C++和C#開發應用程序。閑暇時間他喜歡音樂,旅游和懷舊游戲,并且愿意幫助MSDN翻譯更多的文章和其他開發者共享。可以通過ecaijw@msn.com聯系他。
轉載于:https://my.oschina.net/ind/blog/299206
總結
以上是生活随笔為你收集整理的深入探索.NET框架内部了解CLR如何创建运行时对象的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: ansible内置模块
- 下一篇: 【算法学习笔记】07.数据结构基础 链表