【.Net】JIT And Run[翻译]

文章目录
  1. 1. 0x01 简介
    1. 1.1. 简要说明
    2. 1.2. Windbg 调试技巧
  2. 2. 0x02 主要内容
  3. 3. 由 CLR 引导创建域(Domains Created by the Bootstrap)
  4. 4. System Domain
  5. 5. SharedDomain
  6. 6. DefaultDomain
  7. 7. LoaderHeaps
  8. 8. Type Fundamentals(类型基础)
  9. 9. ObjectInstance
  10. 10. Son of Strike(SOS)
  11. 11. MethodTable
    1. 11.1. Base Instance Size
    2. 11.2. Method Slot Table
    3. 11.3. MethodDesc

概述:微软文档 JIT And Run 运行机制的翻译

关键字:#Method Layout #CLR #SystemDomain #SharedDomain #DefaultDomain

相关开源代码:

原文链接:.NET Framework Internals: How the CLR Creates Runtime Objects | Microsoft Learn - 阅力值 ⭐⭐⭐⭐

建议:在读这篇文章之前,你最好是有相关的 .Net 程序调试经验,并且理解 CLR。

相关文章推荐:

0x01 简介

简要说明

深入到 .Net 框架,查看 CLR 如何在运行时创建对象

这篇文章的主要论点:

  • SystemDomain,ShareDomain,以及 DefalutDomain
  • 对象布局以及内存细节
  • 方法表布局
  • 方法分派

Windbg 调试技巧

主要使用以下几个命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 添加 clrjit 模块加载断点,加载后再加载 sos 和 clr 插件
sxe ld clrjit.dll

.loadby sos clr

# 获取相关入口函数
!name2ee JITDetails.exe SimpleClass.Main

# 添加断点
!bpmd -md 00007ffaeb365ac0

# 查看具体的类型
!DumpHeap -type SimpleClass

0x02 主要内容

  • Domains Created by the CLR Bootstrap
  • System Domain
  • SharedDomain
  • DefaultDomain
  • LoaderHeaps
  • Type Fundamentals
  • ObjectInstance
  • MethodTable
  • Base Instance Size
  • Method Slot Table
  • MethodDesc
  • Interface Vtable Map and Interface Map
  • Virtual Dispatch
  • Static Variables
  • EEClass
  • Conclusion

在将来一段时间内(本文写于2019年10月18日),CLR 都将成为在 Windows 中构建应用的主要基础设施。必要的深入了解有助于我们构建高效、工业实力强大的应用程序。在本文中,作者就 CLR 内部结构,包括对象实例布局、方法表布局、方法调用、以及基于接口的方法调用和各种数据结构进行说明。

本文使用 C# 编写的简单示例,所以对任何语言语法的隐式引用都应该默认为 C#,讨论的一些数据结构和算法可能会在 Microsoft .Net 2.0 的框架进行更改,但是概念基本保持一致。作者使用的的环境为 Visual Studio .Net 2003 Debugger 和 调试扩展 SOS(Son of Strike),用以查看本文中所讨论的数据结构。SOS 可以帮助我们理解 CLR 内部数据结构并转储出有用的信息。有关将 SOS 加载到 Visual Sutio 的方法可以查看 SOS 一节。作者将描述在共享源 CLI(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
图一 SSCLI 引用

**环境信息:**本文是以 x86 平台上的 .Net Framework 1.1 为主。

由 CLR 引导创建域(Domains Created by the Bootstrap)

在 CLR 开始执行第一行托管代码之前,它就创建了三个应用程序域。其中两个在托管代码中是不透明的,甚至对 CLR 主机都不可见。它们只能通过由 shim——mscoree.dllmscorwks.dll (多处理器系统为 mscorsvr.dll) 辅助的 CLR 引导过程创建。如 图2 所示,这连个域分别是 System Domain 和 Shared Domain,两者均是单例。第三个域是 Default Domain,是 AppDomain 的一个实例,也是唯一命名的域。对于见得 CLR 主机(如控制台程序),默认域名由可执行程序名称组成,也可以使用 AppDomain.CreateDomain 在托管代码中创建附加域名,或使用 ICORRuntimeHost 接口在非托管主机代码中创建附加域名。复杂的主机(如 ASP.NET)会更具给定网站中应用程序的数量创建多个域。

