C#高性能低GC 非托管动态扩容数组

开始之前

相比固定长度的Array,大家可能在编程的时候经常会使用 List<T> ,同时可能会经常往里面Add东西,因为List具有可扩容性,但是注重GC的朋友会发现(比如Unity开发者),List.Resize会造成扩容前 数组长度*泛型类型 所占字节长度的GC,同时会造成耗时,以及额外的内存占用(比如List有100个元素的时候触发了扩容,新容量为200,但是总共一共插入了150个元素,导致有50个分配的内存没被利用)

Stream(例如MemoryStream)与List一样,在Resize里会分配当前容量两倍的新byte托管数组,也会造成和上面提到的一样的情况,导致GC和可能存在的额外内存占用,以及拷贝托管数组的耗时。

那么有没有什么办法能实现一个:

  1. 能插入元素

  2. 能动态扩容

  3. 扩容不造成GC

  4. 能指定扩容长度

包含上述内容的动态扩容数组呢?

让我们先看看List和Stream的原理

List<T> 和Stream

List<T> 和Stream一样,基本是内部有一个托管数组 T[] 或 byte[] ,

内部会记录当前总容量,以及元素总数,Stream还会额外记录当前的位置

且内部实现了Resize方法,会new一个新的 托管数组 ,长度为当前总 容量的两倍

紧接着会把老数组的元素 复制 到新数组上,老数组 不会再被引用 且造成 GC

Span和Memory

最近C#提供了Span和Memory类型,提供了安全操作连续内存的方法

他们的内部实现是这样的:

  1. 记录对应泛型类型的 指针

  2. 记录该指针的 长度 (多少个元素)

Span和Memory有一点微小的区别,比如在栈上和托管堆上(Span是ref struct,Memory则是readonly struct的缘故),导致他们的用法不太一样,不过本文只需要关心他们的实现原理。

是不是发现和 List<T> 以及Stream很像?只是托管数组变成指针了,然后少了一些成员?

指针

指针是什么?指针就是一个变量在内存里的地址,所以叫做指针(Pointer),因为指针指向了内存内的一个变量

在内存中的变量有两种情况,一种是 被GC托管的变量 ,一种是 不被GC托管的变量 ,而我们的List和Stream内部的数组,就是 托管数组,由GC托管 。

如果对Span和Memory熟悉的,应该知道List可以直接转Span,怎么做到的呢?只需要把List内部托管的数组的指针传给Span的构造参数就行(List转Memory也可以就是需要自己实现,有点复杂)

那么延伸的想法就来了,如果我们用 非托管指针代替分配的托管数组 来存我们的元素,是不是就可以 不被GC托管而不被产生GC 了?答案是,没错。

自行分配非托管内存

如果我们需要申请非托管内存,我们需要实现以下一条很重要原则:

  • 手动申请的非托管内存必须用好后手动释放(不然就会造成野指针)

C#有两种方法申请非托管内存,并且任何能运行C#的平台都支持(Unity也是支持的,哪怕是IL2CPP)

  1. Marshal.AllocHGlobal ,该方法会返回指定长度的非托管内存,并且返回的内存 有可能会有值

  2. Marshal.AllocCoTaskMem ,该方法会返回 至少 指定长度的非托管内存,但是 也有可能 会返回 超过改长度 的内存,且返回的内存 不会有值(全是0)

这里很明显,第一个提到的方法适合我们的使用场景

托管的动态扩容数组类型

既然用 Sturct可以避免创建时造成的GC (如Span, Memory都是struct),为什么我们要用托管类型( Class )去 定义 我们的 动态扩容数组 呢?

请看一下上面提到的原则, 手动申请的非托管内存必须用好后手动释放(不然就会造成野指针)

只有通过托管类型,我们才能做到这一点:

  • 在构造函数( Constructor 内 申请 非托管内存)

  • 在折构函数( Finalizer 内 释放 申请的内存)

折构函数就是一个对象被GC回收前调用的函数)

实现一个非托管类型的动态扩容数组

因为 非托管类型转指针比较方便 ,所以本文我们 先实现一个非托管类型的动态扩容数组

根据我们上面提到的思路,可以得出以下代码(注,此代码不是完整体):

/// <summary>
/// A buffer that can dynamically extend
/// </summary>
/// <typeparam name="T"></typeparam>
public sealed unsafe class ExtensibleBuffer<T> where T : unmanaged
{
 /// <summary>
 /// Init extensible buffer with a capacity
 /// </summary>
 /// <param name="size"></param>
 /// <param name="initialData"></param>
 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);
}
 
 /// <summary>
 /// Free allocated memories
 /// </summary>
 ~ExtensibleBuffer()
{
   Marshal.FreeHGlobal((IntPtr)Data);
   GC.RemoveMemoryPressure(sizeOfT * TotalLength);
}
}

上面的代码 实现了构造函数和折构函数 ,其中构造函数的 参数指定了扩容大小 ,方法内部 获取了泛型T的内存大小 ,并且 申请了类型大小*扩容数量个字节 的 内存 ,并且如果 有初始化数据 ,就 把初始化托管数据复制到非托管内存上

同时,会标记 目前的总长度 ,以及 通知GC我们有申请的内存大小的内存压力(促进GC多去回收)

折构函数内,我们 释放了申请的内存 ,同时 通知GC我们之前申请的内存大小的内存压力没了,被我们释放了(让GC不要再关系我们这个动态扩容数组了)

索引器

索引器就是数组/List返回指定位置元素的方法:

/// <summary>
/// Get element at index
/// </summary>
/// <param name="index"></param>
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

这个方法需要传 两个参数 ,第一个参数是 原申请的指针 ,第二个参数是 新长度(转指针)

通过简单的封装,我们得到了:

/// <summary>
/// Ensure index exists
/// </summary>
/// <param name="index"></param>
private void EnsureCapacity(ref int index)
{
 if (index < TotalLength) return;
 while (index >= TotalLength)
{
   TotalLength += ExpandSize;
   GC.AddMemoryPre
  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值