c# HashSet的扩容机制需要注意的

论坛 期权论坛 脚本     
niminba   2021-5-23 05:08   751   0

一:背景

1. 讲故事

自从这个纯内存项目进了大客户之后,搞得我现在对内存和CPU特别敏感,跑一点数据内存几个G的上下,特别没有安全感,总想用windbg抓几个dump看看到底是哪一块导致的,是我的代码还是同事的代码? 很多看过我博客的老朋友总是留言让我出一套windbg的系列或者视频,我也不会呀,没办法,人在江湖飘,迟早得挨上几刀,逼着也得会几个花架子😄😄😄,废话不多说,这一篇就来看看 HashSet 是如何扩容的。

二:HashSet的扩容机制

1. 如何查看

了解如何扩容,最好的办法就是翻看HashSet底层源码,最粗暴的入口点就是 HashSet.Add 方法。

从图中可以看到最后的初始化是用 Initialize 的,而且里面有这么一句神奇的代码: int prime = HashHelpers.GetPrime(capacity);,从字面意思看是获取一个质数,哈哈,有点意思,什么叫质数? 简单说就是只能被 1 和 自身 整除的数就叫做质数,那好奇心就来了,一起看看质数是怎么算的吧! 再次截图。

从图中看,HashSet底层为了加速默认定义好了 72 个质数,最大的一个质数是 719w,换句话就是说当元素个数大于 719w 的时候,就只能使用 IsPrime 方法动态计算质数,如下代码:

public static bool IsPrime(int candidate)
{
 if ((candidate & 1) != 0)
 {
  int num = (int)Math.Sqrt(candidate);
  for (int i = 3; i <= num; i += 2)
  {
   if (candidate % i == 0)
   {
    return false;
   }
  }
  return true;
 }
 return candidate == 2;
}

看完了整个流程,我想你应该明白了,当你第一次Add的时候,默认的空间占用是 72 个预定义中最小的一个质数 3,看过我之前文章的朋友知道List的默认大小是4,后面就是简单粗暴的 * 2 处理,如下代码。

private void EnsureCapacity(int min)
{
 if (_items.Length < min)
 {
  int num = (_items.Length == 0) ? 4 : (_items.Length * 2);
 }
}

2. HashSet 二次扩容探究

当HashSet的个数达到3之后,很显然要进行二次扩容,这一点不像List用一个 EnsureCapacity 方法搞定就可以了,然后细看一下怎么扩容。

public static int ExpandPrime(int oldSize)
{
 int num = 2 * oldSize;
 if ((uint)num > 2146435069u && 2146435069 > oldSize)
 {
  return 2146435069;
 }
 return GetPrime(num);
}

从图中可以看到,最后的扩容是在 ExpandPrime 方法中完成的,流程就是先 * 2, 再取最接近上限的一个质数,也就是 7 ,然后将 7 作为 HashSet 新的Size,如果你非要看演示,我就写一小段代码证明一下吧,如下图:

2. 您嗅出风险了吗?

<1> 时间上的风险

为了方便演示,我把 72 个预定义的最后几个质数显示出来。

public static readonly int[] primes = new int[72]
{
 2009191,
 2411033,
 2893249,
 3471899,
 4166287,
 4999559,
 5999471,
 7199369
};

也就是说,当HashSet的元素个数为 2893249 的时候触发扩容变成了 2893249 * 2 => 5786498 最接近的一个质数为:5999471,也就是 289w 暴增到了 599w,一下子就是 599w -289w = 310w 的空间虚占,这可是增加了两倍多哦,吓人不? 下面写个代码验证下。

    static void Main(string[] args)
    {
      var hashSet = new HashSet<int>(Enumerable.Range(0, 2893249));

      hashSet.Add(int.MaxValue);

      Console.Read();
    }

0:000> !clrstack -l

000000B8F4DBE500 00007ffaf00132ae ConsoleApplication3.Program.Main(System.String[]) [C:\4\ConsoleApp1\ConsoleApp1\Program.cs @ 16]
  LOCALS:
    0x000000B8F4DBE538 = 0x0000020e0b8fcc08
0:000> !DumpObj /d 0000020e0b8fcc08
Name:    System.Collections.Generic.HashSet`1[[System.Int32, System.Private.CoreLib]]
Size:    64(0x40) bytes
File:    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.0-preview.5.20278.1\System.Collections.dll
Fields:
       MT  Field  Offset         Type VT   Attr      Value Name
00007ffaf0096d10 4000017    8    System.Int32[] 0 instance 0000020e2025e9f8 _buck种情况算: 托管堆上的总大小近似为: 23.7M + 47.4M + 91.5M = 162.6M,我去,不简单把。。。 也就是说:托管堆上有 162.6 - 91.5 =71.1M 的未回收垃圾 ➕ 刚才的 47.4M 的空间虚占用,总浪费为:118.5M,但愿我没有算错。。。

3. 有解决方案吗?

在List中大家可以通过 Capacity 去控制List的Size,但是很遗憾,在 HashSet 中并没有类似的解决方案,只有一个很笨拙的裁剪方法: TrimExcess,用于将当前Size扩展到最接近的 质数 值, 如下代码所示:

public void TrimExcess()
{
 int prime = HashHelpers.GetPrime(m_count);
 Slot[] array = new Slot[prime];
 int[] array2 = new int[prime];
 int num = 0;
 for (int i = 0; i < m_lastIndex; i++)
 {
  if (m_slots[i].hashCode >= 0)
  {
   array[num] = m_slots[i];
   int num2 = array[num].hashCode % prime;
   array[num].next = array2[num2] - 1;
   array2[num2] = num + 1;
   num++;
  }
 }
}

应用到本案例就是将 289w 限制到 347w,仍然有 58w的空间占用。 如下图:

三: 总结

HashSet的时间和空间上虚占远比你想象的大很多,而且实占也不小,因为底层用到了双数组 m_slots m_buckets,每个Slot还有三个元素: struct Slot { int hashCode;internal int next;internal T value; }所以了解完原理之后谨慎着用吧。

以上就是c# HashSet的扩容机制需要注意的的详细内容,更多关于c# HashSet 扩容机制的资料请关注社区其它相关文章!

分享到 :
0 人收藏
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

积分:1060120
帖子:212021
精华:0
期权论坛 期权论坛
发布
内容

下载期权论坛手机APP