System Domain

SystemDomain 负责创建和初始化 SharedDomainDefaultDomain。它将系统库 mscorlib.dll 加载到 SharedDomain。它还会以隐式或显式方式保留进程范围内的字符串变量。

字符串驻留(String Interning)是 .Net Framework 1.1 中的一个优化特性,由于 CLR 程序集不允许使用该功能,因此该功能显得有些苛刻。尽管如此,通过在所有应用程序域中对给定字面只使用一个字符串实例,它还是比较节省内存。

SystemDomain 还负责生成进程范围内的接口 ID,这些 ID 用于在每个 AppDomain 中创建 InterfaceVtableMaps。系统域会跟踪进程中的所有域,并实现加载和卸载 AppDomain 的功能。

SharedDomain

所有的中性域代码都会被加载到 SharedDomain 中。所有的 AppDomain 中的用户代码都需要系统库 mscorlibmscorlib 会自动加载到共享域中。在 CLR 引导过程中,System 命名空间中的基本类型(如 Object、ValueType、Array、Enum、String 和 Delegate)会被预加载到此域中。在调用 CorBindToRuntimeEx 时,用户代码也可以通过 CLR 托管应用程序指定的 LoaderOptimization 属性来加载到该域。控制台程序可以通过在应用程序的 Main 方法中注释 System.LoaderOptimizationAttribute 来将代码加载到 SharedDomainSharedDomain 还管理一个以基址为索引的程序集映射,该映射是一个查找表,用于管理加载到 DefaultDomain 的程序集和托管代码中创建的其他 AppDomain 的共享依赖关系。DefaultDomain 是加载非共享用户代码的地方。

DefaultDomain

DefaultDomainAppDomain 的一个实例,应用程序代码通常在其中执行。虽然某些应用程序需要再运行时创建额外的 AppDomain (如采用插件架构的应用程序或在运行时生成大量代码的应用程序),但大多数应用程序都会在其生命周期内创建一个域。在该域中执行的所有代码都在域级别上进行上下文绑定。如果应用程序有多个 AppDomain,任何跨域访问豆浆通过 .Net Remoting 代理进行。可以使用继承自 System.ContextBoundObject 的类型创建其他域内上下文边界。每个 AppDomain 都有自己的 SecurityDescriptorSecurityContextDefaultContext,以及自己的加载器堆(高频堆、低频堆和存根堆)、处理表(处理表、大对象堆处理表)、接口表映射管理器和程序集缓存。

LoaderHeaps

LoaderHeaps 用于加载各种运行时 CLR 工件和优化工件,这些工件在域的声明周期中一直存在。这些堆按可预测的快增长,以尽量减少碎片。LoaderHeaps 与垃圾回收器(GC)堆(或对称多处理器或 SMP 的多个堆)不同,GC 堆负责托管对象实例,而 LoaderHeaps 则负责托管类型系统。像 MethodTablesMethodDescsFiledDescsInterface Maps 这样经常访问的工具会在 HighFrequencyHeap 上分配,而像 EEClassClassLoader 及其查找表这样访问频率较低的数据结构会在 LowFrequencyHeap 上分配。存根堆(StubHeap) 承载着便于代码访问安全(CAS)、COM 封装调用和 P/Invoke 的存根。

在对域和 LoaderHeaps 进行了高层次的研究之后,作者结合了如下所示的代码来说明相关的实现细节。

我这边编译是基于 .Net Framework 4.7 版本,与作者所使用的版本不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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();
}
}

编译后在 Windbg 调试,需要在 clrjit 被加载后再加载 sosclr

1
2
# 添加 clrjit 加载断点
sxe ld clrjit.dll

断点触发后加载 sosclr

1
.loadby sos clr

查看域信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
0:000> !DumpDomain
--------------------------------------
System Domain: 00007ffb4b3f5250
LowFrequencyHeap: 00007ffb4b3f57c8
HighFrequencyHeap: 00007ffb4b3f5858
StubHeap: 00007ffb4b3f58e8
Stage: OPEN
Name: None
--------------------------------------
Shared Domain: 00007ffb4b3f4c80
LowFrequencyHeap: 00007ffb4b3f57c8
HighFrequencyHeap: 00007ffb4b3f5858
StubHeap: 00007ffb4b3f58e8
Stage: OPEN
Name: None
Assembly: 00000205800ddfc0 [C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll]
ClassLoader: 00000205800de110
Module Name
00007ffb475a1000 C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll

