叫我安不理

C#查漏补缺----对象内存结构与布局

环境变量

.Net Core 8.0
Windows 11 64位

内存布局

引用类型

在.NET中,数据会按照类型分为不同的对象,对于引用类型的实例,由一个对象标头(Object Header)和方法表(MethodTable)以及字段值组成

  1. 对象标头(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时,什么都不包含,大部分对象都属于这种状态
  1. 方法表(Method Table)/类型数据
    这里是对象之间互相引用时的引用点,换句话说,如果有一个引用指向某个对象,引用者将指向该对象method table reference的地址。
    类型数据所属模块,名称,字段列表,属性列表,方法列表,各个方法的入口点地址等信息
    .NET中的反射,接口,虚方法都需要类型数据作为支撑

反射会把类型数据中的内容包装为托管对象供托管代码访问
接口与虚方法需要访问类型数据中的函数表,在执行时定位实际调用的函数地址

  1. 字段
    如果类型没有字段,当前垃圾回收器要求每个对象至少有一个指针大小的字段,作为数据占位符。但是这个字段不必专用于垃圾回收器,可以被其他用途重用。比如存储数组对象的长度,但最重要的用途还是用作于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. 类型的对齐方式是其最大元素的大小(1,2,4,8等字节)或指定的打包大小(以较小者为准)
  2. 每个字段必须与其自身大小(1,2,4,8等字节)或类型的对齐方式 对齐
  3. 在字段之间添加填充以满足对齐需求

如上所述,在MyStruct/MyClass中,最大的字段是8B的Int64,所以内存开始的地址必须可以被8整除

字段布局的设计决策

  1. 值类型:默认情况下具有顺序布局,因此字段是按照定义的顺序存储在内存中。这主要在设计之初,通常认为结构用与interop(互操作)场景。会被传递给非托管代码。其布局仍会考虑对齐要求,引入填充并增加生成的结构的大小(作为高效访问对齐字段的成本)
  2. 引用类型:默认情况下自动布局,如何布局字段又CLR管理,会以最高效的方式重新排序

自定义内存布局(StructLayout)

从上文可知,结构的内存布局与字段的顺序息息相关。所以也解释了。同样的字段,Paddings差异如此之大的原因。MyStruct整整11个字节被浪费,而MyClass仅有3个字节。如果只是偶尔使用这种结构,那问题不大,如果你的代码严重依赖于值类型,并且应该要以高性能执行的方式来处理数百万个值类型,那么此类浪费可能会带来影响。
.NET提供了一种控制字段布局的方法。这同样是面向interop(互操作)场景所设计的。

  1. LayoutKind.Sequential:顺序布局,按照字段定义的顺序存储并且保证正确的字段对齐。这是非托管结构的默认值
  2. LayoutKind.Auto:保证字段的对齐,但可以对字段进行重新排序(以高效利用内存)。这是托管类型的默认值
  3. 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) | |
| |===================================| |
|=======================================|

posted on 2024-08-27 11:20  叫我安不理  阅读(165)  评论(0编辑  收藏  举报

导航