深入.NET Framework内部, 看看CLR如何创建运行时对象的
[翻译经典文章]深入.NET Framework内部, 看看CLR如何创建运行时对象的
原文: Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects
文章讨论了:
SystemDomain, SharedDomain和Default Domain
对象布局和其他的内存细节
方法表布局
方法分派
文章使用的技术:
.NET Framework
C#
因 为公共语言运行时(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)中拥有相关实现的类, 你可以从msdn.microsoft.com/net/sscli下载到它们. 图表1 会帮助你在搜索一些结构的时候到SSCLI中的信息.
图表1 SSCLI索引
Item | SSCLI Path |
---|---|
AppDomain | \sscli\clr\src\vm\appdomain.hpp |
AppDomainStringLiteralMap | \sscli\clr\src\vm\stringliteralmap.h |
BaseDomain | \sscli\clr\src\vm\appdomain.hpp |
ClassLoader | \sscli\clr\src\vm\clsload.hpp |
EEClass | \sscli\clr\src\vm\class.h |
FieldDescs | \sscli\clr\src\vm\field.h |
GCHeap | \sscli\clr\src\vm\gc.h |
GlobalStringLiteralMap | \sscli\clr\src\vm\stringliteralmap.h |
HandleTable | \sscli\clr\src\vm\handletable.h |
InterfaceVTableMapMgr | \sscli\clr\src\vm\appdomain.hpp |
Large Object Heap | \sscli\clr\src\vm\gc.h |
LayoutKind | \sscli\clr\src\bcl\system\runtime\interopservices\layoutkind.cs |
LoaderHeaps | \sscli\clr\src\inc\utilcode.h |
MethodDescs | \sscli\clr\src\vm\method.hpp |
MethodTables | \sscli\clr\src\vm\class.h |
OBJECTREF | \sscli\clr\src\vm\typehandle.h |
SecurityContext | \sscli\clr\src\vm\security.h |
SecurityDescriptor | \sscli\clr\src\vm\security.h |
SharedDomain | \sscli\clr\src\vm\appdomain.hpp |
StructLayoutAttribute | \sscli\clr\src\bcl\system\runtime\interopservices\attributes.cs |
SyncTableEntry | \sscli\clr\src\vm\syncblk.h |
System namespace | \sscli\clr\src\bcl\system |
SystemDomain | \sscli\clr\src\vm\appdomain.hpp |
TypeHandle | \sscli\clr\src\vm\typehandle.h |
在 我们开始前请注意, 这篇文章提供的信息仅适用于在x86平台架构下的.NET Framework 1.1(有可能多数信息对于Shared Source CLI 1.0中, 一些互操作情形下的多数值得注意的异常来说, 也还是正确的). 对于.NET Framework 2.0来说, 很多信息可能会改变, 所以不要创建依赖于这些内部结构不会改变的软件.
CLR辅助程序创建的域
=================
在 CLR执行第一行托管代码之前, 它先创建三个应用程序域. 其中的两个是从托管代码中产生的, 是透明的, 甚至对于CLR宿主来说都是不可见的. 这两个domain只能通过CLR bootstrap进程创建出来, 这个进程受助于两个垫板作用一样的dll文件, mscoree.dll和mscorwks.dll(当是多处理器系统的时候, 为mscorsvr.dll). 在图表2中, 你可以看到System Domain和Shared Domain, 这两个都是Singleton的(只用唯一一个实例). 第三个域是default app domain, 它是一个AppDomain类的实例, 也是唯一命名的domain. 对于简单的CLR宿主程序, 比方说控制台程序, default domain的名字是由可执行镜像的名字组成的. 其他的域可以通过在托管代码中使用AppDomain.CreateDomain方法, 或者在非托管宿主代码中通过调用ICORRuntimeHost接口, 来创建. 类似ASP.NET这样的复杂的宿主, 基于Web Site的数目来创建多个域.
图表2 CLR辅助程序创建的域
系统域-System Domain
===================
系统域负责创建和初始化Shared Domain和default appdomain. 它加载系统库mscorlib.dll到Shared Domain中. 它还显式或隐式的保持着进程范围的字符串的字面值.
保 存字符串的字面值(string interning)在.NET Framework 1.1中是一项有点点笨拙的优化特性, 因为CLR并不给assemblies机会来选择是否使用它. 不论如何, 它在所有的应用程序域范围内, 提供给定字符串值的唯一实例(相同值的字符串在内存中只有一份).
系统域还负责生成进程范围的接口ID, 这些接口ID被用来在每一个AppDomain中创建InterfaceVtableMaps. 系统域记录并监控着进程中的所有域, 并实现了加载和卸载AppDomain的功能.
共享域-Shared Domain
===================
所有的域-中立的代码都被加载到shared domain中.
- Mscorlib, 这个系统库, 是被所有的appdomain中的用户代码使用和需要的, 它会被自动的加载到SharedDomain中. 像Object, ValueType, Array, Enum, String, 还有Delegate之类的System命名空间中的基础类型, 都会在CLR辅助程序进程(CLR bootstrapping process)中, 被预先加载到SharedDomain里.
- 用户代码(user code)也可以被加载到该域中, 方法是通过在调用CorBindToRuntimeEx方法时, 指定LoaderOptimization属性. LoaderOptimization属性是由CLR宿主应用程序指定的.
- 控制台程序可以给应用程序的main方法编写属性来加载代码到SharedDomain中, 这个属性是System.LoaderOptimizationAttribute.
共 享域还管理由基地址(the base address)索引的assembly map, assembly map的功能类似于一种查找表, 这个查找表用于明确被加载到Default Domain中的assembly和在其它应用程序域的托管代码中创建的assembly的共享依赖关系.
默认域(Default Domain)是非共享的用户代码加载的地方.
默认域-DefaultDomain
===================
默认域是一个AppDomain的实例, 典型地, 应用程序代码在这个域中执行.
当 一些应用程序需要在运行时创建额外的appdomain的时候(比如拥有插件式架构的应用程序, 或者是正在生成相当大量的运行时代码的应用程序), 多数的应用程序会在他们的生命期中创建一个这样的一个域: 所有执行在这个域中的代码都是在域层次上进行了上下文绑定的.
如果一个应用程序有多个appdomain, 那么任何跨domain的访问都要通过.NET Remoting proxies(.net远程代理).
额外的domain内的上下文边界可以通过继承自System.ContextBoundObject的类型来创建.
每 一个AppDomain都有自己的SecurityDescriptor, SecurityContext和DefaultContext, 同样的, 还有自己的加载者堆(高频堆, 低频堆, 和Stub堆), 句柄表(句柄表, 大对象堆句柄表), 接口虚表映射管理器(Interface Vtable Map Manager), 和Assembly Cache.
加载者堆-LoaderHeaps
====================
加载者堆是用来加载各种各样的CLR runtime artifacts【译注:artifact这里可以理解为一种structure】和优化artifacts的, 这些artifacts在域的生命期中都存在.
这些堆按照可以预见的大小的块来增长, 从而最小化内存碎片.
加载者堆与GC堆(在对称的多处理器情况下, 是多重堆-multiple heap) 不同, 不同之处在于GC堆保存对象实例,而加载者堆保存的是整个类型系统.
经 常访问到的artifact比如MethodTables, MeghodDescs, FieldDescs和InterfaceMaps, 都在高频堆上分配, 而不那么经常访问的数据结构比如说EEClass和ClassLoader还有ClassLoader的查找表, 在低频堆(LowFrequencyHeap)上分配.
StubHeap保存着很多stub, stub可以帮助代码访问security (CAS), COM wrapper calls和P/Invoke.
简单在高层次上过了一遍各种域和加载者堆之后, 我们现在来看一下以一个简单应用程序为上下文背景的, 这些结构的物理细节. 见图表3. 我们将程序的执行中断在了"mc.Method1();", 并且使用SOS debugger extension的DumpDomain命令dump出了域的信息, 这里是编辑过的输出结果:
---------------------------------------------------
!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
图表3 Sample1.exe
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"的AppDomain中.
Mscorlib.dll被加载到SharedDomain中, 但是它还是被列在SystemDomain中, 因为他是核心的系统库.
每个域中都分配了自己的高频堆,低频堆,和stub堆. 系统域和共享域使用同样的ClassLoader, 而Default AppDomain使用的是它自己的ClassLoader.
输出结果中并没有显示出加载者堆保存的尺寸和已经committed的尺寸. 高频堆初始保留尺寸是32KB, committed的尺寸是4KB. 低频堆和Stub堆初始保留尺寸是8KB, committed的尺寸是4KB.
在SOS输出中同样没有显示出来的是InterfaceVtableMap堆. 每个域都有一个InterfaceVtableMap堆(后面再用的时候就简写为IVMap), 在域初始化阶段它,被创建在自己的加载者堆上. IVMap堆初始保留大小为4KB, 初始committed的大小是4KB. 我们将在接下来的部分中,探索类型布局的时候,讨论IVMap的重要性.
图表2 展示了默认的进程堆, JIT代码堆, GC堆(针对小对象的), 和大对象堆(针对大于等于85000字节的对象的), 通过他们来说明了:这些堆和加载者堆在语义上的不同.
just-in-time(JIT)编译器生成x86指令, 并且把它们存储在JIT代码堆上.
GC堆和大对象堆都是垃圾收集堆, 托管对象是在这些堆上实例化出来的.
类型基础- Type Fundamentals
========================
类型是在.NET编程中的基础单位. 在C#中, 一个类型使用关键字class, struct,和interface来声明. 多数的类型是显示的由程序员来创建的, 然而, 在特殊的互操作情形下, 或者在远程对象激活(.NET remoting)场景中, .NET CLR隐式的生成一些类型. 这些生成的类型包括COM和运行时可调用的包装器, 还有透明的代理.(COM and Runtime Callable Wrappers and Transparent Proxies).
我们接下来探索一下类型基础, 从包含一个对象引用的栈开始.(典型地, 栈是一个对象实例开始他的生命期的位置.) 代码在图表4中, 其中包括一个简单的程序, 有调用静态方法的控制台入口点. Method1创建了一个类型为SmallClass的的实例, SmallClass中包括一个字节数组, 我们通过这个数组来demo在大对象堆上的对象实例的创建. 代码的实用价值不高, 但是足够为我们的讨论服务了.
图表 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;"语句的栈的一个快照(snapshot), 这是一个典型的fastcall的栈框架. (Fastcall是.NET的一种调用约定, 在这种调用约定下, 传递给函数的参数在可能的情况下会通过寄存器来传递, 其他的参数从右至左的压入栈中供函数调用, 函数调用结束后, 由函数自身将栈中的参数清除.)
值类型变量objSize存储在栈框架之内.
类似smallObj的引用类型以一个固定的大小(4字节的双字), 存储在栈中, 双字的内容是在普通GC堆上的对象实例的地址.
在传统C++中, 这是一个对象指针; 在托管世界中, 这是一个对象引用. 不论如何, 它包含对象实例的地址. 我们将会对存储在对象引用的地址中的数据结构使用术语ObjectInstance.
图表5 简单程序的栈框架和堆-SimpleProgram Stack Frame and Heaps
smallObj对象的实例(object instance), 存储在普通GC堆上, 其中包含一个字节数组, 叫做_largeObj, 这个字节数组的大小是85000字节(注意, 图中显示的是85016字节, 这是真实存储的空间大小.)
CLR对待大小>=85000字节的对象, 跟对待比这小的对象的方式不同. 大对象分配在Large Object Heap(LOH)中, 而小对象是创建在普通GC堆上的. 因为普通GC堆对于对象的分配和垃圾收集是有优化的(所以适合存储小对象的效率高). 大对象堆是没有压缩的(夯实的), 而GC堆在GC垃圾收集发生的时候是压缩的. 更重要的是, 大对象堆(LOH)仅在完全垃圾回收的时候才被释放(LOH is only collected on full GC collections).
smallObj的ObjectInstance包含TypeHandle(类型句柄), TypeHandle指向相关连的类型的MethodTable.
任何一个声明了的类型都仅有一个MethodTable, 并且所有同样类型的对象的实例都指向同一份MethodTable.
MethodTable包含
关于这种类型的信息(属于哪一个? interface, abstract class, concrete class, COM Wrapper还是Proxy).
实现了的接口数量
为了方法分配而设立的接口映射表(interface map for the method dispatch)
方法表中的槽的数量(方法表中方法的数量)(number of slots in the method table)
一张满是指向方法的实现的槽的表格
一个由MethodTable指向的重要的数据结构, 是EEClass. 在MethodTable展开之前, CLR类加载器(class loader)从元数据(Metadata)中创建出EEClass. 在图表4中, SmallClass的MethodTable指向它的EEClass. 这些结构指向他们的模块和assembly.
MethodTable和EEClass典型地分配在具体域的加载者堆上. 字节数组(Byte[])是一个特例. 方法表MethodTable和EEClass分配在共享域中的加载者堆上.
加载者堆是appdomain-specific的, 任何这里提到的数据结构(MethodTable和EEClass)一旦加载起来就不会被移除, 除非它的AppDomain被卸载掉.
同样, 默认的appdomain也不能被卸载掉, 因此代码直到CLR关闭都还存在着.
对象实例-ObjectInstance
====================
正如我们提到的, 所有的值类型要么以inline(内联)地存储在线程栈中, 要么内联地存储在GC堆当中. 所有的引用类型都是在GC堆上或者大对象堆创建的. 图表6 显示了一个典型的对象实例的布局.
一个对象可以被以下的结构引用:
1. 基于栈的局部变量;
2. interop或者P/Invoke情形下的句柄表;
3. 寄存器(寄存器中的内容是:执行方法时的this指针或方法参数)
4. 服务于拥有finalizer方法的对象的finalizer queue.
OBJECTREF并不指向Object Instance的首地址, 而是指向一个以DWORD(4个字节)为单位的一个偏移量.
这个DWORD的偏移量叫做Object Header, 并且拥有一个指向SyncTableEntry表的索引值(a 1-based syncblk number). 通过索引的链锁效应, CLR在需要增长内存尺寸的情况下, 可以在内存中自由的移动SyncTableEntry表.
SyncTableEntry中保存着一个指回对象的weak reference, 这样CLR就可以追踪到SyncBlock的所有权(属于哪个对象). Weak Reference可以让GC在没有其他强引用的情况下, 收集到这个对象.
SyncTableEntry中还存着一个指向SyncBlock的指针, SyncBlock中保存着有用的信息, 但是这些信息很少被所有的对象实例使用到. 这些信息包括对象锁(object's lock), 它的Hash Code, 一些转换数据(thunking data), 和它的AppDomain index.
对多数的对象实例来说, 他们当中没有为SyncBlock分配的存储空间, syncblk number是0. 然而,当线程执行遇到例如lock(obj), 或者obj.GetHashCode的时候, 就不同了. 就像下面的代码一样:
SmallClass obj = new SmallClass() // Do some work here lock(obj) { /* Do some synchronized work here */ } obj.GetHashCode();
图表6 对象实例布局-Object Instance Layout
在这段代码中, smallObj会使用0(没有syncblk)做为它起始时的syncblk number. 那句lock语句引发了CLR创建一个syncblk entry的动作, 并用相应的数值来更新对象的object header. 由于C#的lock关键字可以展开为一个try-finally块, 用来使用Monitor类, 所以Monitor对象在为同步化(synchronization)而准备的syncblk中创建出来. 对GetHashCode方法的调用把对象的hash code填入到syncblk中.
SyncBlock中还有些其他的数据域, 它们有的用在COM的interop上, 有的用在针对非托管代码的marshaling delegate上. 但是这些数据域跟典型的对象使用无关.
TypeHandle的位置是紧跟着ObjectInstance中的syncblk number的. 为了保持连续性, 我会在详细阐述变量实例之后, 讨论TypeHandle.
在TypeHandle之后紧跟着一个实例的变量列表域. 默认情况下, 这个实例域会按照能让内存高效使用的方式来压缩, 或者按照能让内存读取高效的对齐来做最小程度的填充. 图表7中的代码显示了一个SimpleClass, 该class拥有很多包含不同大小的变量的实例.
图表7 拥有实例变量的SimpleClass- 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 显示出了SimpleClas对象实例在Visual Studio Debugger内存窗口中的一个例子. 我们在图表7的 return语句上下断点, 然后用在寄存器ECX中存储的simpleObj的地址来在内存窗口中显示对象的实例. 头4个字节的块就是syncblk number. 因为我们之前没有在任何synchronizing的代码中使用这个实例, 它被设置为0. 以变量形式存在栈中的的对象引用, 指向偏移量为4的四个字节. 字节变量b1, b2, b3和b4都被一个挨着一个的排放着. 两个short型的变量被放在一起. 字符串型的变量str是一个4字节的OBJECTREF, 指向GC堆中字符串实际存在的地址. 字符串是一种特殊的类型, 在assembly加载的进程中, 它们的所有包含着相同内容的实例, 都会被指向相同的在全局字符串表中的那一份唯一实例. 这个进程叫做string interning, 是用来优化内存的使用的.
如同我们之前提到的, 在.NET Framework1.1中, 一个assbmbly不可能从这个interning process中退出(opt out of), 尽管未来的CLR版本可能会修改这种能力.
图表8 调试器内存窗口中的object instance- Debugger Memory Window for Object Instance
所以, 默认情况下, 在源代码中声明的成员变量的字面顺序, 在内存中并不会被保留下来. 在Interop的场景下, 变量的字面顺序必须被正向的依次放到内存中, StructLayoutAttribute属性可以用来完成这个设定, 该属性接受LayoutKind枚举类型的变量作为参数. LayoutKind.Sequential会为marshaled的数据设定字面的顺序, 尽管在.NET Framework 1.1中,这个设定还不会对托管布局生效.(.NET Framework 2.0就会了). 在interop场景下, 你实在需要额外的填充(padding)和显式的对于数据域顺序的控制时, LayoutKind.Explicit可以和FieldOffset这个修饰符结合起来在field level帮助您达到目的.
看过了原始内存的内容, 让我们用SOS来看一下对象实例吧. 一个有用的命令是DumpHeap, 它可以列出针对某一类型的所有堆中的内容, 还有这一类型的所有实例. 不依赖寄存器, DumpHeap命令可以show出我们创建的唯一实例的地址.
---------------------------------------------------!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 Name 955124 1 36 SimpleClass
整个对象的大小是36字节. 不论字符串多大, SimpleClass的instance中只包含一个DWORD OBJECTREF. SimpleClass的实例变量只占28个字节. 剩下的八个字节是由TypeHandle(4字节), 和syncblk number(4字节)组成的.
找到了simpleObj实例的地址后, 让我们用DumpObj命令来dump出这个实例吧, 如下:
---------------------------------------------------!DumpObj 0x00a8197c Name: SimpleClass MethodTable 0x00955124 EEClass 0x02ca33b0 Size 36(0x24) bytes FieldDesc*: 00955064 MT 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. 由于class loader重新安排了实例的数据域, 所以填充(padding)的部分达到了最小化. 我们可以用ObjSize命令来dump出实例占用空间的图示. 这里是输出结果:
---------------------------------------------------!ObjSize 0x00a8197c sizeof(00a8197c) = 72 ( 0x48) bytes (SimpleClass)
******************************************************************************************************
Son of Strike
在本文中, SOS调试器扩展时用来展现CLR数据结构内容的.
它是.NET Framework安装程序的一部分, 位置在%windir%\Microsoft.NET\Framework\v1.1.4322.
在你加载SOS到你的进程之前, 在Visual Studio .NET的工程属性里选择允许托管代码调试.(enable managed debugging)
添加SOS.dll所在的文件夹到环境变量中. 要在断点时, 加载SOS.dll, 打开Debug | Windows | Immediate. 在Immediate窗口中,
执行.load sos.dll命令. 用!help命令来得到关于debugger 命令的帮助. 更多关于SOS的信息, 参见the June 2004 Bugslayer column
******************************************************************************************************
如果你从object graph的大小(72字节)减去SimpleClass实例的大小(36字节), 你会得到变量str的长度(36字节). 让我们通过dump出这个字符串实例来确认一下吧. 输出结果如下:
---------------------------------------------------!DumpObj 0x00a819a0 Name: System.String MethodTable 0x009742d8 EEClass 0x02c4c6c4 Size 36(0x24) bytes
如果你把字符串的长度(36字节)加上SimpleClass实例的大小(36字节), 你就得到了对象的整个大小(72字节), 正与前面ObjSize命令的结果相同.
注意, ObjSize方法并不包括由syncblk架构占用的内存. 在.NET Framework 1.1中, CLR并不了解被非托管资源占据的内存, 比如说GDI对象, COM对象, 文件句柄等等. 因此, 他们的大小是不会被这个命令的结果报告中反映出来的.
TypeHandle, 是一个指向MethodTable的指针, 它的位置紧跟在syncblk number之后. 在一个对象实例创建之前, CLR会查询加载了的类型,
如果这个类型没有找到就加载它, 获得类型的MethodTable的地址, 创建对象实例, 然后填充对象的TypeHandle值. JIT编译器产生的代码使用TypeHandle来寻找MethodTable, 用于实现method dispatching. CLR可以在任何时候通过TypeHandle指向的MethodTable来反向追溯已经加载了的类型.
方法表- MethodTable
=================
任何一个类或者接口, 当他们加载到AppDomain当中的时候, 都会由一个叫做MethodTable数据结构来代表. 在对象的第一个实例都还没被加载的情况下, 创建出一个MethodTable是类的加载动作的执行成果。
ObjectInstance代表的是对象的状态, MethodTable代表的是对象的行为.
MethodTable把object instance与language compiler-generated memory-mapped metadata structures, 通过EEClass联系起来. 在MethodTable中的信息和metadata structure可以在托管代码中通过Systen.Type来访问到.
在托管代码中, 一个指向MethodTable的指针可以通过Type.RuntimeTypeHandle属性来获得. TypeHandle, 存在于ObjectInstance中, 它指向一个偏移量, 这个偏移量是从MethodTable的首地址算起的. 这里的偏移量默认是12字节。这开头的12个字节包含GC的一些信息, 我们并不打算在这里讨论这些信息.
图表9展现了一个典型的MethodTable的布局. 我们会show一些重要的TypeHandle的数据域, 但是为了一个更完整的列表, 还是看图表吧. 让我们从Base Instance Size开始吧, 因为它与运行时的内存轮廓有直接关系.
图表9 方法表布局- MethodTable Layout
基本实例尺寸-Base Instance Size
===========================
基本实例尺寸是由class loader计算出来的对象的大小, 是基于代码中的数据域声明来计算的. 如同前面讨论的, 当前GC的实现需要一个对象的大小至少是12个字节. 如果一个类没有任何的实例数据域被定义, 它会白白的用前4个字节作为占位字节. 剩下的8字节会被Object Header(可能包括一个syncblk number), 和TypeHandle占据. 再次强调, 对象的大小是可以被StructLayoutAttribute属性影响的.
看看图表3(MyClass和两个接口)中MyClass的MethodTable的内存快照吧(Visual Studio .NET 2003 memory window). 请拿它和SOS生成的输出结果进行比较. 在图表9中, 对象大小是在4字节的偏移量的地方的, 其值为12 (0x0000000C)字节. 下面是 SOS中命令DumpHeap的输出结果
--------------------------------------!DumpHeap -type MyClass Address MT Size 00a819ac 009552a0 12 total 1 objects Statistics: MT Count TotalSize Class Name 9552a0 1 12 MyClass
方法槽表-Method Slot Table
======================
在MethodTable中内嵌的是一张指向各自方法的方法描述器(MethodDesc)的指针组成的表格. 他们的存在允许了这个类型拥有一些行为. Method Slot表是根据按如下顺序实现了的方法的线性表来创建的: 继承的虚函数, 新虚函数, 实例方法, 和静态方法.(Inherited virtuals, Introduced virtuals, Instance Methods, and Static Methods).
ClassLoader遍历当前类的, 基类的, 和接口的metadata, 然后创建出method table. 在layout process, 任何重载了的虚函数都会被取代, 取代并隐藏父类的方法, 创建新的slot, 必要的情况下复制slot. 对slot的复制对于创建一个illusion是必不可少的, 所谓illusion是指每个接口都有他自己的小虚函数表. 然而, 复制的slot指向相同的物理实现. MyClass有三个实例方法, 一个类构造函数(.cctor), 和一个对象构造函数(.ctor). 对象构造函数是由C#编译器为所有没有显式定义构造函数的对象,自动生成的. 类构造函数是由编译器生成的, 因为我们定义并初始化了一个静态变量. 图表10 显示出了MyClass
的方法表的布局. 布局显示出了10个方法, 为了IVMap,Method2有重复的slot,这个重复将会在后面介绍. 图表11显示了MyClass的方法表在编辑过后的SOS dump.
图表10 MyClass的方法表布局
图表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()
任何类型的头四个方法永远都会是ToString, Equals, GetHashCode, 和Finalize.
他们是从System.Object继承来的虚方法. Method2的slot是duplicated的, 但是二者都指向相同的方法描述器(method descriptor). 显式编码的.cctor会和静态方法分为一组, .ctor会和实例方法分为一组. (The explicitly coded .cctor and .ctor will be grouped with static methods and instance methods, respectively.)
方法描述- MethodDesc
======================
方法描述(Method Descriptor)(MethodDesc)是方法实现的的一种封装, CLR是知道,了解这种封装的. 有好几种Method Descriptor, 他们的存在不仅使得调用托管代码的实现更容易, 而且使得对interop的实现的调用也变得容易了一些. 在这篇文章中, 我们只研究以图表3的代码为上下文的托管MethodDesc.
一个MethodDesc是作为类加载过程的一部分(class loading process)而产生出来的, MethodDesc初始情况下指向中间语言Intermediate Language(IL).
每一个MethodDesc都被一个叫做PreJitStub的填充, PreJitStub负责触发JIT的编译过程.
图表12展示了一个典型的布局. 方法表的slot entry实际指向PreJitStub, 而不是指向实际的MethodDesc. 这是一个从MethodDesc算起, 负5个字节的偏移量, 并且是每个方法继承的8个字节的填充的一部分.
那五个字节包含调用PreJitStub函数的指令. 这5字节的偏移量可以从SOS的DumpMT命令的结果输出中看到(图表11的MyClass), 因为MethodDesc总是在MethodSlot Table入口指向的位置往后数5个字节的位置上. 紧接着第一个调用, 一个对于JIT编译函数的调用被触发. 在编译结束之后, 这五个字节所包含的调用指令会被覆盖为一条无条件转移到JIT编译的x86的代码的jmp指令.
图表12 Method Descriptor
在图表12中的Method Table Slot入口指向的代码的反汇编结果中, 显示出了对于PreJitStub的调用. 这是一个删节了的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
只有从给定地址开始的五个字节内容是代码, 后面包含的是Method2的MethodDesc的数据. 这里的"!u"命令对这一点是不知情的, 所以生成了一些胡言乱语, 故尔你可以忽略那五个字节之后的任何东西(它们不是指令).
CodeOrIL在JIT编译之前, 包含方法实现的IL码的Relative Virtual Address(RVA). 这个数据域被一个标志位标识: 其中存储的是IL. 一经要求, CLR完成了编译之后, CLR会使用JIT处理过的代码的地址来更新这个数据域.让我们从列表中选择一个方法, 然后使用DumpMT命令dump出来JIT编译之前和之后的MethodDesc吧:
---------------------------------!DumpMD 0x00955268 Method Name : [DEFAULT] [hasThis] Void MyClass.Method2() MethodTable 9552a0 Module: 164008 mdToken: 06000006 Flags : 400 IL RVA : 00002068
编译之后, MethodDesc看起来像这样:
---------------------------------!DumpMD 0x00955268 Method Name : [DEFAULT] [hasThis] Void MyClass.Method2() MethodTable 9552a0 Module: 164008 mdToken: 06000006 Flags : 400 Method VA : 02c633e8
在方法描述器(method descriptor)中的Flags数据域会根据方法的类型来编码, 所谓方法类型是指: 静态方法, 实例方法, 接口方法, 或者是COM实现方法.
让我们看看MethodTable的复杂的另一面吧: Interface implementation.
托管环境下, Interface implementation被实现的看起来简单一些, 达到这个效果的方式是把所有的复杂度都吸收到布局过程中. 下一步, 我们将要show给你接口是如何布局的, 还有基于接口的方法分派(method dispatching)是如何工作的.
接口虚表映射和接口映射- Interface Vtable Map and Interface Map
=====================================================
在MethodTable中, 偏移量为12的位置, 存储着一个重要的指针, IVMap. 如图表9, IVMap指向一个AppDomain等级的映射表, 该映射表以一个进程等级的接口ID为索引. 这个接口ID是在接口类型第一次加载的时候生成的. 任何一个接口的实现都会有一个IVMap的入口. 如果MyInterface1被两个类实现了, 那么在接口的IVMap中就会有两个入口. 入口会指回到MyClass的方法表内嵌的sub-table的开头, 见图表9. 这个是method dispatching发生时, 基于接口的索引. IVMap是根据内嵌在方法表中的Interface Map的信息而创建出来的. Interface Map是在布局方法表的过程中, 根据类的metadata创建出来的. 一旦类型加载结束, 只有IVMap会在method dispatching中使用到.
Interface Map在偏移量28的位置, 它会指向内嵌在MethodTable中的InterfaceInfo的入口. 这样, 对于两个被MyClass实现了的接口中的任何一个接口, 都会有两个入口了.
第一个InterfaceInfo入口的头四个字节指向MyInterface1的TypeHandle(参考图表9和图表10).
接下来的WORD(两字节)被Flags占据(其中0是指继承自父类, 1指的是被当前类实现).
从Flags再接下来的WORD是Start Slot, 通过它, class loader得以编排接口实现的sub-table. 对于MyInterface1, 这个值是4, 意味slot 5 和slot 6指向implementation. 对于MyInterface2, 这个值是6, 所以, slot 7 和slot 8 指向implementation. 如果必要的话, ClassLoader会复制这些slot来创建illusion. 这里的illusion指在物理映射到相同的method descriptor的同时, 任何一个接口都得到了自己的实现. 在MyClass中, MyInterface1.Method2 和MyInterface2.Method2会指向相同的实现.
基于接口的方法分派(method dispatching)是通过IVMap发生的, 而直接的方法分派是通过各自槽的MethodDesc的地址发生的. 如前所述, .NET Framework使用fastcall这种调用约定. 如果可能的话, 头两个参数典型地被传入ECX和EDX寄存器(译注:参见文章汇编语言基础之六- 调用栈和各种调用约定的总结对比)(最左边参数, 通过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编译器将MethodDesc的地址直接的用在了代码中. 基于接口的方法分派是通过IVMap来发生的, 并且比直接分派需要多一些指令. 一条用来拿到IVMap的地址, 另一条拿到MethodTable中接口实现的start slot. 并且, 将一个对象实例转换为一个接口也仅仅是拷贝一下这个指针到目标变量当中就可以了. 在图表3 【译注:原文中这个地方时图表2,显然有错误,应该是图表3】中, 语句 "mi1 = mc;" 只用了一条指令就把mc中的OBJECTREF拷贝给了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
虚拟分派总是通过一个定死了的slot number来发生, 与给定的类的实现层次的MethodTable指针无关. 在MethodTable布局的时候, ClassLoader替换掉父类的实现, 而使用子类的实现. 结果, 对于父类对象的方法调用被分派到子类对象的实现上. 反汇编代码展现了分派通过slot number 8来发生的, debugger memory window中的DumpMT命令的输出结果也是这样(见图表10).
静态变量- Static Variables
========================
静态变量是MethodTable数据结构的重要组成部分. 他们在method table slot数组之后被分配在MethodTable上. 所有原始的静态类型都是内联的, 而静态值类型(结构体), 引用类型是通过AppDomain的handle table(句柄表)上的OBJECTREF来引用的. MethodTable中的OBJECTREF指向AppDomain的句柄表中的OBJECTREF, 这个OBJECTREF会使得堆上创建出来的对象实例一直存活下去, 直到AppDomain被卸载掉. 在图表9中, 一个静态的字符串变量, str, 指向句柄表中的OBJECTREF, 而这个OBJECTREF指向GC堆上的MyString.
EEClass
========================
EEClass在MethodTable被创建之前就存在了, 在与MethodTable结合的时候, 是一个类型声明的CLR版. 实际上, EEClass和MethodTable在逻辑上是一个数据结构(他们共同代表一个单个类型), 他们中的内容是基于使用频率不同而分开的. 非常经常使用的数据域存在MethodTable中, 而不太经常使用的数据域存在EEClass中. 所以, JIT编译函数需要的信息(比如说names, fields, 和offsets)就存在EEClass中, 然而, 运行时需要的信息(比如虚表slot和GC信息)就存在MethodTable中.
加载到AppDomain中的任何一个类型都有一个EEClass. 这里所说的类型包括: 接口, 类, 抽象类, 数组, 和结构体. 任何一个EEClass 都是被执行引擎跟踪的树的节点. 为了诸如:加载类, 布局MethodTable, 辨别类型, 类型转换,这样的目的, CLR使用这个网络来导航到需要的EEClass结构. EEClass之间的孩子到父亲的关系是基于继承关系来创建的, 然而, 从父亲到孩子的关系是建立在继承关系和类的加载顺序的联合的基础上的. 随着在托管代码的执行, 新的EEClass节点被一个个的添加, 旧的节点之间的关系被不断修补, 新的节点关系也被建立起来.
网络中EEClass的兄弟之间还有水平的关系呢. EEClass有三个数据域被用来建立加载起来的类型之间的关系: ParentClass, SiblingChain, 和ChildrenChain. 参见图表13, 来看看以图表4为上下文的MyClass的EEClass的扼要图解.
图表13展现了一些与我们的讨论相关联的数据域. 因为我们忽略了布局中的一些数据域, 我们并没有在这张图表中展现出真是的偏移量. EEClass有一个针对MethodTable的环形引用. EEClass也指向在默认AppDomain的高频堆中分配的MethodDesc块. 进程堆上有一个对FieldDesc对象列表的引用, 它提供了在MethodTable创建时的field布局信息. EEClass在AppDomain的低频堆上, 这样,操作系统可以更好的进行内存的页面管理, 因此减小了working set.
图表13 EEClass布局- EEClass Layout
================================
图表13中的其他数据域光看名字就能理解他们在MyClass(图表3)上下文中的作用了. 让我们看一下使用SOS工具dump出的EEClass的真实物理内存吧. 在mc.Method1这一行设定断点后, 运行图表3中的代码.首先, 通过运行命令!Name2EE获得MyClass的EEClass的地址:
------------------------------------
!Name2EE C:\Working\test\ClrInternals\Sample1.exe MyClass MethodTable: 009552a0 EEClass: 02ca3508 Name: MyClass
Name2EE命令的第一个参数是模块名称, 这个模块名称可以通过DumpDomain命令来获得. 现在我们有了EEClass的地址了, 我们来dump出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文件映射在内存中的metadata表的index.
指向System.Object. Sibling 链(图表13)的Parent类, 说明了他的加载是加载Program类的结果造成的.
MyClass有8个vtable slot(可以被虚拟分派的方法). 尽管Method1和Method2并不是虚拟方法, 它们在通过接口来分派的时候,还是被认为是虚函数并添加到列表中. 算上.cctor和.ctor到列表中, 这样你就一共有10个方法了. 这个类有两个静态域, 都被列在后面了. MyClass没有实例域. 其余的域都挺自我说明问题的.
结论
=========
我们一起游历了一下CLR中最重要的一些内部信息. 很显然, 还有很多方面需要被覆盖到, 并且还要更加深入, 但是我们希望这篇文章可以给你一个CLR怎么工作的大致印象. 这里展现的很多的信息在未来版本的CLR和.NET Framework中可能会改变, 但是尽管这篇文章覆盖到的数据结构更改了, 概念是不会变的.
-----------------------------------------------------------
作者介绍
Hanu Kommalapati is an Architect at Microsoft Gulf Coast District (Houston). In his current role at Microsoft, he helps enterprise customers in building scalable component frameworks based on the .NET Framework. He can be reached at hanuk@microsoft.com.
Tom Christian is an Escalation Engineer with Developer Support at Microsoft, working with ASP.NET and the .NET debugger extension for WinDBG (sos/psscor). He is based in Charlotte, NC and can be contacted at tomchris@microsoft.com.
后记: 很多学习.NET的资料都推荐这篇文章, <Windows用户态程序高效排错>中称这篇文章是字字珠玑, 于是学友舍得花两天的时间翻译,校对这篇文章. 主要目的是让自己能更深刻的理解文章所述及的技术细节. 希望你能和我一样, 看了这篇文章后能有所收获.
其实文章中涉及到的技术不仅仅是标题部分列出来的.NET Framework和C#。 如果读者懂一些汇编的基础知识,将会有更好的理解。我的博文中有个汇编基础的系列, 我觉得作为这篇文章的一点知识准备挺适合的.