--------------------------------------
Domain 1: 0000020580043c10
LowFrequencyHeap: 0000020580044408
HighFrequencyHeap: 0000020580044498
StubHeap: 0000020580044528
Stage: OPEN
SecurityDescriptor: 0000020580046230
Name: JITDetails.exe
Assembly: 00000205800ddfc0 [C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll]
ClassLoader: 00000205800de110
SecurityDescriptor: 00000205800dbc00
Module Name
00007ffb475a1000 C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll

Assembly: 00000205800f8ad0 [D:\Documents\A_Source\Windows-API-Usage\.Net-Code\JITDetails\bin\Debug\JITDetails.exe]
ClassLoader: 00000205800f8c20
SecurityDescriptor: 00000205800f4ac0
Module Name
00007ffaeb3841b8 D:\Documents\A_Source\Windows-API-Usage\.Net-Code\JITDetails\bin\Debug\JITDetails.exe

如上所示,程序名为 JITDetails,可以看到每个 AppDomain 中都分配了一个HighExpresencyHeap、LowFrequencyHeap 和 StubHeap。在上述的输出中,只能看到 SharedDomain 和 Domain 1 是具有相同的 ClassLoader。需要注意区分的是,在作者的版本中,也就是如下所示的代码中 SystemDomain 和 ShareDomain 是具有相同的 ClassLoader,AppDomain 则自己单独拥有一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
!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

输出内容显示加载器的预留和提交大小。HighFrequencyHeap 的初始预留大小为 32KB,提交大小为 4KB。LowFrequencyHeap 和 StubHeap 的初始预留大小为 8KB,提交大小为 4KB。SOS 输出中未显示的还有 InterfaceVtableMap 堆。每个域都有一个 InterfaceVtableMap(IVMap),在域初始化阶段创建于自己的 LoaderHeap 上。 IVMap 堆保留 4KB,并在初始化时提交 4KB。后续章节会讨论到 IVMap。

!DumpDomain 输出显示了默认的进程堆、JIT代码对、GC堆(用于小对象)和大对象堆(用于大小为 85000 或更多字节的对象),以说明这些堆与加载器之间的语义区别。即时(JIT)编译器生成 x86 指令并将其存储在 JIT代码堆中。 GC 堆和大型对象是垃圾回收堆,托管对象就在这些堆上实例化。

Type Fundamentals(类型基础)

类型是 .Net 编程的基本单元。在 C# 中,可以使用类、结构和接口关键字来声明类型。大多数类型都是由程序员显式创建的,但在特殊的互操作情况和远程对象调用(.Net Remoting)情况下,.Net CLR 会隐式生成类型。这些生成的类型包括 COM 和 Runtime Callable Wrapers(可调用包装) 以及 Transparent Proxies(透明代理)。

作者从包含对象引用的堆栈帧开始,来探索 .Net 类型的基本原理(通常,堆栈是对象实例开始运行的位置之一)。如下所示的代码包含一个简单的程序,其包含一个调用静态方法的控制台入口点。方法1创建了一个 SmallClass 类型的实例,其中包含了一个字节数组,用于演示在大型对象堆上创建对象实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 Program
{
public static void Main(string[] args)
{
SmallClass smallObj = Program.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;
}
}

如下图是一个典型的 fastcall 栈帧快照,断点位于 Create 方法中的 return smallObj;。(补充一下 fastcall 的调用约定,它规定函数的参数尽可能通过寄存器来传奇,所有其他参数从右向左传递到堆栈,然后由被调用函数弹出)。值类型的局部变量 objSize 被内联到堆栈框架中。像 smallObj 这样的引用类型变量以固定大小(4 字节 DWORD)存储在对战中,并包含在正常 GC 堆上分配的对象实例地址。在传统 C++ 中,这是一个对象指针;而在托管代码中,这是一个对象引用。尽管如此,它还是包含了一个对象实例的地址。这里使用 ObjectInstance 来表示位于对象引用所指向地址的数据结构。

