.NET中的数组在内存中如何布局?

总的来说,.NET的值类型和引用类型都映射一段连续的内存片段。不过对于值类型对象来说,这段内存只需要存储其字段成员,而对应引用类型对象,还需要存储额外的内容。就内存布局来说,引用类型有两个独特的存在,一个是字符串,另一个就是数组。我在《你知道.NET的字符串在内存中是如何存储的吗?》一文中对字符串的内存布局作了详细介绍,今天我们来聊聊数组类型的内存布局。

一、引用类型布局
二、数组类型布局
三、值类型数组
四、引用类型数组

一、引用类型布局

但是对于引用类型对象,除了存储其所有字段成员外,还需要存储一个Object Header和TypeHandle,前者可以用来存储Hash值,也可以用来存储同步状态;后者存储的是目标类型方法表的地址(详细介绍可以参考我的文章《如何计算一个实例占用多少内存?》、《如何将一个实例的内存二进制内容读出来?》。

如下图所示,对于32位(x86)系统,Object Header和TypeHandle各占据4个字节;但是对于64位(x64)来说,存储方法表指针的TypeHandle自然扩展到8个字节,但是Object Header依然是4个字节,为了确保TypeHandle基于8字节的内存对齐,所以会前置4个字节的“留白(Padding)”。

image_thumb2

顺便说一下,即使没有定义任何的字段成员,运行时依然会使用一个“指针宽度(IntPtr.Size)”的存储空间(上图中的Payload),所以x86/x64系统中一个引用类型对象至少占据12/24字节的内存。除此之外,所谓对象的引用并不是指向这段内存的起始位置,而是指向TypeHandle的地址。

二、数组类型布局

既然数组是引用类型,它自然按照上面的方式进行内存布局。它依然拥有4字节的Object Header,TypeHandle部分存储的是数组类型自身的方法表地址。其荷载内容(Payload)采用如下的布局:前置4个字节以UInt32的形式存储数组的长度,后面依次存储每个数组元素的内容。对于64位(x64)来说,为了确保数组元素的内存对齐,两者之间具有4个字节的Padding。

image_thumb5

三、值类型数组

对于值类型的数组,Payload部分直接存储元素自身的值。如下程序演示了如何将一个字节数组对象在内存中的字节序列读出来。如代码片段所示,GetArray方法根据上述的内存布局计算出一个数组对象占据的字节数,并创建出对应的字节数据来存储数组对象的字节内容。我们在上面说过,一个数组变量指向的是目标对象TypeHandle部分的地址,所以我们需要前移一个指针宽度才能得到内存的起始位置。我们最终利用起始位置和字节数,将承载数组自身对象的字节读出来存放到预先创建的字节数组中。

var array = new byte[] { byte.MaxValue, byte.MaxValue, byte.MaxValue };
Console.WriteLine($"Array: {BitConverter.ToString(GetArray(array))}");
Console.WriteLine($"TypeHandle of Byte[]: {BitConverter.ToString(GetTypeHandle<byte[]>())}");

unsafe static byte[] GetArray<T>(T[] array)
{
    var size = IntPtr.Size // Object header + Padding
     + IntPtr.Size // TypeHandle
     + IntPtr.Size // Length + Padding
     + Unsafe.SizeOf<T>() * array.Length // Elements
        ;
    var bytes = new byte[size];

    var pointer = Unsafe.AsPointer(ref array);
    var head = *(IntPtr*)pointer - IntPtr.Size;
    Marshal.Copy(head, bytes, 0, size);
    return bytes;
}

unsafe static byte[] GetTypeHandle<T>() => BitConverter.GetBytes(typeof(T).TypeHandle.Value);

为了进一步验证数组对象每个部分的内容,我们还定义了GetTypeHandle<T>方法读取目标类型TypeHandle的值(方法表地址)。在演示程序中,我们创建了一个长度位3的字节数组,并将三个数组元素的值设置位byte.MaxValue。我们将承载这个数组的字节序列和字节数组类型的TypeHandle的值打印出来。

Array: [00-00-00-00-00-00-00-00]-[E0-6A-0D-01-FF-7F-00-00]-[03-00-00-00]-00-00-00-00-[FF-FF-FF]
TypeHandle of Byte[]: E0-6A-0D-01-FF-7F-00-00

如上所示的输出结果验证了数组对象的内存布局。由于演示机器为64位系统,所以前8个字节表示Object Header(4字节)和Padding(4字节)。中间高亮的8个字节正好与字节数组类型的TypeHandle的值一致。后面4个字节(03-00-00-00)表示字节的长度(3),紧随其后的4个字节位Padding。最后的内容正好是三个数组元素的值(FF-FF-FF)。

四、引用类型数组

对于引用类型的数组,其每个数组元素存储是元素对象的地址,下面的程序验证了这一点。如代码片段所示,我们定义了GetAddress<T>方法得到指定变量指向的目标地址,并将其转换成返回的字节数组。演示程序创建了一个包含三个元素的字符串数组,我们将承载数组对象的字节序列和作为数组元素的三个字符串对象的地址打印出来。

var s1 = "foo";
var s2 = "bar";
var s3 = "baz";
var array = new string[] { s1, s2, s3 };

Console.WriteLine($"Array: {BitConverter.ToString(GetArray(array))}");
Console.WriteLine($"element 1: {BitConverter.ToString(GetAddress(ref s1))}");
Console.WriteLine($"element 2: {BitConverter.ToString(GetAddress(ref s2))}");
Console.WriteLine($"element 3: {BitConverter.ToString(GetAddress(ref s3))}");

unsafe static byte[] GetAddress<T>(ref T value)
{
    var address = *(IntPtr*)Unsafe.AsPointer(ref value);
    return  BitConverter.GetBytes(address);
}

从如下的代码片段可以看出,在承载数组对象的字节序列中,最后的24字节正好是三个字符串的地址。

Array: 00-00-00-00-00-00-00-00-48-E9-5E-03-FF-7F-00-00-03-00-00-00-00-00-00-00-E0-EF-40-73-72-02-00-00-00-F0-40-73-72-02-00-00-20-F0-40-73-72-02-00-00
element 1: E0-EF-40-73-72-02-00-00
element 2: 00-F0-40-73-72-02-00-00
element 3: 20-F0-40-73-72-02-00-00

既然我们知道了数组对象在内存中的布局规则,我们就可以《利用一段字节序列构建一个数组对象》!

posted @ 2023-10-30 08:50  Artech  阅读(3816)  评论(5编辑  收藏  举报