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

一:背景

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 _buckets
00007ffaf00f7ad0 4000018    10 ...ivate.CoreLib]][] 0 instance 0000020e2bea1020 _slots
00007ffaeffdf828 4000019    28     System.Int32 1 instance     2893250 _count
0:000> !DumpObj /d 0000020e2025e9f8
Name:    System.Int32[]
Size:    23997908(0x16e2dd4) bytes
Array:    Rank 1, Number of elements 5999471, Type Int32 (Print Array)
Fields:
None

而且最重要的是,这里是一次性扩容的,而非像redis中实现的那样渐进式扩容,时间开销也是大家值得注意的

<2> 空间上的风险

这个有什么风险呢?可以看一下:289w 和 599w 两个HashSet的占用空间大小,这也是我最敏感的。

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

      var hashSet2 = new HashSet<int>(Enumerable.Range(0, 2893249));
      hashSet2.Add(int.MaxValue);

      Console.Read();
    }

0:000> !clrstack -l
OS Thread Id: 0x4a44 (0)
000000B1B4FEE460 00007ffaf00032ea ConsoleApplication3.Program.Main(System.String[]) [C:\4\ConsoleApp1\ConsoleApp1\Program.cs @ 18]
  LOCALS:
    0x000000B1B4FEE4B8 = 0x000001d13363cc08
    0x000000B1B4FEE4B0 = 0x000001d13363d648

0:000> !objsize 0x000001d13363cc08
sizeof(000001D13363CC08) = 46292104 (0x2c25c88) bytes (System.Collections.Generic.HashSet`1[[System.Int32, System.Private.CoreLib]])
0:000> !objsize 0x000001d13363d648
sizeof(000001D13363D648) = 95991656 (0x5b8b768) bytes (System.Collections.Generic.HashSet`1[[System.Int32, System.Private.CoreLib]])

可以看到, hashSet1的占用: 46292104 / 1024 / 1024 = 44.1M, hashSet2 的占用 : 95991656 / 1024 / 1024 = 91.5M,一下子就浪费了: 91.5 - 44.1 = 47.4M

如果你真以为仅仅浪费了 47.4M 的话,那你就大错特错了,不要忘了底层在扩容的时候,使用新的 size 覆盖了老的 size,而这个 老的 size 集合在GC还没有回收的时候会一直占用堆上空间的,这个能听得懂吗? 如下图:

要验证的话可以用 windbg 去托管堆上抓一下 Slot[] m_slots int[] m_buckets 两个数组,我把代码修改如下:

  static void Main(string[] args)
  {
    var hashSet2 = new HashSet<int>(Enumerable.Range(0, 2893249));
    hashSet2.Add(int.MaxValue);
    Console.Read();
  }

0:011> !dumpheap -stat
00007ffaf84f7ad0    3  123455868 System.Collections.Generic.HashSet`1+Slot[[System.Int32, System.Private.CoreLib]][]

这里就拿 Slot[] 说事,从上面代码可以看到,托管堆上有三个 Slot[] 数组,这就有意思了,怎么有三个哈,是不是有点懵逼,没关系,我们将三个 Slot[] 的地址找出来,一个一个看。

0:011> !DumpHeap /d -mt 00007ffaf84f7ad0
     Address        MT   Size
0000016c91308048 00007ffaf84f7ad0 16743180
0000016c928524b0 00007ffaf84f7ad0 34719012
0000016ce9e61020 00007ffaf84f7ad0 71993676 

0:011> !gcroot 0000016c91308048
Found 0 unique roots (run '!gcroot -all' to see all roots).
0:011> !gcroot 0000016c928524b0
Found 0 unique roots (run '!gcroot -all' to see all roots).
0:011> !gcroot 0000016ce9e61020
Thread 2b0c:
  0000006AFAB7E5F0 00007FFAF84132AE ConsoleApplication3.Program.Main(System.String[]) [C:\4\ConsoleApp1\ConsoleApp1\Program.cs @ 15]
    rbp-18: 0000006afab7e618
      -> 0000016C8000CC08 System.Collections.Generic.HashSet`1[[System.Int32, System.Private.CoreLib]]
      -> 0000016CE9E61020 System.Collections.Generic.HashSet`1+Slot[[System.Int32, System.Private.CoreLib]][]

从上面可以看到,我通过 gcroot 去找这三个地址的引用根,有两个是没有的,最后一个有的自然就是新的 599w 的size,对不对,接下来用 !do 打出这三个地址的值。

0:011> !do 0000016c91308048
Name:    System.Collections.Generic.HashSet`1+Slot[[System.Int32, System.Private.CoreLib]][]
Size:    16743180(0xff7b0c) bytes
Array:    Rank 1, Number of elements 1395263, Type VALUETYPE (Print Array)
Fields:
None

0:011> !do 0000016c928524b0
Name:    System.Collections.Generic.HashSet`1+Slot[[System.Int32, System.Private.CoreLib]][]
Size:    34719012(0x211c524) bytes
Array:    Rank 1, Number of elements 2893249, Type VALUETYPE (Print Array)
Fields:
None

0:011> !do 0000016ce9e61020
Name:    System.Collections.Generic.HashSet`1+Slot[[System.Int32, System.Private.CoreLib]][]
Size:    71993676(0x44a894c) bytes
Array:    Rank 1, Number of elements 5999471, Type VALUETYPE (Print Array)
Fields:
None

从上面的 Rank 1, Number of elements 信息中可以看到,原来托管堆不仅有扩容前的Size :2893249,还有更前一次的扩容Size: 1395263,所以按这种情况算: 托管堆上的总大小近似为: 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)

