相比固定长度的Array,大家可能在编程的时候经常会使用 List
,同时可能会经常往里面Add东西,因为List具有可扩容性,但是注重GC的朋友会发现(比如Unity开发者),List.Resize会造成扩容前 数组长度*泛型类型
所占字节长度的GC,同时会造成耗时,以及额外的内存占用(比如List有100个元素的时候触发了扩容,新容量为200,但是总共一共插入了150个元素,导致有50个分配的内存没被利用)
Stream(例如MemoryStream)与List一样,在Resize里会分配当前容量两倍的新byte托管数组,也会造成和上面提到的一样的情况,导致GC和可能存在的额外内存占用,以及拷贝托管数组的耗时。
那么有没有什么办法能实现一个:
能插入元素
能动态扩容
扩容不造成GC
能指定扩容长度
包含上述内容的动态扩容数组呢?
让我们先看看List和Stream的原理
List
和StreamList
和Stream一样,基本是内部有一个托管数组 T[]
或 byte[]
,
内部会记录当前总容量,以及元素总数,Stream还会额外记录当前的位置
且内部实现了Resize方法,会new一个新的 托管数组 ,长度为当前总 容量的两倍
紧接着会把老数组的元素 复制 到新数组上,老数组 不会再被引用 且造成 GC
最近C#提供了Span和Memory类型,提供了安全操作连续内存的方法
他们的内部实现是这样的:
记录对应泛型类型的 指针
记录该指针的 长度 (多少个元素)
Span和Memory有一点微小的区别,比如在栈上和托管堆上(Span是ref struct,Memory则是readonly struct的缘故),导致他们的用法不太一样,不过本文只需要关心他们的实现原理。
是不是发现和 List
以及Stream很像?只是托管数组变成指针了,然后少了一些成员?
指针是什么?指针就是一个变量在内存里的地址,所以叫做指针(Pointer),因为指针指向了内存内的一个变量
在内存中的变量有两种情况,一种是 被GC托管的变量 ,一种是 不被GC托管的变量 ,而我们的List和Stream内部的数组,就是 托管数组,由GC托管 。
如果对Span和Memory熟悉的,应该知道List可以直接转Span,怎么做到的呢?只需要把List内部托管的数组的指针传给Span的构造参数就行(List转Memory也可以就是需要自己实现,有点复杂)
那么延伸的想法就来了,如果我们用 非托管指针代替分配的托管数组 来存我们的元素,是不是就可以 不被GC托管而不被产生GC 了?答案是,没错。
如果我们需要申请非托管内存,我们需要实现以下一条很重要原则:
手动申请的非托管内存必须用好后手动释放(不然就会造成野指针)
C#有两种方法申请非托管内存,并且任何能运行C#的平台都支持(Unity也是支持的,哪怕是IL2CPP)
Marshal.AllocHGlobal
,该方法会返回指定长度的非托管内存,并且返回的内存 有可能会有值
Marshal.AllocCoTaskMem
,该方法会返回 至少 指定长度的非托管内存,但是 也有可能 会返回 超过改长度 的内存,且返回的内存 不会有值(全是0)
这里很明显,第一个提到的方法适合我们的使用场景
既然用 Sturct可以避免创建时造成的GC (如Span, Memory都是struct),为什么我们要用托管类型( Class )去 定义 我们的 动态扩容数组 呢?
请看一下上面提到的原则, 手动申请的非托管内存必须用好后手动释放(不然就会造成野指针)
只有通过托管类型,我们才能做到这一点:
在构造函数( Constructor 内 申请 非托管内存)
在折构函数( Finalizer 内 释放 申请的内存)
折构函数就是一个对象被GC回收前调用的函数)
因为 非托管类型转指针比较方便 ,所以本文我们 先实现一个非托管类型的动态扩容数组
根据我们上面提到的思路,可以得出以下代码(注,此代码不是完整体):
////// A buffer that can dynamically extend /// ///public sealed unsafe class ExtensibleBuffer where T : unmanaged { /// /// Init extensible buffer with a capacity /// /// /// private ExtensibleBuffer(int size, T[] initialData) { sizeOfT = (byte)sizeof(T); ExpandSize = size; Data = (T*)Marshal.AllocHGlobal(sizeOfT * ExpandSize); if (initialData != null) { fixed(T* ptr = initialData) { CopyFrom(ptr, 0, 0, initialData.Length); } } TotalLength = ExpandSize; GC.AddMemoryPressure(sizeOfT * ExpandSize); } ////// Free allocated memories /// ~ExtensibleBuffer() { Marshal.FreeHGlobal((IntPtr)Data); GC.RemoveMemoryPressure(sizeOfT * TotalLength); } }
上面的代码 实现了构造函数和折构函数 ,其中构造函数的 参数指定了扩容大小 ,方法内部 获取了泛型T的内存大小 ,并且 申请了类型大小*扩容数量个字节 的 内存 ,并且如果 有初始化数据 ,就 把初始化托管数据复制到非托管内存上
同时,会标记 目前的总长度 ,以及 通知GC我们有申请的内存大小的内存压力(促进GC多去回收)
折构函数内,我们 释放了申请的内存 ,同时 通知GC我们之前申请的内存大小的内存压力没了,被我们释放了(让GC不要再关系我们这个动态扩容数组了)
索引器就是数组/List返回指定位置元素的方法:
////// Get element at index /// /// public T this[int index] { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => Data[index]; [MethodImpl(MethodImplOptions.AggressiveInlining)] set { EnsureCapacity(ref index); Data[index] = value; } }
我们在插入的时候检查下申请的内存就好,确保插入到有效的内存里。
既然要 避免每次扩容都双倍现在的长度从而造成内存浪费 ,我们需要在 构造函数里标记扩容大小 ,然后 每次扩容的时候当前总长度+=扩容大小 就好
幸运的是C#提供了一个重新分配通过 Marshal.AllocHGlobal
申请的内存的方法:
Marshal.ReAllocHGlobal
这个方法需要传 两个参数 ,第一个参数是 原申请的指针 ,第二个参数是 新长度(转指针)
通过简单的封装,我们得到了:
////// Ensure index exists /// /// private void EnsureCapacity(ref int index) { if (index < TotalLength) return; while (index >= TotalLength) { TotalLength += ExpandSize; GC.AddMemoryPressure(sizeOfT * ExpandSize); } Extend(); } ////// Extend buffer /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Extend() { Data = (T*)Marshal.ReAllocHGlobal((IntPtr)Data, new IntPtr(TotalLength * sizeOfT)); }
我们只需要定期触发(比如每次插入的时候,访问的话为了性能我们就不检查了,因为是指针,也不会导致数组越界,只是会返回我们想不到的结果) EnsureCapacity
,来 检查指定的索引是否被我们申请过 ,如果 没的话 ,就 动态扩容以及通知GC即可
我们只需要 取别的数组/指针 ,然后 从指定偏移开始 , 复制指定长度 到我们 申请的指针的指定位置 即可:
////// Copy data to extensible buffer /// /// /// /// /// ///public void CopyFrom(T[] src, int srcIndex, int dstIndex, int length) { fixed (T* ptr = src) { CopyFrom(ptr, srcIndex, dstIndex, length); } } /// /// Copy data to extensible buffer /// why unaligned? https://stackoverflow.com/a/72418388 /// /// /// /// /// ///public void CopyFrom(T* src, int srcIndex, int dstIndex, int length) { var l = dstIndex + length; //size check EnsureCapacity(ref l); //copy Unsafe.CopyBlockUnaligned(Data + dstIndex, src + srcIndex, (uint)length); }
StackOverFlow的这篇文章:stackoverflow.com/a/724证明了不对齐的拷贝内存更快,不过这里我们是非托管类型的非托管内存,所以这样玩不会出问题
与上面的实现类似,我们只需要获取 需要复制到的数组/指针 ,从我们 动态扩容数组的第几个元素开始复制 , 复制多少个 即可
注,这里如果需要复制到指定的数组位置,可以把数组转指针后+偏移,然后调用传指针的方法去复制
////// Copy data from buffer to dst from dst[0] /// /// /// /// ///public void CopyTo(ref T[] dst, int srcIndex, int length) { fixed (T* ptr = dst) { CopyTo(ptr, srcIndex, length); } } /// /// Copy data from buffer to dst from dst[0] /// /// /// /// ///public void CopyTo(T* dst, int srcIndex, int length) { var l = srcIndex + length; //size check EnsureCapacity(ref l); //copy Unsafe.CopyBlockUnaligned(dst, Data + srcIndex, (uint)length); }
Span特别有用, 在切割内存之类的地方没有什么比Span更适合的了,所以我们顺便把转Span也支持吧
////// convert an extensible to buffer from start index with provided length /// /// /// ///public Span AsSpan(int startIndex, int length) { var l = startIndex + length; //size check EnsureCapacity(ref l); return new Span (Data + startIndex, length); }
这样我们可以 从指定位置开始讲指定长度个元素转为Span ,同时操作返回的Span可以直接操作到我们这个动态扩容数组内的元素上( 因为操作Span的元素相当于直接操作内存 )
////// Convert to span /// /// ///public static implicit operator Span (ExtensibleBuffer buffer) => buffer.AsSpan(0, buffer.TotalLength);
这里我们从 第0个元素开始把当前总长度个元素 转Span
因为 有可能 需要给 其他接口使用 ,所以我们 需要能把非托管数组的数据复制到托管数组 ,只需要 new个托管数组然后调用复制的接口即可
////// Convert buffer data to an Array (will create a new array and copy values) /// /// /// ///public T[] ToArray(int startIndex, int length) { T[] ret = new T[length]; CopyTo(ref ret, startIndex, length); return ret; }
可以在 GitHub 上看:Nino,当然我本人 更希望大家来点star ,也可以看下面贴出的代码:
using System; using System.Runtime.InteropServices; using System.Runtime.CompilerServices; namespace Nino.Shared.IO { ////// A buffer that can dynamically extend /// ///public sealed unsafe class ExtensibleBuffer where T : unmanaged { /// /// Default size of the buffer /// private const int DefaultBufferSize = 128; ////// Data that stores everything /// public T* Data { get; private set; } ////// Size of T /// private readonly byte sizeOfT; ////// expand size for each block /// public readonly int ExpandSize; ////// Total length of the buffer /// public int TotalLength { get; private set; } ////// Init buffer /// public ExtensibleBuffer() : this(DefaultBufferSize) { } ////// Init buffer /// public ExtensibleBuffer(int expandSize) : this(expandSize, null) { } ////// Init extensible buffer with a capacity /// /// /// private ExtensibleBuffer(int size, T[] initialData) { sizeOfT = (byte)sizeof(T); ExpandSize = size; Data = (T*)Marshal.AllocHGlobal(sizeOfT * ExpandSize); if (initialData != null) { fixed(T* ptr = initialData) { CopyFrom(ptr, 0, 0, initialData.Length); } } TotalLength = ExpandSize; GC.AddMemoryPressure(sizeOfT * ExpandSize); } ////// Get element at index /// /// public T this[int index] { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => Data[index]; [MethodImpl(MethodImplOptions.AggressiveInlining)] set { EnsureCapacity(ref index); Data[index] = value; } } ////// Ensure index exists /// /// private void EnsureCapacity(ref int index) { if (index < TotalLength) return; while (index >= TotalLength) { TotalLength += ExpandSize; GC.AddMemoryPressure(sizeOfT * ExpandSize); } Extend(); } ////// Extend buffer /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Extend() { Data = (T*)Marshal.ReAllocHGlobal((IntPtr)Data, new IntPtr(TotalLength * sizeOfT)); } ////// Convert buffer data to an Array (will create a new array and copy values) /// /// /// ///public T[] ToArray(int startIndex, int length) { T[] ret = new T[length]; CopyTo(ref ret, startIndex, length); return ret; } /// /// convert an extensible to buffer from start index with provided length /// /// /// ///public Span AsSpan(int startIndex, int length) { var l = startIndex + length; //size check EnsureCapacity(ref l); return new Span (Data + startIndex, length); } /// /// Convert to span /// /// ///public static implicit operator Span (ExtensibleBuffer buffer) => buffer.AsSpan(0, buffer.TotalLength); /// /// Copy data to extensible buffer /// /// /// /// /// ///public void CopyFrom(T[] src, int srcIndex, int dstIndex, int length) { fixed (T* ptr = src) { CopyFrom(ptr, srcIndex, dstIndex, length); } } /// /// Copy data to extensible buffer /// why unaligned? https://stackoverflow.com/a/72418388 /// /// /// /// /// ///public void CopyFrom(T* src, int srcIndex, int dstIndex, int length) { var l = dstIndex + length; //size check EnsureCapacity(ref l); //copy Unsafe.CopyBlockUnaligned(Data + dstIndex, src + srcIndex, (uint)length); } /// /// Copy data from buffer to dst from dst[0] /// /// /// /// ///public void CopyTo(ref T[] dst, int srcIndex, int length) { fixed (T* ptr = dst) { CopyTo(ptr, srcIndex, length); } } /// /// Copy data from buffer to dst from dst[0] /// /// /// /// ///public void CopyTo(T* dst, int srcIndex, int length) { var l = srcIndex + length; //size check EnsureCapacity(ref l); //copy Unsafe.CopyBlockUnaligned(dst, Data + srcIndex, (uint)length); } /// /// Free allocated memories /// ~ExtensibleBuffer() { Marshal.FreeHGlobal((IntPtr)Data); GC.RemoveMemoryPressure(sizeOfT * TotalLength); } } }
就这样,我们理论上低GC高性能的非托管动态扩容数组就做好了,让我们分析一下性能,测试代码:
BenchmarkDotNet=v0.13.1, OS=macOS Monterey 12.0.1 (21A559) [Darwin 21.1.0] Intel Core i9-8950HK CPU 2.90GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores .NET SDK=6.0.301 [Host] : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT ShortRun : .NET 6.0.6 (6.0.622.26707), X64 RyuJIT Job=ShortRun Platform=AnyCpu Runtime=.NET 6.0 IterationCount=1 LaunchCount=1 WarmupCount=1
首先我们测试了ExtensibleBuffer和List的 无优化版 ( V1 ,不指定扩容/初始长度),以及 优化版 ( V2 ,指定扩容/初始长度)
同时我们测试了 byte(1字节) 作为泛型类型,以及 int(4字节) 作为泛型类型
我们先看看 100个元素 的插入:
Method | testCount | Mean | Error | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|
ByteExtensibleBufferInsertV1 | 100 | 466.7 ns | NA | 0.0277 | 0.0277 | 0.0277 | 40 B |
ByteExtensibleBufferInsertV2 | 100 | 440.4 ns | NA | 0.0219 | 0.0219 | 0.0219 | 40 B |
ByteListInsertV1 | 100 | 273.6 ns | NA | 0.0687 | - | - | 432 B |
ByteListInsertV2 | 100 | 173.2 ns | NA | 0.0253 | - | - | 160 B |
IntExtensibleBufferInsertV1 | 100 | 663.2 ns | NA | 0.1173 | 0.1173 | 0.1173 | 40 B |
IntExtensibleBufferInsertV2 | 100 | 645.4 ns | NA | 0.0858 | 0.0858 | 0.0858 | 40 B |
IntListInsertV1 | 100 | 299.2 ns | NA | 0.1884 | - | - | 1,184 B |
IntListInsertV2 | 100 | 192.1 ns | NA | 0.0725 | - | - | 456 B |
为什么会 比List略慢 ?因为 申请内存是有耗时 的,虽然 基本无感知 。不过 GC的优化是不是挺不错 的?
我们现在看看 1000个元素 的插入:
Method | testCount | Mean | Error | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|
ByteExtensibleBufferInsertV1 | 1000 | 3,323.6 ns | NA | 0.2327 | 0.2327 | 0.2327 | 40 B |
ByteExtensibleBufferInsertV2 | 1000 | 1,560.3 ns | NA | 0.2365 | 0.2365 | 0.2365 | 40 B |
ByteListInsertV1 | 1000 | 1,917.9 ns | NA | 0.3643 | - | - | 2,296 B |
ByteListInsertV2 | 1000 | 1,554.6 ns | NA | 0.1678 | - | - | 1,056 B |
IntExtensibleBufferInsertV1 | 1000 | 3,080.0 ns | NA | 0.9689 | 0.9689 | 0.9689 | 41 B |
IntExtensibleBufferInsertV2 | 1000 | 989.2 ns | NA | 0.9251 | 0.9251 | 0.9251 | 41 B |
IntListInsertV1 | 1000 | 2,445.4 ns | NA | 1.3390 | - | - | 8,424 B |
IntListInsertV2 | 1000 | 1,868.7 ns | NA | 0.6447 | - | - | 4,056 B |
速度是不是基本一样 了?但是 GC是不是少了特别特别多 ?
现在看看 1000以上的元素 的插入:
Method | testCount | Mean | Error | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|
ByteExtensibleBufferInsertV1 | 10000 | 25,683.9 ns | NA | 2.3499 | 2.3499 | 2.3499 | 42 B |
ByteExtensibleBufferInsertV2 | 10000 | 11,535.0 ns | NA | 2.3499 | 2.3499 | 2.3499 | 42 B |
ByteListInsertV1 | 10000 | 17,051.6 ns | NA | 5.2490 | - | - | 33,112 B |
ByteListInsertV2 | 10000 | 16,544.9 ns | NA | 1.5869 | - | - | 10,056 B |
IntExtensibleBufferInsertV1 | 10000 | 25,945.4 ns | NA | 9.5825 | 9.5825 | 9.5825 | 46 B |
IntExtensibleBufferInsertV2 | 10000 | 9,269.5 ns | NA | 8.2397 | 8.2397 | 8.2397 | 46 B |
IntListInsertV1 | 10000 | 23,988.9 ns | NA | 20.8130 | - | - | 131,400 B |
IntListInsertV2 | 10000 | 16,521.5 ns | NA | 6.3477 | - | - | 40,056 B |
ByteExtensibleBufferInsertV1 | 100000 | 276,784.6 ns | NA | 22.9492 | 22.9492 | 22.9492 | 56 B |
ByteExtensibleBufferInsertV2 | 100000 | 121,097.0 ns | NA | 23.5596 | 23.5596 | 23.5596 | 56 B |
ByteListInsertV1 | 100000 | 247,649.4 ns | NA | 205.3223 | 205.3223 | 34.4238 | 262,583 B |
ByteListInsertV2 | 100000 | 213,715.3 ns | NA | 161.1328 | 161.1328 | 26.8555 | 100,074 B |
IntExtensibleBufferInsertV1 | 100000 | 244,882.5 ns | NA | 93.7500 | 93.7500 | 93.7500 | 109 B |
IntExtensibleBufferInsertV2 | 100000 | 111,195.8 ns | NA | 86.3037 | 86.3037 | 86.3037 | 82 B |
IntListInsertV1 | 100000 | 533,471.8 ns | NA | 619.1406 | 619.1406 | 233.3984 | 1,049,161 B |
IntListInsertV2 | 100000 | 326,374.4 ns | NA | 265.6250 | 265.6250 | 99.6094 | 400,123 B |
ByteExtensibleBufferInsertV1 | 1000000 | 2,656,296.6 ns | NA | 226.5625 | 226.5625 | 226.5625 | 195 B |
ByteExtensibleBufferInsertV2 | 1000000 | 1,214,632.2 ns | NA | 197.2656 | 197.2656 | 197.2656 | 174 B |
ByteListInsertV1 | 1000000 | 2,422,943.3 ns | NA | 1394.5313 | 1394.5313 | 398.4375 | 2,097,906 B |
ByteListInsertV2 | 1000000 | 1,636,061.4 ns | NA | 207.0313 | 207.0313 | 197.2656 | 1,000,185 B |
IntExtensibleBufferInsertV1 | 1000000 | 3,663,844.0 ns | NA | 851.5625 | 851.5625 | 851.5625 | 547 B |
IntExtensibleBufferInsertV2 | 1000000 | 857,195.9 ns | NA | 498.0469 | 498.0469 | 498.0469 | 377 B |
IntListInsertV1 | 1000000 | 3,717,760.8 ns | NA | 1054.6875 | 1039.0625 | 1000.0000 | 8,389,735 B |
IntListInsertV2 | 1000000 | 2,265,089.4 ns | NA | 511.7188 | 511.7188 | 492.1875 | 4,000,381 B |
ByteExtensibleBufferInsertV1 | 10000000 | 29,853,310.2 ns | NA | 1656.2500 | 1656.2500 | 1656.2500 | 1,178 B |
ByteExtensibleBufferInsertV2 | 10000000 | 10,881,063.5 ns | NA | 984.3750 | 984.3750 | 984.3750 | 714 B |
ByteListInsertV1 | 10000000 | 30,683,668.0 ns | NA | 3312.5000 | 3312.5000 | 1625.0000 | 33,556,204 B |
ByteListInsertV2 | 10000000 | 16,752,229.2 ns | NA | 593.7500 | 593.7500 | 437.5000 | 10,000,366 B |
IntExtensibleBufferInsertV1 | 10000000 | 52,335,791.2 ns | NA | 2500.0000 | 2500.0000 | 2500.0000 | 1,802 B |
IntExtensibleBufferInsertV2 | 10000000 | 8,783,753.0 ns | NA | 984.3750 | 984.3750 | 984.3750 | 714 B |
IntListInsertV1 | 10000000 | 78,802,672.6 ns | NA | 5142.8571 | 5142.8571 | 3000.0000 | 134,220,415 B |
IntListInsertV2 | 10000000 | 33,037,550.0 ns | NA | 937.5000 | 937.5000 | 937.5000 | 40,001,345 B |
ByteExtensibleBufferInsertV1 | 100000000 | 297,324,344.5 ns | NA | 5000.0000 | 5000.0000 | 5000.0000 | 3,808 B |
ByteExtensibleBufferInsertV2 | 100000000 | 113,086,965.2 ns | NA | 800.0000 | 800.0000 | 800.0000 | 741 B |
ByteListInsertV1 | 100000000 | 303,881,242.5 ns | NA | 5500.0000 | 5500.0000 | 3000.0000 | 268,438,564 B |
ByteListInsertV2 | 100000000 | 172,889,432.0 ns | NA | 666.6667 | 666.6667 | 666.6667 | 100,002,269 B |
IntExtensibleBufferInsertV1 | 100000000 | 394,704,429.0 ns | NA | 12000.0000 | 12000.0000 | 12000.0000 | 9,536 B |
IntExtensibleBufferInsertV2 | 100000000 | 77,565,079.3 ns | NA | 1000.0000 | 1000.0000 | 1000.0000 | 848 B |
IntListInsertV1 | 100000000 | 690,861,266.0 ns | NA | 8000.0000 | 8000.0000 | 3000.0000 | 1,073,746,576 B |
IntListInsertV2 | 100000000 | 310,024,197.0 ns | NA | 500.0000 | 500.0000 | 500.0000 | 400,001,880 B |
速度是不是快了好几倍(毕竟直接在指针上复制会快很多,也少了托管数组分配的耗时),GC是不是少了几千、几万、几十万倍?
有人可能会问, 这玩意儿有使用场景吗 ?
答案是有的,且很多 。
基本上用 Stream持续写入二进制数据的使用场景都很契合 这个非托管动态扩容数组(如网络IO),因为这种IO都是KB/MB/GB级别的,而在这个量级下,该动态扩容数组有着出色的性能和卓越的GC优化
序列化 这种需要不断写入数据的场所也很契合
填充加密 的使用情况也很适合(比如把二进制数据每n字节之间插入m字节的假数据,最后再转托管byte数组返回出去,可以用这个动态扩容数组在塞入假数据期间实现无GC高性能处理)
TCP粘包处理也很契合 (类似上面提到的网络IO,但是不太一样,因为要不断地Enqueue二进制数据到扩容数组,然后如果满足包头记录的总长度了,就Dequeue出去,把后面的内容移动到最前面,以后会有这个方案的文章)
还有很多很多的用途 , 比如通过非托管动态扩容数组写数据,然后用其非托管数据的指针,传递给C/C++等原生代码去实现无GC的高性能功能 (这个以后也会有文章,关于搭配这个和Zlib native的文章)
为什么不是无GC非托管动态扩容数组呢?因为我们这个数组是个对象,所以造成GC。
特别感谢阅读到最后的朋友,希望能给大家带来帮助,以后我还会写一个收集对象的内存地址,转IntPtr实现的低GC托管动态扩容数组。