我们再简单回顾一下数组对象的内存布局。如下图所示,对于32位(x86)系统,Object Header和TypeHandle各占据4个字节;但是对于64位(x64)来说,存储方法表指针的TypeHandle自然扩展到8个字节,但是Object Header依然是4个字节,为了确保TypeHandle基于8字节的内存对齐,所以会前置4个字节的“留白(Padding)”。
其荷载内容(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提供的针对内存的诊断工具得到验证。
我们最后做一个简单的性能测试看看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的分配,耗时不到原来的一半。