正常 GC 堆上的 smallObj 对象实例包含一个名为 _largeObjByte[],其大小为 85000 字节(图示为 85016 字节,这是实际的存储大小,额外的16字节用于存储数组的大小)。CLR 对大小大于或等于 85000 字节的对象的处理方式与较小的独享不同。大对象在大对象堆(LOH)上分配,而小对象则在普通 GC 堆上创建,这样可以优化对象分配和来接回收。LOH 不会被压缩,而 GC 堆在每次 GC 回收时都会被压缩。此外,LOH 只在 GC 全部收集时才会被收集。

MethodTable 作为一个比较重要的结构体成员指向 EEClass。 在 MethodTable 被填充之前,CLR 类加载器会根据元数据创建 EEClass。在上述所示的代码中,SmallClass 的 MethodTable 和 EEClass 通常在特定域的加载器堆上分配。 Byte[] 是一个特例,MethodTable 和 EEClass 是在共享域的加载器堆上分配的。加载器堆是特定于 AppDomain 的,这里已经提到的任何数据结构一旦加载,在 AppDomain 被卸载之前都不会小时。此外,默认的 AppDomain 无法卸载,因为代码会一直存在,知道 CLR 关闭。

ObjectInstance

如前所述,值类型的所有实例要么在线程栈上内联,要么在 GC 堆上内联。 所有引用类型都在 GC 堆或 LOH 上创建。下图显示了典型的对象实例布局。 对象可以从基于堆栈的局部变量、互操作或 P/Invoke 应用程序中的句柄表、寄存器(执行方法时的本指针和方法参数)或具有终结器方法的对象的终结器队列中引用。

  • OBJECTREF 并不指向对象实例的开头,而是指向一个 DWORD 偏移量(4 字节)。 该 DWORD 称为 “对象头”,并保存 SyncTableEntry 表的索引(基于 1 的同步布尔运算数)。 由于链是通过索引进行的,因此 CLR 可以在内存中移动表,同时根据需要增加表的大小。
  • SyncTableEntry 会维护一个指向对象的弱引用,以便 CLR 可以跟踪 SyncBlock 的所有权。 弱引用使 GC 能够在不存在其他强引用的情况下收集对象。 SyncTableEntry 还存储一个指向 SyncBlock 的指针,该指针包含有用的信息,但对象的所有实例很少需要这些信息。 这些信息包括对象的锁定、哈希代码、任何增量数据及其 AppDomain 索引。 对于大多数对象实例来说,没有为实际同步块分配存储空间,因此同步块编号为零。

当执行线程执行 lock(obj) 或 obj.GetHashCode 等语句时,情况就会发生变化,如图所示:

1
2
3
4
5
6
7
SmallClass obj = new SmallClass()
// Do some work here
lock(obj)
{
/* Do some synchronized work here */
}
obj.GetHashCode();

在上述代码中,smallObject 将使用 0 (无 syncblk)作为其起始 syncblk 编号。锁定语句会导致 CLR 创建一个 syncblk 条目,并用相应的编号更新对象头,由于 C# 锁定关键字扩展为使用监视器类的 try-finally,因此会在 syncblk 上创建一个监视器对象用于同步。调用 GetHashCode 方法会将对象哈希代码填充到 syncblk 中。

  • SyncBlock 中还有其他字段,这些字段用于 COM 互操作和将委托传递给非托管底阿妈,但与典型的对象用法无关。
  • TypeHandle 紧随 ObjectInstance 中的 syncblk 编号。为了保持连续性,作者会在后续讨论单例变量时在讨论 TypeHandle。TypeHandle 后面是 Instance 的字段的变量列表。默认情况下,实例字段将以这样一种方式打包:高效使用内存,并尽量减少对齐填充。如下所示代码显示了一个 SimpleClass,其中包含了大量大小不一的实例变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
}
}

如下图展示了VS 调试下 SimpleClass 对象实例的内存布局。使用上述代码在 return; 语句处添加断点,寄存器 ECX 包含了 simpleObj 对象的地址,可以直接通过内存窗口来查看。

  • 第一个 4-byte 块是 syncblk 号。由于没有呀在任何同步代码中使用该实例(或访问其 HashCode),因此将其设置为了 0。
  • 对象引用存储在堆栈变量中。指向从偏移量 4 开始的 4 个字节。字节变量 b1、b2、b3、b4 全部并排填充。
  • 两个 short 类型的变量 s1 和 s2 被放在一起。
  • 字符串变量 str 是一个 4 字节的 OBJECTTREE,指向位于 GC 堆上的字符串实际实例。字符串是一种特殊类型,在程序集加载过程中,所有包含相同文字的实例都指向全局字符串表中的相同实例。这一过程被称为字符串互操作(String Interning),旨在优化内存使用。