相关推荐

  • C# ArrayList、HashSet、HashTable、List、Dictionary的区别详解

    在C#中,数组由于是固定长度的,所以常常不能满足我们开发的需求. 由于这种限制不方便,所以出现了ArrayList. ArrayList.List<T> ArrayList是可变长数组,你可以将任意多的数据Add到ArrayList里面.其内部维护的数组,当长度不足时,会自动扩容为原来的两倍. 但是ArrayList也有一个缺点,就是存入ArrayList里面的数据都是Object类型的,所以如果将值类型存入和取出的时候会发生装箱.拆箱操作(就是值类型与引用类型之间的转换),这个会影响程序性能

  • 一文看懂C#中List的扩容机制

    一:背景 1. 讲故事 在前一篇大内存排查中,我们看到了Dictionary正在做扩容操作,当时这个字典的count=251w,你把字典玩的66飞起,其实都是底层为你负重前行,比如其中的扩容机制,当你遇到几百万甚至千万的大集合这个扩容机制还真的需要挖一下,免的入戏太深,难以自拔. 二:List扩容机制 1. 如何查看 要想看它的扩容机制,可以用ILSpy去看看List的源码即可,非常简单. 从源码的 int num = (_items.Length == 0) ? 4 : (_items.Len

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

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

  • 对Java ArrayList的自动扩容机制示例讲解

    注意: 不同的JDK版本的扩容机制可能有差异 实验环境:JDK1.8 扩容机制: 当向ArrayList中添加元素的时候,ArrayList如果要满足新元素的存储超过ArrayList存储新元素前的存储能力,ArrayList会增强自身的存储能力,已达到存储新元素的要求 ArrayList:本质通过内部维护的数组对象进行数据存储 ①:分析ArrayList的add(E)方法 public boolean add(E e) { ensureCapacityInternal(size + 1); /

  • Java基础之ArrayList的扩容机制

    我们知道Java中的ArrayList对象底层是基于数组实现的,而数组是有长度限制的,那基于数组实现的ArrayList是否有长度限制呢?我们通过ArrayList的构造方法来剖析 ArrayList提供了3种构造方法以便我们来获取: ArrayList(int initialCapacity) 第一种需要赋值长度进行new ArrayList() 第二种无参构造,不需要赋值数组初始长度 ArrayList(Collection<? extends E> c) 第三种入参一个继承了Collec

  • 浅谈Golang 切片(slice)扩容机制的原理

    我们知道 Golang 切片(slice) 在容量不足的情况下会进行扩容,扩容的原理是怎样的呢?是不是每次扩一倍?下面我们结合源码来告诉你答案. 一.源码 Version : go1.15.6  src/runtime/slice.go //go1.15.6 源码 src/runtime/slice.go func growslice(et *_type, old slice, cap int) slice { //省略部分判断代码 //计算扩容部分 //其中,cap : 所需容量,newcap

  • 详解ArrayList的扩容机制

    目录 一.ArrayList 了解过吗?它是啥?有啥用? 二.ArrayList 如何指定底层数组大小的 三.数组的大小一旦被规定就无法改变 四.ArrayList 具体是怎么添加数据的 五.ArrayList 又是如何删除数据的呢 六.ArrayList 是线程安全的吗?不安全的表现 七.为什么线程不安全还要用它呢 一.ArrayList 了解过吗?它是啥?有啥用? 众所周知,Java 集合框架拥有两大接口 Collection 和 Map,其中,Collection 麾下三生子 List.S

  • 关于ArrayList的动态扩容机制解读

    目录 1. 前言 2. ArrayList 的动态扩容机制 2.1. ArrayList 的主要属性 2.2. ArrayList 的构造器 2.3. ArrayList 的动态扩容 3. 小结 3.1. 初始容量 3.2. 动态扩容大小 3.3. 动态扩容大小测试 1. 前言 对于 ArrayList 的动态扩容机制想必大家都听说过,之前的文章中也谈到过,不过由于时间久远,早已忘却. 所以利用这篇文章做做笔记,加深理解记忆 2. ArrayList 的动态扩容机制 要了解其动态扩容机制就必须先

  • add方法理解ArrayList的扩容机制

    目录 ArrayList的构造方法(前置知识) ArrayList的add方法(理解扩容机制) add 添加元素 得到最小扩容量 判断是否需要扩容 扩容方法 ArrayList的构造方法(前置知识) 可快速过 一些基本成员变量: // 默认初始大小 private static final int DEFAULT_CAPACITY = 10; // 空数组 用于空实例 private static final Object[] EMPTY_ELEMENTDATA = new Object[0];

  • 解析从源码分析常见的基于Array的数据结构动态扩容机制的详解

    本文的写作冲动来源于今晚看到的老赵的一则微博"大家知道System.Collections.Generic.List<T>是一种什么样的数据结构?内部的元素是怎么存放的?还有Dictionary<TKey,TValue>呢?-". 查了一下书,如果参考数据结构和算法里介绍的线性表合哈希表的特点,非常官方的答案就类似:List<T>是一种线性的内存连续分配的存储结构,元素是顺序存放的:它的优点是内存连续分配,相对节省空间,在设定长度范围内增加元素开销很

  • 新手入门了解ArrayList扩容机制

    我们下面用最简单的代码创建ArrayList并添加11个元素,并 一 一 讲解底层源码:在说之前,给大家先普及一些小知识: >ArrayList底层是用数组来实现的 >数组一旦创建后,大小就是固定的,如果超出了数组大小后,就会创建一个新的数组 >接下来所谓数组的扩容实质上是重新创建一个大小更大的新数组 @Test public void testArrayList() { //创建一个泛型为String的ArrayList(这里泛型是什么不重要) ArrayList<String&

随机推荐