.NET性能优化-你应该为集合类型设置初始大小

前言

计划开一个新的系列,来讲一讲在工作中经常用到的性能优化手段、思路和如何发现性能瓶颈,后续有时间的话应该会整理一系列的博文出来。
今天要谈的一个性能优化的Tips是一个老生常谈的点,但是也是很多人没有注意的一个点。在使用集合类型是,你应该设置一个预估的初始大小,那么为什么需要这样做?我们一起来从源码的角度说一说。

集合类型

我们先来聊一聊.NET BCL库中提供的集合类型,对于这个大家肯定都不陌生,比如ListHashSetDictionaryQueueStack等等,这些都是大家每天都用到,非常熟悉的类型了,那么大家在使用的时候有没有注意过它们有一个特殊构造函数呢?像下面代码块中的那样。

public Stack (int capacity) 
public List (int capacity)
public Queue (int capacity)
public HashSet (int capacity)
public Dictionary (int capacity)

哎?为什么这些构造函数都有一个叫capacity的参数呢?我们来看看这个参数的注释。初始化类的新实例,该实例为空并且具有指定的初始容量或默认初始容量。
这就很奇怪了不是吗?在我们印象里面只有数组之类的才需要指定固定的长度,为什么这些可以无限添加元素的集合类型也要设置初始容量呢?这其实和这些集合类型的实现方式有关,废话不多说,我们直接看源码。

List源码

首先来看比较简单的List的源码(源码地址在文中都做了超链接,可以直接点击过去,在文末也会附上链接地址)。下面是List的私有变量。

// 用于存在实际的数据,添加进List的元素都由存储在_items数组中
internal T[] _items;// 当前已经存储了多少元素
internal int _size;// 当前的版本号,List每发生一次元素的变更,版本号都会+1
private int _version;

从上面的源码中,我们可以看到虽然List是动态集合,可以无限的往里面添加元素,但是它底层存储数据的还是使用的数组,那么既然使用的数组那它是怎么实现能无限的往里面添加元素的?我们来看看Add方法。

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(T item)
{// 版本号+1_version++;T[] array = _items;int size = _size;// 如果当前已经使用的空间 小于数组大小那么直接存储 size+1if ((uint)size < (uint)array.Length){_size = size + 1;array[size] = item;}else{// 注意!!如果已经使用的空间等于数组大小,那么走AddWithResize方法AddWithResize(item);}
}

从上面的源码可以看到,如果内部_item数组有足够的空间,那么元素直接往里面加就好了,但是如果内部已存放的元素_size等于_item数组大小时,会调用AddWithResize方法,我们来看看里面做了啥。