在 .Net Framwwork 1.1 中,程序集不能选择退出这种糊掉过程,不过 CLR 的未来版本可能会提供这种功能。(文章写于2019年,截止目前)

在 .Net Framework 4.7 x64 版本下内存布局如下所示:

l1 图示有误,应该是 8-byte。

因此,默认情况下,源代码中成员变量的词法序列不会在内存中保留。在需要将词法序列带入内存的互操作场景中,可以使用 StructLayoutAttribute,这个属性使用 LayoutKind 枚举作为参数。

  • LayoutKind.Squential(连续布局):将为编译后的数据保持词法顺序,但是在 .Net Framework 1.1 版本中不会影响托管布局,从 .Net Framework 2.0 中才会生效。

  • LayoutKind.Explicit:显式指定每个字段的偏移量,需要配合 FieldOffset 字段来使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    [StructLayout(LayoutKind.Explicit)]
    public struct MyStruct
    {
    [FieldOffset(0)]
    public int x;

    [FieldOffset(4)]
    public double y;

    [FieldOffset(12)]
    public char z;
    }
  • LayoutKind.Auto:允许运行时自动排列字段以优化性能

在查看了原始内存内容后,可以进一步借助 SOS 来查看对象实例。借助 DumpHeap 命令可以列出所有堆内容和特定类型的所有实例。

DumpHeap 可以显示我们创建的唯一实例的地址,并且不依赖寄存器:

1
2
3
4
5
6
7
8
!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

如下为 .Net Framework 4.7 x64 下的调试输出:

1
2
3
4
5
6
7
8
!DumpHeap -type SimpleClass
Address MT Size
0000024801412db0 00007ffaeb3a5ba0 48

Statistics:
MT Count TotalSize Class Name
00007ffaeb3a5ba0 1 48 JITDetails.SimpleClass
Total 1 objects

以下分析以作者所用 .Net Framework 版本为例,对象的总大小为 36 字节。无论字符串有多大,SimpleClass 的实例都只包含 DWORD OBJECTREF。SimpleClass 的实例变量只占 28 个字节。剩下的 8 个字节由 TypeHandle(4字节) 和 syncblk 编号(4字节)组成。找到 SimpleOnj 实例的地址后,可以继续查看下实例的内容,如下所示:

1
2
3
4
5
6
!ObjSize 0x00a8197c 
sizeof(00a8197c) = 72 ( 0x48) bytes (SimpleClass)

在 .Net Framework 4.7 x64 下输出:
0:000> !ObjSize 0000024801412db0
sizeof(0000024801412db0) = 96 (0x60) bytes (JITDetails.SimpleClass)

Son of Strike(SOS)

在本文中,SOS 调试器扩展用于显示 CLR 数据结构的内容。它是 .Net Framework 安装的一部分,位于 %windir%\Microsoft.NET\Framework\v1.1.4322 目录下。

SOS 调试

(当然我还是建议直接使用 Windbg 进行调试,非得使用 vs 调试的话可以参考这篇文章:VisualStudio中集成扩展调试SOS - 活着的虫子 - 博客园)

  1. 在将 SOS 载入进程之前,请在 Visual Studio .Net 的项目属性中启用托管调试;
  2. 将 SOS.dll 所在目录添加到 PATH 环境变量中;
  3. 要加载 SOS.dll 请在断点处打开调试器 -> 窗口 -> 立即窗口
  4. 在立即窗口中,执行 .load sos.dll

如果从对象的总大小(72字节)中减去 SimpleClass 实例的大小(36字节),就会得到 str 的大小,即 36 字节。可以通过转储 str 实例来验证这一点,输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
!DumpObj 0x00a819a0 
Name: System.String
MethodTable 0x009742d8
EEClass 0x02c4c6c4
Size 36(0x24) bytes

