原文: Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objectshtml
文章讨论了:程序员
SystemDomain, SharedDomain和Default Domain算法
对象布局和其余的内存细节chrome
方法表布局编程
方法分派bootstrap
文章使用的技术:数组
.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 2004Bugslayer 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 attomchris@microsoft.com.
后记: 不少学习.NET的资料都推荐这篇文章, <Windows用户态程序高效排错>中称这篇文章是字字珠玑, 因而学友舍得花两天的时间翻译,校对这篇文章. 主要目的是让本身能更深入的理解文章所述及的技术细节. 但愿你能和我同样, 看了这篇文章后能有所收获.
其实文章中涉及到的技术不只仅是标题部分列出来的.NET Framework和C#。 若是读者懂一些汇编的基础知识,将会有更好的理解。个人博文中有个汇编基础的系列, 我以为做为这篇文章的一点知识准备挺适合的.
看了这篇文章以后, 相信你也以为.net不像从前那么神秘了,对么? :)
好多长句, 理清楚从句之间的从属和修饰关系很累人. 若是忠于原文, 光看懂长句就要花上一两分钟, 因此仍是把长句子拆成了许多短句. 方便你们快速获取一些印象. 有的时候概念仍是英文的好懂些, 就没翻译的那么完全. 有些中英文概念都在紧随其后的括号中有另一个的注解. 第一次出现的名词, 通常用英文直接写出, 后面概念重复了的时候才适当翻译成中文. 总之, 以易读为目标组织语言.
不知道你们的习惯如何, 我的感受在银屏上读文章就但愿一目了然, 因此为了概念和结构的清晰, 原文的某些段落被用换行拆开了.
翻译水平有限, 技术水平也有限, 让读者见笑了. 原文的连接在页面的顶部. 若是有困惑能够对照着看看.
欢迎批评指正!
posted @ 2009-11-11 23:13 中道学友 阅读(1144) 评论(0) 编辑