利用一段字节序列构建一个数组对象

.NET中的数组在内存中如何布局? 》介绍了一个.NET下针对数组对象的内存布局。既然我们知道了内存布局,我们自然可以按照这个布局规则创建一段字节序列来表示一个数组对象,就像《以纯二进制的形式在内存中绘制一个对象》构建一个普通的对象,以及《你知道.NET的字符串在内存中是如何存储的吗?》构建一个字符串对象一样。

一、数组类型布局
二、利用字节数组构建数组
三、利用非托管本地内存构建数组
四、性能测试

一、数组类型布局

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

image_thumb5_thumb

其荷载内容(Payload)采用如下的布局:前置4个字节以UInt32的形式存储数组的长度,后面依次存储每个数组元素的内容。对于64位(x64)来说,为了确保数组元素的内存对齐,两者之间具有4个字节的Padding。

二、利用字节数组构建数组

如下所示的BuildArray<T>方法帮助我们构建一个指定长度的数组,数组元素类型由泛型参数决定。如代码片段所示, 我们根据上述的内存布局规则计算出目标数组占据的字节数,并据此创建一个对应的字节数组来表示构建的数组。我们将数组类型(T[])的TypeHandle的值(方法表地址)写入对应的位置(偏移量和长度均为IntPtr.Size),紧随其后的4个字节写入数组的长度。自此一个指定元素类型/长度的空数组就已经构建出来了,我们让返回的数组变量指向数组的第IntPtr.Size个字节(4字节/8字节)。

unsafe static T[] BuildArray<T>(int length)
{
    var byteCount =
        IntPtr.Size // Object header + Padding
        + IntPtr.Size // TypeHandle
        + IntPtr.Size // Length + Padding
        + Unsafe.SizeOf<T>() * length // Elements
        ;

   var bytes = new byte[byteCount];
    Unsafe.Write(Unsafe.AsPointer(ref bytes[IntPtr.Size]), typeof(T[]).TypeHandle.Value);
    Unsafe.Write(Unsafe.AsPointer(ref bytes[IntPtr.Size * 2]), length);

    T[] array = null!;
    Unsafe.Write(Unsafe.AsPointer(ref array), new IntPtr(Unsafe.AsPointer(ref bytes[IntPtr.Size])));
    return array;
}

接下来我们就来验证一下BuildArray<T>构建的数组是否可以正常使用。如下面的代码片段所示,我们调用这个方法构建了一个长度位100的整型数组,并利用调试断言确定构建的数组长度是否正常,并验证每个元素是否置空。接下来我们对每个数组元素赋值,并利用调试断言验证赋值是否有效。

var array = BuildArray<int>(100);
Debug.Assert(array.Length == 100);
Debug.Assert(array.All(it => it == 0));
for (int index = 0; index < array.Length; index++)
{
    array[index] = index;
}
for (int index = 0; index < array.Length; index++)
{
    Debug.Assert(array[index] == index);
}

上面演示的是值类型(Int32)数组的构建,下面采用类似的形式构建了一个引用类型(String)的数组。

var array = BuildArray<string>(100);
Debug.Assert(array.Length == 100);
Debug.Assert(array.All(it => it is null));
for (int index = 0; index < array.Length; index++)
{
    array[index] = index.ToString();
}
for (int index = 0; index < array.Length; index++)
{
    Debug.Assert(array[index] == index.ToString());
}

三、利用非托管本地内存构建数组

既然我们可以利用一段连续的托管内存(字节数组)构建一个指定元素类型、指定长度的数组,我们自然也能利用非托管内存达到相同的目的。利用非托管本地内存构建数组带来的最大好处显而易见,那就是不会对GC造成任何压力,前提是我们能够自行释放分配的内容。为了我们将上面定义的BuildArray<T>方法改造成如下的形式:在完成针对字节数的计算之后,我们调用NativeMemory的AllocZeroed方法分配长度适合的内存,并将内容置空(设置为零)。接下来按照布局规则将TypeHandle和长度写入对应的位置。最后让返回的变量指向TypeHandle对应的地址就可以了。

unsafe static T[] BuildArray<T>(int length)
{
    var byteCount =
        IntPtr.Size // Object header + Padding
        + IntPtr.Size // TypeHandle
        + IntPtr.Size // Length + Padding
        + Unsafe.SizeOf<T>() * length // Elements
        ;

    var pointer = NativeMemory.AllocZeroed((uint)byteCount);
    Unsafe.Write(Unsafe.Add<nint>(pointer, 1), typeof(T[]).TypeHandle.Value);
    Unsafe.Write(Unsafe.Add<nint>(pointer, 2), length);

    T[] array = null!;
    Unsafe.Write(Unsafe.AsPointer(ref array), new IntPtr(Unsafe.Add<nint>(pointer, 1)));
    return array;
}

unsafe static void Free<T>(T[] array)
{
    var address = *(nint*)Unsafe.AsPointer(ref array);
    NativeMemory.Free(Unsafe.Add<nint>(address.ToPointer(), -1));
}

上面的代码还实现了用来释放本地内存的Free方法。我们通过对指定数组变量进行“解地址”得到带释放数组对象的地址,但是这个地址并非分配内存的初始位置,所有我们需要前移一个身位(InPtr.Size)得到指向初始内存地址的指针,并将其作为NativeMemory的Free方法的参数,这样在BuildArray<T>方法中分配的内存就能被释放了。

var random = new Random();
while (true)
{
    var length = random.Next(10, 100);
    var array = BuildArray<int>(length);
    Debug.Assert(array.Length == length);
    Debug.Assert(array.All(it=>it == 0));

    for (int index = 0; index < length; index++) array[index] = index;
    for (int index = 0; index<length; index++) Debug.Assert(array[index] == index);

    Free(array);
}

在如下的演示程序中,我们在一个无限循环中调用BuildArray<T>方法构建一个随机长度的整型数组,然后我们利用调试断言验证其长度和元素初始值,然后对每个元素进行赋值并验证。由于每次循环都调用Free方法对创建的数组对象进行了释放,所以内存总是会维持在一个稳当的状态,这可以从VS提供的针对内存的诊断工具得到验证。

image

四、性能测试

我们最后做一个简单的性能测试看看BuildArray<T> + Free<T>与直接new T[]这两种编程方式的性能差异。如下面的代码片段所示,我们定义了两个Benchmark方法,ManagedArray方法直接返回利用new关键字创建的整型数组,长度为1024;NativeArray方法调用BuildArray<T>方法构建了一个相同长度的整型数组,并调用Free方法将其“释放”。

[MemoryDiagnoser]
public class Benchmark
{

    [Benchmark]
    public int[] ManagedArray()=> new int[1024];

    [Benchmark]
    public void NativeArray()=>Free(BuildArray<int>(1024));

    unsafe static T[] BuildArray<T>(int length);

    unsafe static void Free<T>(T[] array);
}

如下所示的是性能测试的结果,可以看出NativeArray不仅仅没有基于GC的分配,耗时不到原来的一半。

image

posted @ 2023-10-31 08:44  Artech  阅读(801)  评论(6编辑  收藏  举报