# .Net Framework 4.7 版本下的输出
0:000> !DumpObj /d 000002cdc8cc2de0
Name: System.String
MethodTable: 00007ffb475f07a0
EEClass: 00007ffb475a4868
Size: 42(0x2a) bytes
File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
String: MyString
Fields:
MT Field Offset Type VT Attr Value Name
00007ffb475f3368 4000283 8 System.Int32 1 instance 8 m_stringLength
00007ffb475f1610 4000284 c System.Char 1 instance 4d m_firstChar
00007ffb475f07a0 4000288 e0 System.String 0 shared static Empty
>> Domain:Value 000002cdc7143c60:NotInit <<

如果将字符串实例 str 的大小(36字节)与 SimpleClass 实例的大小(36字节)相加,ObjSize 命令报告的总大小为 72 字节。

注意,ObjSize 不包括 syncbl 基础结构占用的内存。此外,在 .Net Framework 1.1 中,CLR 不知道 GDI 对象、 COM 独享、文件句柄等非托管资源占用的内存,因此该命令不会显示相关内容。

TypeHandle 是指向 MethodTable 的指针,位于 syncblk 编号之后。在创建对象实施之前,CLR 会查找已加载的类型,如果为找到,则加载该类型,获取 MethodTable 地址,创建对象实例,并用 TypeHandle 值填充对象实例。JIT 编译器生成的代码会使用 TypeHandle 来查找用于派遣方法的 MethodTable。每当 CLR 需要通过 MethodTable 回溯已加载的类型时,都会使用 TypeHandle。

MethodTable

每个类和接口加载到 AppDomain 时,都将在内存中以 MethodTable 的数据结构来表示。这是在创建对象的第一个实例之前进行类加载活动的结果。 ObjectInstance 代表状态,而 MethodTable 则代表行为。 MethodTable 通过 EEClass 将对象实例与语言编译器生成的内存映射元数据结构绑定。可通过 System.Type 在托管代码中访问 MethodTable 中的信息和挂在其上的数据结构。即使在托管代码中,也可以通过 Type.RuntimeTypeHandle 属性获取指向 MethodTable 的指针。 TypeHandle 包含在对象实例中,指向从 MethodTable 开始的偏移量。该偏移量默认为 12 字节,包含 GC 信息。

如下图所示为 MethodTable 的典型布局,包含了所有信息。作者就其中 TypeHandle 里的一些重要字段进行了说明,从实例大小开始(一般与运行时配置文件相关)

Base Instance Size

基实例大小是类加载器根据代码中的字段声明计算出的对象大小。基于前文的讨论,当前的 GC 实体需要一个至少 12 字节的对象实例。如果一个类没有定义任何字段,则会有4个字节的开销。剩下的 8 个字节将由对象头(可能包含一个 syncblk 编号)和 TypeHandle 占用。同样,对象的大小可以收结构布局(StructLayoutAttribute)属性的影响。

Method Slot Table

MethodTable 中包含一个 MethodSlotTable,该表指向相应的方法描述符(MethodDesc),从而实现该类型的行为。MethodSlotTable 是根据以下顺序排列的线性化实现方法列表创建的:

  1. 继承的虚拟函数
  2. 引入的虚拟函数
  3. 实例函数
  4. 静态函数

ClassLoader 会浏览当前类、父类和接口的元数据,并创建方法表。在申请并填充内存过程中,它会替换任何重载的虚拟方法,替换任何被隐藏的父类方法,创建新的插槽(slot),并在必要时复制插槽。槽的重复是必要的,这样可以造成一种错觉,即每个接口都有自己的 mini vtable。然而,重复的插槽指向的是同一个物理实现。

MyClass 有三个实例方法、一个类构造函数(.cctor)和一个对象构造函数(.ctor)。类的构造函数有编译器生成,因为我们定义并初始化了一个静态变量。如下图所示显示了 MyClass 的方法表布局。该布局显示了 10 个方法,因为 IVMap 的 Method2 插槽重复了,这一过程会在后文中说明。

如下所示,显示了 MyClass 的 MethodTable 的相关信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
!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()

# .Net Framework 4.7 版本下的打印
0:000> !DumpHeap -type MyClass
Address MT Size
00000172558d2de0 00007ffaeb385d18 24

