一:背景
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 扩容机制的资料请关注社区其它相关文章! |