深入了解 .NET 对象分配基础知识






4.92/5 (51投票s)
确切了解 .NET 中对象分配时发生的情况,为什么在一般情况下它非常高效,以及如何触发较慢的代码路径。
引言
虽然理解垃圾回收基础知识对于使用 .NET 至关重要,但了解对象分配的工作原理同样重要。它向您展示了它的简单性和高性能,特别是与原生堆分配可能存在的阻塞性相比。在一个大型、原生、多线程的应用程序中,堆分配可能是主要的性能瓶颈,这需要您执行各种自定义堆管理技术。由于许多这些细节都隐藏在操作系统分配 API 后面,因此也很难衡量这种情况何时发生。更重要的是,理解这一点将为您提供如何出错以及如何使对象分配效率大大降低的线索。
在本文中,我想举一个来自《编写高性能 .NET 代码》第二章的例子,然后通过一些书中未涵盖的附加示例进一步扩展。
在调试器中查看对象分配
让我们从一个简单的对象定义开始:完全为空。
class MyObject
{
}
static void Main(string[] args)
{
var x = new MyObject();
}
为了检查分配时发生的情况,我们需要使用“真正”的调试器,例如 Windbg。不要害怕它。如果您需要快速入门指南,请查看此页面上的免费示例章节,它将帮助您立即上手。它并没有您想象的那么糟糕。
以 Release 模式将上述程序编译为 x86(您可以选择 x64,但下面的示例是 x86)。
在 Windbg 中,按照以下步骤启动并调试程序:
- 按 Ctrl+E 执行程序。导航到并打开已编译的可执行文件。
- 运行命令:
sxe ld clrjit
(这会告诉调试器在加载名称中包含 clrjit 的任何程序集时中断,您需要在下一步之前加载它) - 运行命令:
g
(继续执行) - 当它中断时,运行命令:
.loadby sos clr
(加载 .NET 调试工具) - 运行命令:
!bpmd ObjectAllocationFundamentals Program.Main
(在方法的开头设置断点。第一个参数是程序集名称。第二个参数是方法名称,包括它所属的类。) - 运行命令:
g
执行将在 Main
方法的开头中断,就在调用 new()
之前。打开反汇编窗口以查看代码。
以下是 Main
方法的代码,已添加注释以供清晰理解:
; Copy method table pointer for the class into
; ecx as argument to new()
; You can use !dumpmt to examine this value.
mov ecx,006f3864h
; Call new
call 006e2100
; Copy return value (address of object) into a register
mov edi,eax
请注意,每次执行程序时,实际地址都会不同。单步执行(F10 或工具栏)几次,直到 call 006e2100
(或您的等效项)被高亮显示。然后单步进入(F11)。现在您将看到 .NET 中的主要分配机制。它非常简单。本质上,在当前的 gen0
段的末尾,有一个预留的位空间,我称之为分配缓冲区。如果尝试的分配可以在其中容纳,我们可以更新一些值并立即返回,而无需进行更复杂的工作。
如果我用伪代码概括一下,它看起来会是这样:
if (object fits in current allocation buffer)
{
Increment a pointer, return address;
}
else
{
call JIT_New to do more complicated work in CLR
}
实际的汇编代码看起来是这样的:
; Set eax to value 0x0c, the size of the object to
; allocate, which comes from the method table
006e2100 8b4104 mov eax,dword ptr [ecx+4] ds:002b:006f3868=0000000c
; Put allocation buffer information into edx
006e2103 648b15300e0000 mov edx,dword ptr fs:[0E30h]
; edx+40 contains the address of the next available byte
; for allocation. Add that value to the desired size.
006e210a 034240 add eax,dword ptr [edx+40h]
; Compare the intended allocation against the
; end of the allocation buffer.
006e210d 3b4244 cmp eax,dword ptr [edx+44h]
; If we spill over the allocation buffer,
; jump to the slow path
006e2110 7709 ja 006e211b
; update the pointer to the next free
; byte (0x0c bytes past old value)
006e2112 894240 mov dword ptr [edx+40h],eax
; Subtract the object size from the pointer to
; get to the start of the new obj
006e2115 2b4104 sub eax,dword ptr [ecx+4]
; Put the method table pointer into the
; first 4 bytes of the object.
; eax now points to new object
006e2118 8908 mov dword ptr [eax],ecx
; Return to caller
006e211a c3 ret
; Slow Path - call into CLR method
006e211b e914145f71 jmp clr!JIT_New (71cd3534)
在快速路径中,只有 9 条指令,包括返回。这非常高效,特别是与 malloc
这样的东西相比。是的,这种复杂性是以对象生命周期结束时的性能为代价的,但到目前为止,这看起来相当不错!
在慢速路径中会发生什么?简而言之,很多。以下都可能发生:
- 需要定位 gen0 中的某个可用槽。
- 触发 gen0 GC。
- 触发完全 GC。
- 需要从操作系统分配新的内存段并将其分配给 GC 堆。
- 带有终结器的对象需要额外的簿记。
- 可能还有更多...
另一个值得注意的地方是对象的大小:0x0c
(十进制 12)字节。如其他地方所述,这是 32 位进程中对象的最小大小,即使没有字段。
现在让我们对具有单个 int
字段的对象进行相同的实验。
class MyObjectWithInt { int x; }
按照上面的相同步骤进入分配代码。
在我的运行中,分配器的第一行是:
00882100 8b4104 mov eax,dword ptr [ecx+4] ds:002b:00893874=0000000c
唯一有趣的是,对象的大小(0x0c)与之前完全相同。新的 int
字段适合最小大小。您可以通过使用 !DumpObject
命令(或缩写版本:!do
)检查对象来看到这一点。要获取对象分配后的地址,请单步执行指令,直到到达 ret
指令。对象地址现在在 eax
寄存器中,因此打开“寄存器”视图并查看值。在我的计算机上,它的值为 2372770。现在执行命令:!do 2372770
您应该会看到与此类似的输出:
0:000> !do 2372770
Name: ConsoleApplication1.MyObjectWithInt
MethodTable: 00893870
EEClass: 008913dc
Size: 12(0xc) bytes
File: D:\Ben\My Documents\Visual Studio 2013\Projects\ConsoleApplication1\ConsoleApplication1\bin\Release\ConsoleApplication1.exe
Fields:
MT Field Offset Type VT Attr Value Name
70f63b04 4000001 4 System.Int32 1 instance 0 x
这很奇怪。字段位于偏移量 4(int
的长度为 4),因此仅占 8 个字节(范围 0-7)。偏移量 0(即对象的地址)包含方法表指针,那么其他 4 个字节在哪里?这就是同步块,它们实际上位于偏移量 -4 字节处,位于对象地址之前。这就是 12 个字节。
尝试使用 long
。
class MyObjectWithLong { long x; }
在我的运行中,分配器的第一行现在是:
00f22100 8b4104 mov eax,dword ptr [ecx+4] ds:002b:00f33874=00000010
显示大小为 0x10(十进制 16 字节),这现在符合我们的预期。12 字节的最小对象大小,但已包含 4 字节的开销,因此为 8 字节的 long
额外增加了 4 个字节。对分配的对象进行检查也显示对象大小为 16 字节。
0:000> !do 2932770
Name: ConsoleApplication1.MyObjectWithLong
MethodTable: 00f33870
EEClass: 00f313dc
Size: 16(0x10) bytes
File: D:\Ben\My Documents\Visual Studio 2013\Projects\ConsoleApplication1\ConsoleApplication1\bin\Release\ConsoleApplication1.exe
Fields:
MT Field Offset Type VT Attr Value Name
70f5b524 4000002 4 System.Int64 1 instance 0 x
如果您将对象引用放入测试类中,您会看到与 int
相同的情况。
终结器
现在让我们让它变得更有趣。如果对象有终结器会怎样?您可能听说过具有终结器的对象在 GC 期间会产生更多开销。这是真的——它们会存活得更久,需要更多的 CPU 周期,并且通常会降低效率。但是终结器也会影响对象分配吗?
回想一下,我们上面的 Main
方法看起来是这样的:
mov ecx,006f3864h
call 006e2100
mov edi,eax
但是,如果对象有终结器,它看起来是这样的:
mov ecx,119386Ch
call clr!JIT_New (71cd3534)
mov esi,eax
我们失去了我们巧妙的分配助手!现在我们必须直接跳转到 JIT_New
。分配具有终结器的对象比普通对象慢得多。需要修改更多内部 CLR 结构来跟踪此对象的生命周期。成本不仅仅在对象生命周期的结束。
它慢多少?在我自己的测试中,它似乎比分配普通对象的快速路径慢 8-10 倍。如果您分配大量对象,这种差异是相当可观的。因为这个原因以及其他原因,除非真的需要,否则不要添加终结器。
调用构造函数
如果您非常仔细,您可能会注意到在分配对象后没有调用构造函数来初始化对象。分配器正在更改一些指针,返回一个对象给您,并且没有对该对象进行进一步的函数调用。这是因为属于类字段的内存总是被预先初始化为 0
,并且这些对象没有进一步的初始化要求。让我们看看如果我们更改为以下定义会发生什么:
class MyObjectWithInt { int x = 13; }
现在 Main
函数看起来是这样的:
mov ecx,0A43834h
; Allocate memory
call 00a32100
; Copy object address to esi
mov esi,eax
; Set object + 4 to value 0x0D (13 decimal)
mov dword ptr [esi+4],0Dh
字段初始化已内联到调用者中!
请注意,这段代码是完全等效的:
class MyObjectWithInt { int x; public MyObjectWithInt() { this.x = 13; } }
但是如果我们这样做呢?
class MyObjectWithInt
{
int x;
[MethodImpl(MethodImplOptions.NoInlining)]
public MyObjectWithInt()
{
this.x = 13;
}
}
这会显式禁用对象构造函数的内联。还有其他方法可以防止内联,但这是最直接的方法。
现在我们可以在内存分配之后看到对构造函数的调用:
mov ecx,0F43834h
call 00f32100
mov esi,eax
mov ecx,esi
call dword ptr ds:[0F43854h]
读者练习
您能否使上面显示的分配器跳转到慢速路径?分配请求必须有多大才能触发此操作?(提示:尝试分配不同大小的数组。)您可以通过检查正在运行的代码中的寄存器和其他值来弄清楚这一点吗?
摘要
您可以看到,在大多数情况下,.NET 中对象的分配都非常快速高效,在简单情况下不需要调用 CLR 也不需要复杂的算法。除非绝对需要,否则避免使用终结器。它们不仅在垃圾回收的清理过程中效率较低,而且分配起来也更慢。
在调试器中玩弄示例代码,亲自感受一下。