Statistics:
MT Count TotalSize Class Name
00007ffaeb385d18 1 24 MyClass
Total 1 objects
0:000> !DumpMT -MD 00007ffaeb385d18
EEClass: 00007ffaeb382718
Module: 00007ffaeb3841b8
Name: MyClass
mdToken: 0000000002000004
File: D:\Documents\A_Source\Windows-API-Usage\.Net-Code\JITDetails\bin\Debug\JITDetails.exe
BaseSize: 0x18
ComponentSize: 0x0
Slots in VTable: 9
Number of IFaces in IFaceMap: 2
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
00007ffb47aa77a0 00007ffb475ac7a0 PreJIT System.Object.ToString()
00007ffb47b1f2c0 00007ffb477459b0 PreJIT System.Object.Equals(System.Object)
00007ffb47b0cbb0 00007ffb477459d8 PreJIT System.Object.GetHashCode()
00007ffb47b1cad0 00007ffb477459e0 PreJIT System.Object.Finalize()
00007ffaeb4904d8 00007ffaeb385cf0 NONE MyClass.Method1()
00007ffaeb4904e0 00007ffaeb385cf8 NONE MyClass.Method2()
00007ffaeb4904e8 00007ffaeb385d00 NONE MyClass.Method3()
00007ffaeb490890 00007ffaeb385d10 JIT MyClass..cctor()
00007ffaeb490a20 00007ffaeb385d08 JIT MyClass..ctor()

任何类型的前四个方法总是 ToStringEqualsGetHashCodeFinalize

这些都是从 System.Object 继承而来的虚拟方法。 Method2 插槽是重复的,但两者都指向同一个方法描述符。 显式编码的 .cctor.ctor 将分别与静态方法和实例方法分组。

MethodDesc

方法描述符(MethodDesc)是 CLR 所知的方法实现的封装。除了托管实现之外,还有几种类型的方法描述符可以方便地调用各种互操作实现。本文仅就前文所述的 MyClass 来说明 MethodDescMethodDesc 是作为类的一部分在生成时被填充的,最初指向中间语言(IL)。每个 MethodDesc 都用 PreJitStub 填充,PreJitStub 负责触发 JIT 编译。如下图所示,是一个典型的 MethodDesc 结构布局。方法表槽条目实际上指向存根,而不是实际的 MethodDesc 数据结构。这与实际 MethodDesc 的偏移量为 -5 字节,是每个方法继承的 8 字节填充的一部分。这 5 个字节包含调用 PreJitStub 例程的指令。这个 5 字节的偏移量可以从 SOS 的 DumpMT 输出中看到,因为 MethodDesc 总是在 Method Slot Table 条目指向的位置之后 5 字节。在第一次调用时,调用 JIT 编译例程。编译完成后,包含调用指令的 5 个字节将被无条件跳转到 JIT 编译的 x86 代码所覆盖。

对上图中 Method Table Slot 指向的代码进行反汇编,将显示对 PreJitStub 的调用,输出如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
!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

# 在 .Net Framework x64 下输出如下所示:
0:000> !DumpMD /d 00007ffaeb3a5cf8
Method Name: MyClass.Method2()
Class: 00007ffaeb3a2718
MethodTable: 00007ffaeb3a5d18
mdToken: 0000000006000006
Module: 00007ffaeb3a41b8
IsJitted: no
CodeAddr: ffffffffffffffff
Transparency: Critical

接下里,执行 Method1 之后再反汇编相同地址查看,输出如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
!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

# 在 .Net Framework x64 下输出如下所示:
0:000> !DumpMD /d 00007ffaeb3a5cf0
Method Name: MyClass.Method1()
Class: 00007ffaeb3a2718
MethodTable: 00007ffaeb3a5d18
mdToken: 0000000006000005
Module: 00007ffaeb3a41b8
IsJitted: yes
CodeAddr: 00007ffaeb4b0a70
Transparency: Critical

只有地址的前 5 个字节是代码,其余的都是 Method2 的 MethodDesc 数据。 !U 命令不知道这一点,会生成乱码,因此可以忽略前 5 个字节之后的内容。

JIT 编译钱的 CodeOrIL 包含 IL 方法实现的相对虚拟地址(RVA)。该字段被标记为 IL。按需编译后,CLR 会用 JIT 代码的地址更新该字段。如下所示为在 JIT 编译前后使用 DumpMT 命令转储 MethodDesc 的输出:

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

JIT 编译后:

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