C#查漏补缺----对象内存结构与布局
环境变量
.Net Core 8.0
Windows 11 64位
内存布局
引用类型
在.NET中,数据会按照类型分为不同的对象,对于引用类型的实例,由一个对象标头(Object Header)和方法表(MethodTable)以及字段值组成
- 对象标头(Object Header):按照CLR的描述,标头存储了“对象上的所有附加信息”。在32位平台上占用4字节,64位平台上占用8字节(仅使用后4个字节,以0填充前4个字节仅用于对齐)。4字节即32位,其中高6位用于标记附加信息
- 高1位:如果对象是string类型,标记字符串内存是否大于或者等于0x80(是否只包含ASCII)的字符,否则用于Runtime内部检查,GC标记阶段,标记对象是否被检查
- 高2位:如果对象是string类型,标记字符串内容是否需要特殊排序方式,否则标记对象析构函数(Finalizer)是否需要运行,如果调用了GC.SuppressFinalize,会设置为1.
- 高3位:标记对象是否固定(Pinned Object) ,如果有固定类型的GC句柄,则这个位会在GC过程中暂时性标记为1
- 高4位:标记对象是否被自旋锁(lock)获取线程锁
- 高5位:标记对象是否包含同步块索引(SyncBlock Index)或者Hash值
- 高6位:标记对象是否包含Hash值
对于String类型而言,前2位用于内容是否只包含ASCII字符,可以影响字符串排序时使用的算法。如果高1位成立,则表示排序时需要把字符串转为int处理。
高4,5,6位用于标记低26位保存了什么内容。有4中状态。这些状态会根据对对象所进行的操作进行切换,例如获取锁,释放锁,计算Hash值等。
这四种状态分别是
- 三个位为1,0,0时,包含通过自旋获取线程锁的AppDomainID(16-26位),进入次数(10-15位),线程ID(0-9位)
- 三个位为0,1,1时,包含Hash的缓存,引用类型的对象会在首次获取Hash值时生成一个缓存并保存到低26位
- 三个位为0,1,0时,包含同步块索引
- 三个位为0,0,0时,什么都不包含,大部分对象都属于这种状态
- 方法表(Method Table)/类型数据
这里是对象之间互相引用时的引用点,换句话说,如果有一个引用指向某个对象,引用者将指向该对象method table reference的地址。
类型数据所属模块,名称,字段列表,属性列表,方法列表,各个方法的入口点地址等信息
.NET中的反射,接口,虚方法都需要类型数据作为支撑
反射会把类型数据中的内容包装为托管对象供托管代码访问
接口与虚方法需要访问类型数据中的函数表,在执行时定位实际调用的函数地址
- 字段
如果类型没有字段,当前垃圾回收器要求每个对象至少有一个指针大小的字段,作为数据占位符。但是这个字段不必专用于垃圾回收器,可以被其他用途重用。比如存储数组对象的长度,但最重要的用途还是用作于GC。
根据上面介绍的内存布局,可以知道堆上每个对象都至少包含者3个部分。在32位runtime上,堆上最小对象将有12字节。64位runtime上24字节。
public class MyClass
{
public byte f1;
public long f2;
public int f3;
public long f4;
}
Type layout for 'MyClass'
Size: 24 bytes. Paddings: 3 bytes (%12 of empty space)
|============================|
| Object Header (8 bytes) |
|----------------------------|
| Method Table Ptr (8 bytes) |
|============================|
| 0-7: Int64 f2 (8 bytes) |
|----------------------------|
| 8-15: Int64 f4 (8 bytes) |
|----------------------------|
| 16-19: Int32 f3 (4 bytes) |
|----------------------------|
| 20: Byte f1 (1 byte) |
|----------------------------|
| 21-23: padding (3 bytes) |
|============================|
值类型
值类型代表数据本身
public struct MyStruct
{
public byte f1;
public long f2;
public int f3;
public long f4;
}
Type layout for 'MyStruct'
Size: 32 bytes. Paddings: 11 bytes (%34 of empty space)
|===========================|
| 0: Byte f1 (1 byte) |
|---------------------------|
| 1-7: padding (7 bytes) |
|---------------------------|
| 8-15: Int64 f2 (8 bytes) |
|---------------------------|
| 16-19: Int32 f3 (4 bytes) |
|---------------------------|
| 20-23: padding (4 bytes) |
|---------------------------|
| 24-31: Int64 f4 (8 bytes) |
|===========================|
内存对齐
细心的朋友可以看到,在内存分配的过程中,出现了padding这样的字眼。且paddings值还不一样,引用类型为3,值类型为11。这就引申出一个新的概念----内存对齐。
为什么要内存对齐?
现在CPU可以使用高效的代码来访问对齐的数据,访问未对齐的数据虽然可以,但需要更多的指令,因此速度会慢一点。
每种基元数据类型(int,float等)都有其自己首选的对齐方式----应该是存储它的地址的值的倍数。因此4字节的int32的具有4字节(可被4整除)的对齐方式,8字节的double具有8字节(可被8整除)的对其方式。以此类推,最简单的是1字节的char和byte.因为是1字节,所以它们存储在任何位置都是对齐的
内存布局定义的三个规则
- 类型的对齐方式是其最大元素的大小(1,2,4,8等字节)或指定的打包大小(以较小者为准)
- 每个字段必须与其自身大小(1,2,4,8等字节)或类型的对齐方式 对齐
- 在字段之间添加填充以满足对齐需求
如上所述,在MyStruct/MyClass中,最大的字段是8B的Int64,所以内存开始的地址必须可以被8整除
字段布局的设计决策
- 值类型:默认情况下具有顺序布局,因此字段是按照定义的顺序存储在内存中。这主要在设计之初,通常认为结构用与interop(互操作)场景。会被传递给非托管代码。其布局仍会考虑对齐要求,引入填充并增加生成的结构的大小(作为高效访问对齐字段的成本)
- 引用类型:默认情况下自动布局,如何布局字段又CLR管理,会以最高效的方式重新排序
自定义内存布局(StructLayout)
从上文可知,结构的内存布局与字段的顺序息息相关。所以也解释了。同样的字段,Paddings差异如此之大的原因。MyStruct整整11个字节被浪费,而MyClass仅有3个字节。如果只是偶尔使用这种结构,那问题不大,如果你的代码严重依赖于值类型,并且应该要以高性能执行的方式来处理数百万个值类型,那么此类浪费可能会带来影响。
.NET提供了一种控制字段布局的方法。这同样是面向interop(互操作)场景所设计的。
- LayoutKind.Sequential:顺序布局,按照字段定义的顺序存储并且保证正确的字段对齐。这是非托管结构的默认值
- LayoutKind.Auto:保证字段的对齐,但可以对字段进行重新排序(以高效利用内存)。这是托管类型的默认值
- LayoutKind.Explicit:不能保证任何内容的布局,因为我们显式定义了布局
[StructLayout(LayoutKind.Auto)]
public struct MyStruct
{
public byte f1;
public long f2;
public int f3;
public long f4;
}
Type layout for 'MyStruct'
Size: 24 bytes. Paddings: 3 bytes (%12 of empty space)
|===========================|
| 0-7: Int64 f2 (8 bytes) |
|---------------------------|
| 8-15: Int64 f4 (8 bytes) |
|---------------------------|
| 16-19: Int32 f3 (4 bytes) |
|---------------------------|
| 20: Byte f1 (1 byte) |
|---------------------------|
| 21-23: padding (3 bytes) |
|===========================|
自动布局的主要缺点是我们不能在Interop中使用这种结构,所以我们在编码过程中,要审时度势。如果我们仅仅需要高性能(栈分配,数据局部性,线程安全,较少空间占用)通用代码,我们根本不关心这个限制。只有在Interop中,我们才考虑默认布局而不是自动布局。
到目前为止,我们所展示的struct都是非托管的,但是结构也可以是托管的------只要向它们添加引用对象即可。如下所述,添加了一个object。struct会将默认布局改为自动布局(因为该struct从非托管对象变成了托管对象)
public struct MyStruct
{
public byte f1;
public long f2;
public int f3;
public long f4;
public object o1;
}
Type layout for 'MyStruct'
Size: 32 bytes. Paddings: 3 bytes (%9 of empty space)
|============================|
| 0-7: Object o1 (8 bytes) |
|----------------------------|
| 8-15: Int64 f2 (8 bytes) |
|----------------------------|
| 16-23: Int64 f4 (8 bytes) |
|----------------------------|
| 24-27: Int32 f3 (4 bytes) |
|----------------------------|
| 28: Byte f1 (1 byte) |
|----------------------------|
| 29-31: padding (3 bytes) |
|============================|
那么,引用类型能否也使用[StructLayout(LayoutKind.Squential)]改变自己的内存布局呢?答案也是可以的。(.NET内存管理宝典一书,说类和非托管结构的自动布局不能更改。我在.NET Core环境中是可以的,作者应该是.NET Framework环境)
[StructLayout(LayoutKind.Sequential)]
public class MyClass
{
public byte f1;
public long f2;
public int f3;
public long f4;
}
Type layout for 'MyClass'
Size: 32 bytes. Paddings: 11 bytes (%34 of empty space)
|============================|
| Object Header (8 bytes) |
|----------------------------|
| Method Table Ptr (8 bytes) |
|============================|
| 0: Byte f1 (1 byte) |
|----------------------------|
| 1-7: padding (7 bytes) |
|----------------------------|
| 8-15: Int64 f2 (8 bytes) |
|----------------------------|
| 16-19: Int32 f3 (4 bytes) |
|----------------------------|
| 20-23: padding (4 bytes) |
|----------------------------|
| 24-31: Int64 f4 (8 bytes) |
|============================|
当struct包含了具有[StructLayout(LayoutKind.Auto)]布局的其他struct时,默认布局行为也将会变更为Auto。大多数常用的内置struct(Decimal,Char,Boolean)是Sequential布局。
public struct MyStruct
{
public byte f1;
public long f2;
public int f3;
public long f4;
public MyStructSub sub;
}
[StructLayout(LayoutKind.Auto)]
public struct MyStructSub
{
public int? i;
}
Type layout for 'MyStruct'
Size: 32 bytes. Paddings: 3 bytes (%9 of empty space)
|===================================|
| 0-7: Int64 f2 (8 bytes) |
|-----------------------------------|
| 8-15: Int64 f4 (8 bytes) |
|-----------------------------------|
| 16-19: Int32 f3 (4 bytes) |
|-----------------------------------|
| 20: Byte f1 (1 byte) |
|-----------------------------------|
| 21-23: padding (3 bytes) |
|-----------------------------------|
| 24-31: MyStructSub sub (8 bytes) |
| |===============================| |
| | 0-7: Nullable`1 i (8 bytes) | |
| |===============================| |
|===================================|
但是Datetime/DateTimeOffset很神奇,它是auto布局。因此当Datetime用作用一个struct的字段时,它的布局也将更改为自动。且强制设置为Sequential也失效,并不理解为什么会这样?
更新: 这应该是.net core的一个bug.在.net core 2.X环境中,上文结论为true.在.net core 8.x中。Datetime与其他内置struct行为一致
也完全自定义内存布局,可以设置[StructLayout(LayoutKind.Explicit)]来强制规定内存布局。
[StructLayout(LayoutKind.Explicit)]
public struct MyStruct
{
[FieldOffset(0)]
public byte f1;
[FieldOffset(8)]
public long f2;
[FieldOffset(16)]
public int f3;
[FieldOffset(24)]
public long f4;
[FieldOffset(32)]
public DateTime o1;
}
Type layout for 'MyStruct'
Size: 40 bytes. Paddings: 11 bytes (%27 of empty space)
|=======================================|
| 0: Byte f1 (1 byte) |
|---------------------------------------|
| 1-7: padding (7 bytes) |
|---------------------------------------|
| 8-15: Int64 f2 (8 bytes) |
|---------------------------------------|
| 16-19: Int32 f3 (4 bytes) |
|---------------------------------------|
| 20-23: padding (4 bytes) |
|---------------------------------------|
| 24-31: Int64 f4 (8 bytes) |
|---------------------------------------|
| 32-39: DateTime o1 (8 bytes) |
| |===================================| |
| | 0-7: UInt64 _dateData (8 bytes) | |
| |===================================| |
|=======================================|