《.NET中的数组在内存中如何布局? 》介绍了一个.NET下针对数组对象的内存布局。既然我们知道了内存布局,我们自然可以按照这个布局规则创建一段字节序列来表示一个数组对象,就像《以纯二进制的形式在内存中绘制一个对象》构建一个普通的对象,以及《你知道.NET的字符串在内存中是如何存储的吗?》构建一个字符串对象一样。
一、数组类型布局
二、利用字节数组构建数组
三、利用非托管本地内存构建数组
四、性能测试
一、数组类型布局
我们再简单回顾一下数组对象的内存布局。如下图所示,对于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
unsafe static T[] BuildArray(int length) { var byteCount = IntPtr.Size // Object header + Padding + IntPtr.Size // TypeHandle + IntPtr.Size // Length + Padding + Unsafe.SizeOf () * 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
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
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(pointer, 1), typeof(T[]).TypeHandle.Value); Unsafe.Write(Unsafe.Add 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)); }(pointer, 2), length);
上面的代码还实现了用来释放本地内存的Free方法。我们通过对指定数组变量进行“解地址”得到带释放数组对象的地址,但是这个地址并非分配内存的初始位置,所有我们需要前移一个身位(InPtr.Size)得到指向初始内存地址的指针,并将其作为NativeMemory的Free方法的参数,这样在BuildArray
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
在如下的演示程序中,我们在一个无限循环中调用BuildArray
四、性能测试
我们最后做一个简单的性能测试看看BuildArray
[MemoryDiagnoser] public class Benchmark { [Benchmark] public int[] ManagedArray()=> new int[1024]; [Benchmark] public void NativeArray()=>Free(BuildArray<int>(1024)); unsafe static T[] BuildArray(int length); unsafe static void Free (T[] array); }
如下所示的是性能测试的结果,可以看出NativeArray不仅仅没有基于GC的分配,耗时不到原来的一半。