// AddWithResize方法
[MethodImpl(MethodImplOptions.NoInlining)]
private void AddWithResize(T item)
{Debug.Assert(_size == _items.Length);int size = _size;// 调用Grow方法,并且把size+1,至少需要size+1的空间才能完成Add操作Grow(size + 1);_size = size + 1;_items[size] = item;
}// Grow方法
private void Grow(int capacity)
{Debug.Assert(_items.Length < capacity);// 如果内部数组长度等于0,那么赋值为DefaultCapacity(大小为4),否则就赋值两倍当前长度int newcapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length;// 这里做了一个判断,如果newcapacity大于Array.MaxLength(大小是2^31元素)// 也就是说一个List最大能存储2^32元素if ((uint)newcapacity > Array.MaxLength) newcapacity = Array.MaxLength;// 如果newpapacity小于预算需要的容量,也就是说元素数量大于Array.MaxLength// 后面Capacity会抛出异常if (newcapacity < capacity) newcapacity = capacity;// 为Capacity属性设置值Capacity = newcapacity;
}// Capacity属性
public int Capacity
{// 获取容量,直接返回_items的容量get => _items.Length;set{// 如果value值还小于当前元素个数// 直接抛异常if (value < _size){ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.value, ExceptionResource.ArgumentOutOfRange_SmallCapacity);}// 如果value值和当前数组长度不匹配// 那么走扩容逻辑if (value != _items.Length){// value大于0新建一个新的数组// 将原来的数组元素拷贝过去if (value > 0){T[] newItems = new T[value];if (_size > 0){Array.Copy(_items, newItems, _size);}_items = newItems;}// value小于0 那么直接把_items赋值为空数组// 原本的数组可以被gc回收了else{_items = s_emptyArray;}}}

通过上面的代码我们可以得到两个有意思的结论。

  1. 一个List元素最大能存放2^31个元素.

  2. 不设置Capacity的话,默认初始大小为4,后面会以2倍的空间扩容。

List底层是通过数组来存放元素的,如果空间不够会按照2倍大小来扩容,但是它并不能无限制的存放数据。
在元素低于4个的情况下,不设置Capacity不会有任何影响;如果大于4个,那么就会走扩容流程,不仅需要申请新的数组,而且还要发生内存复制和需要GC回收原来的数组。
大家必须知道分配内存、内存复制和GC回收内存的代价是巨大的,下面有个示意图,举了一个从4扩容到8的例子。
8576b0f2a04b4bb8ccedd6ab2623c440.png

上面列举了我们从源码中看到的情况,那么不设置初始化的容量,对性能影响到底有多大呢?所以构建了一个Benchmark,来看看在不同量级下的影响,下面的Benchmark主要是探究两个问题。

  • 设置初始值容量和不设置有多大的差别

  • 要多少设置多少比较好,还是可以随意设置一些值

public class ListCapacityBench
{// 宇宙的真理 42private static readonly Random OriginRandom = new(42);// 整一个数列模拟常见的集合大小 最大12万private static readonly int[] Arrays ={3, 5, 8, 13, 21, 34, 55, 89, 100, 120, 144, 180, 200, 233, 250, 377, 500, 550, 610, 987, 1000, 1500, 1597, 2000, 2584,4181, 5000, 6765, 10946, 17711, 28657, 46368, 75025, 121393};// 生成一些随机数private static readonly int[] OriginArrays = Enumerable.Range(0, Arrays.Max()).Select(c => OriginRandom.Next()).ToArray();// 不设置容量[Benchmark(Baseline = true)]public int WithoutCapacity(){return InnerTest(null);}// 刚好设置需要的容量[Benchmark]public int SetArrayLengthCapacity(){return InnerTest(null, true);}// 设置为8[Benchmark]public int Set8Capacity(){return InnerTest(8);}// 设置为16[Benchmark]public int Set16Capacity(){return InnerTest(16);}// 设置为32[Benchmark]public int Set32Capacity(){return InnerTest(32);}// 设置为64[Benchmark]public int Set64Capacity(){return InnerTest(64);}// 实际的测试方法// 不使用JIT优化,模拟集合的实际使用场景[MethodImpl(MethodImplOptions.NoOptimization)]private static int InnerTest(int? capacity, bool setLength = false){var list = new List<int>();foreach (var length in Arrays){List<int> innerList;if (capacity == null){innerList = setLength ? new List<int>(length) : new List<int>();}else{innerList = new List<int>(capacity.Value);}// 真正的测试方法  简单的填充数据foreach (var item in OriginArrays.AsSpan()[..length]){innerList.Add(item);}list.Add(innerList.Count);}return list.Count;}

9c1118c9fd30632490d712005cdf1bb8.png
从上面的Benchmark结果可以看出来,设置刚好需要的初始容量最快(比不设置快40%)、GC次数最少(50%+)、分配的内存也最少(节约60%),另外不建议不设置初始大小,它是最慢的。
要是实在不能预估大小,那么可以无脑设置一个8表现稍微好一点点。如果能预估大小,因为它是2倍扩容,可以在2的N次方中找一个接近的。

8 16 32 64 128 512 1024 2048 4096 8192 ......

Queue、Stack源码

接下来看看Queue和Stack,看看它的扩容逻辑是怎么样的。

private void Grow(int capacity)
{Debug.Assert(_array.Length < capacity);const int GrowFactor = 2;const int MinimumGrow = 4;int newcapacity = GrowFactor * _array.Length;if ((uint)newcapacity > Array.MaxLength) newcapacity = Array.MaxLength;newcapacity = Math.Max(newcapacity, _array.Length + MinimumGrow);if (newcapacity < capacity) newcapacity = capacity;SetCapacity(newcapacity);
}

基本一样,也是2倍扩容,所以按照我们上面的规则就好了。

HashSet、Dictionary源码

HashSet和Dictionary的逻辑实现类似,只是一个Key就是Value,另外一个是Key对应Value。不过它们的扩容方式有所不同,具体可以看我之前的博客,来看看扩容的源码,这里以HashSet为例。

private void Resize() => Resize(HashHelpers.ExpandPrime(_count), forceNewHashCodes: false);
private void Resize(int newSize, bool forceNewHashCodes)
{// Value types never rehashDebug.Assert(!forceNewHashCodes || !typeof(T).IsValueType);Debug.Assert(_entries != null, "_entries should be non-null");Debug.Assert(newSize >= _entries.Length);var entries = new Entry[newSize];int count = _count;Array.Copy(_entries, entries, count);if (!typeof(T).IsValueType && forceNewHashCodes){Debug.Assert(_comparer is NonRandomizedStringEqualityComparer);_comparer = (IEqualityComparer<T>)((NonRandomizedStringEqualityComparer)_comparer).GetRandomizedEqualityComparer();for (int i = 0; i < count; i++){ref Entry entry = ref entries[i];if (entry.Next >= -1){entry.HashCode = entry.Value != null ? _comparer!.GetHashCode(entry.Value) : 0;}}if (ReferenceEquals(_comparer, EqualityComparer<T>.Default)){_comparer = null;}}// Assign member variables after both arrays allocated to guard against corruption from OOM if second fails_buckets = new int[newSize];
#if TARGET_64BIT_fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)newSize);
#endiffor (int i = 0; i < count; i++){ref Entry entry = ref entries[i];if (entry.Next >= -1){ref int bucket = ref GetBucketRef(entry.HashCode);entry.Next = bucket - 1; // Value in _buckets is 1-basedbucket = i + 1;}}_entries = entries;
}

从上面的源码中可以看到Resize的步骤如下所示。

  1. 通过HashHelpers.ExpandPrime获取新的Size

  2. 创建新的数组,使用数组拷贝将原数组元素拷贝过去

  3. 对所有元素进行重新Hash,重建引用

从这里大家就可以看出来,如果不指定初始大小的话,HashSetDictionary这样的数据结构扩容的成本更高,因为还需要ReHash这样的操作。
那么HashHelpers.ExpandPrime是一个什么样的方法呢?究竟每次会扩容多少空间呢?我们来看HashHelpers源码。

public const uint HashCollisionThreshold = 100;// 这是比Array.MaxLength小最大的素数
public const int MaxPrimeArrayLength = 0x7FFFFFC3;
public const int HashPrime = 101;public static int ExpandPrime(int oldSize)
{// 新的size等于旧size的两倍int nwSize = 2 * oldSize;// 和List一样,如果大于了指定最大值,那么直接返回最大值if ((uint)newSize > MaxPrimeArrayLength && MaxPrimeArrayLength > oldSize){Debug.Assert(MaxPrimeArrayLength == GetPrime(MaxPrimeArrayLength), "Invalid MaxPrimeArrayLength");return MaxPrimeArrayLength;}// 获取大于newSize的第一素数return GetPrime(newSize);
}   public static int GetPrime(int min)
{if (min < 0)throw new ArgumentException(SR.Arg_HTCapacityOverflow);// 获取大于min的第一个素数foreach (int prime in s_primes){if (prime >= min)return prime;}// 如果素数列表里面没有 那么计算for (int i = (min | 1); i < int.MaxValue; i += 2){if (IsPrime(i) && ((i - 1) % HashPrime != 0))return i;}return min;
}// 用于扩容的素数列表
private static readonly int[] s_primes =
{3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239, 293, 353, 431, 521, 631, 761, 919,1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591,17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523, 108631, 130363, 156437,187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827, 807403, 968897, 1162687, 1395263,1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471, 7199369
};// 当容量大于7199369时,需要计算素数
public static bool IsPrime(int candidate)
{if ((candidate & 1) != 0){int limit = (int)Math.Sqrt(candidate);for (int divisor = 3; divisor <= limit; divisor += 2){if ((candidate % divisor) == 0)return false;}return true;}return candidate == 2;
}

从上面的代码我们就可以得出HashSetDictionary每次扩容后的大小就是大于二倍Size的第一个素数,和List直接扩容2倍还是有差别的。

至于为什么要使用素数来作为扩容的大小,简单来说是使用素数能让数据在Hash以后更均匀的分布在各个桶中(素数没有其它约数),这不在本文的讨论范围,具体可以戳链接1、链接2、链接3了解更多。

总结

从性能的角度来说,强烈建议大家在使用集合类型时指定初始容量,总结了下面的几个点。

  • 如果你知道集合将要存放的元素个数,那么就直接设置那个大小,那样性能最高.

    • 比如那种分页接口,页大小只可能是50、100

    • 或者做Map操作,前后的元素数量是一致的,那就可以直接设置

  • 如果你不知道,那么可以预估一下个数,在2的次方中找一个合适的就可以了.

    • 可以尽量的预估多一点点,能避免Resize操作就避免

附录

List源码:

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/List.cs

Stack源码:

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Collections/src/System/Collections/Generic/Stack.cs

Queue源码:

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/Queue.cs

HashSet源码:

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/HashSet.cs

Dictionary源码:

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/Dictionary.cs

浅析 C# Dictionary:

https://www.cnblogs.com/InCerry/p/10325290.html

作者:InCerry

出处:https://www.cnblogs.com/InCerry/p/Dotnet-Opt-Perf-You-Should-Set-Capacity-For-Collection.html

版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。

声明:本博客版权归「InCerry」所有。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/288220.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

android单元测试

1、创建一个类继承AndroidTestCase 2、在AndroidManifest.xml中配置 转载于:https://www.cnblogs.com/kingxiaozi/p/4669689.html

(十一)python3 只需3小时带你轻松入门——面向对象

类 用来描述具有相同的属性和方法的对象的集合。它定义了该集合中每个对象所共有的属性和方法。对象是类的实例。 例如&#xff1a; 人是一种类型&#xff0c;拥有年龄、身高、体重、性别等属性。具体一个人是一个对象&#xff0c;例如小明&#xff1b;小明有年龄、身高、体重…

javascript:with的用法以及延长作用域链

转自&#xff1a;http://lllt.iteye.com/blog/1246424 《Javascript高级程序设计&#xff08;第二版&#xff09;》第66页中提到&#xff1a;“由于with语句的变量对象是只读的&#xff0c;结果url就成了函数执行环境的一部分&#xff0c;因而可以作为函数的值被返回。”&#…

linux之pmap命令查看进程的地址空间和占用的内存

1 问题 我们怎么知道进程占用的内存多大 2 我们可以使用pmap命令 pmap命令用于显示一个或多个进程的内存状态,报告进程的地址空间和内存状态信息。 一般使用 pmap pid 一般参数选项如下 -x extended显示扩展格式 -d device显示设备格式 -q quiet不显示header/footer行 …

【OAuth2】:赋予用户控制权的安全通行证--原理篇

&#x1f973;&#x1f973;Welcome Huihuis Code World ! !&#x1f973;&#x1f973; 接下来看看由辉辉所写的关于OAuth2的相关操作吧 目录 &#x1f973;&#x1f973;Welcome Huihuis Code World ! !&#x1f973;&#x1f973; 一.什么是OAuth? 二.为什么要用OAuth?…

坚持建设国家自主信息体系,龙芯中科胡伟武“419”论坛畅谈LoongArch生态建设蓝图...

4月19日&#xff0c;第五届关键信息基础设施自主安全创新论坛在北京召开&#xff0c;这也是龙芯中科正式发布自主指令系统LoongArch一周年之际。在本次论坛上&#xff0c;龙芯中科董事长胡伟武发表了《龙架构&#xff08;LoongArch&#xff09;生态建设》的演讲&#xff0c;围绕…

(十)python3 只需3小时带你轻松入门——模块与包

模块 一般情况下&#xff0c;是一个以.py为后缀的文件。使用import导入。我们创建一个py文件&#xff0c;名为myprint&#xff0c;该py文件作为模块&#xff0c;在里面定义一个函数名为myprint&#xff1a; def myprint():print(这是我的print模块)保存后&#xff0c;在同目录…

【ArcGIS风暴】ArcGIS 10.6创建规则格网(渔网fishnet)图文经典详解

GIS中常常需要地图分幅与编号,或者需要按照规则格网(三角网、矩形网等)去批量裁剪或提取矢量和栅格数据,相关内容可以参看下面的文章。本文主要详细讲解ArcGIS10.6软件中创建渔网的方法,为地图分幅或规则裁剪做好数据准备。 ArcGIS批量裁剪提取或分幅方法总结参考文章: 《…

计算机转财经专业考试考什么,高三对口升学第三次模拟考试财经基础课计算机专业课...

商丘市三高2012-2013学年度第二学期第五次模考《计算机专业专业课》试卷一、选择题(每小题2分&#xff0c;共100分。)1、在Access中&#xff0c;建立的数据库文件的扩展名为()。(A)dbt (B)dbf (C)mdf (D)mdb2、如果“通讯录”表和“籍贯”表通过各自的“籍贯代码”字段建立了一…

linux之wget和curl如何携带cookie进行链接访问

1 问题 wget和curl如何携带cookie进行链接访问 2 解决办法 1) curl携带cookie curl --cookie "key=value" "链接地址" 这里的链接一定要加上双引号,如果链接太长,可能有问题. 如下 curl --cookie "name=zhangsan" "http://localhost…

xshell通过隧道连接_DNS安全之隧道攻击

DNS隧道攻击原理&#xff1a;即协议封装&#xff0c;IP over DNS&#xff0c;将其他协议封装在合法的dns请求和应答报文中&#xff0c;clent端通过构造特殊的域名记录请求发送数据给server&#xff0c;server端通过构造特殊的txt记录值来回应数据给client。其特点&#xff1a;请…

C#内建接口:IEquatable泛型

这节来讲一下泛型接口&#xff1a;IEquatable。IEquatable泛型接口处于System.Runtime命名空间下&#xff0c;最早在.NET Framework 2.0中发布&#xff0c;只有泛型版本。像之前我们讲过的IComparable&#xff0c;IEnumerable接口&#xff0c;它们属于是1.0时期的内建接口&…

联想台式计算机 不启动u盘,新电脑不识别u盘启动盘?不用送修,自己就能搞定!...

原标题&#xff1a;新电脑不识别u盘启动盘&#xff1f;不用送修&#xff0c;自己就能搞定&#xff01;新买的电脑想要通过u盘启动来装系统&#xff0c;但是一些用户却遇到难题了&#xff0c;以为制作好u盘启动盘就能直接开机启动了。殊不知&#xff0c;很多新电脑都是预装的win…

Hadoop(三)通过C#/python实现Hadoop MapReduce

MapReduceHadoop中将数据切分成块存在HDFS不同的DataNode中&#xff0c;如果想汇总&#xff0c;按照常规想法就是&#xff0c;移动数据到统计程序&#xff1a;先把数据读取到一个程序中&#xff0c;再进行汇总。但是HDFS存的数据量非常大时&#xff0c;对汇总程序所在的服务器将…

python简介及环境安装

Python的创始人为荷兰人吉多范罗苏姆 。1989年圣诞节期间&#xff0c;在阿姆斯特丹&#xff0c;Guido为了打发圣诞节的无趣&#xff0c;决心开发一个新的脚本解释程序&#xff0c;作为ABC语言的一种继承。 Python是一种跨平台的计算机程序设计语言。 是一个高层次的结合了解释…

晋中学院计算机考研,晋中学院有多少人死在考研路上

晋中学院有多少人死在考研路上(2011-05-23 20:34:30)标签&#xff1a;日记杂谈有一句话叫作&#xff1a;长江后浪推前浪,前浪死在沙滩上&#xff0c;用来形容少年老成与故人的可怜。转眼间到了谋取出路的时候了&#xff0c;班上几乎所有的人都在那里嚷嚷的要考研&#xff0c;但…

解决ArcGIS 9.3卸载时出现invalid install.log file的方法

地信君都知道,ArcGIS 9.3 License卸载时常出现“invalid install.log file”的错误提示,而导致卸载失败,如下:

使用virt-install安装kvm虚拟机时需要的问题

使用virt-install安装kvm虚拟机时需要的问题今天在做kvm的实验时&#xff0c;使用virt-install安装虚拟机的过程中遇到了一些问题&#xff0c;其中有一个问题弄了好久都没有弄好&#xff0c;不过现在已经好了。由于我使用virt-install命令安装虚拟机的&#xff0c;其命令如下&a…

从同步函数 hello-world-dotnet 开始探索OpenFunction

OpenFunction[1] 是一个现代化的云原生 FaaS&#xff08;函数即服务&#xff09;框架&#xff0c;它引入了很多非常优秀的开源技术栈&#xff0c;包括 Knative、Tekton、Shipwright、Dapr、KEDA 等&#xff0c;这些技术栈为打造新一代开源函数计算平台提供了无限可能&#xff1…

还不会制作游戏脚本解放双手?那是你不会超强自动化框架AirTest!

最近朋友问我能不能写一个自动化&#xff0c;帮他解放一下双手。我想了想&#xff0c;在我知识里很多辅助脚本制作工具&#xff0c;想想那些可能会有一堆局限性&#xff0c;想到了Python有自动化测试游戏框架或者工具&#xff0c;但是一直没有了解&#xff0c;搜了下资料&#…