.NET效能優化-你應該為集合型別設定初始大小
前言
計劃開一個新的系列,來講一講在工作中經常用到的效能優化手段、思路和如何發現效能瓶頸,後續有時間的話應該會整理一系列的博文出來。
今天要談的一個性能優化的Tips是一個老生常談的點,但是也是很多人沒有注意的一個點。在使用集合型別是,你應該設定一個預估的初始大小,那麼為什麼需要這樣做?我們一起來從原始碼的角度說一說。
集合型別
我們先來聊一聊.NET BCL庫中提供的集合型別,對於這個大家肯定都不陌生,比如List
、HashSet
、Dictionary
、Queue
、Stack
等等,這些都是大家每天都用到,非常熟悉的型別了,那麼大家在使用的時候有沒有注意過它們有一個特殊建構函式呢?像下面程式碼塊中的那樣。
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+1
if ((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;
}
}
}
通過上面的程式碼我們可以得到兩個有意思的結論。
- 一個List元素最大能存放2^31個元素.
- 不設定Capacity的話,預設初始大小為4,後面會以2倍的空間擴容。
List
底層是通過陣列來存放元素的,如果空間不夠會按照2倍大小來擴容,但是它並不能無限制的存放資料。
在元素低於4個的情況下,不設定Capacity不會有任何影響;如果大於4個,那麼就會走擴容流程,不僅需要申請新的陣列,而且還要發生記憶體複製和需要GC回收原來的陣列。
大家必須知道分配記憶體、記憶體複製和GC回收記憶體的代價是巨大的,下面有個示意圖,舉了一個從4擴容到8的例子。
上面列舉了我們從原始碼中看到的情況,那麼不設定初始化的容量,對效能影響到底有多大呢?所以構建了一個Benchmark,來看看在不同量級下的影響,下面的Benchmark主要是探究兩個問題。
- 設定初始值容量和不設定有多大的差別
- 要多少設定多少比較好,還是可以隨意設定一些值
public class ListCapacityBench
{
// 宇宙的真理 42
private 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;
}
從上面的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 rehash
Debug.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);
#endif
for (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-based
bucket = i + 1;
}
}
_entries = entries;
}
從上面的原始碼中可以看到Resize的步驟如下所示。
- 通過
HashHelpers.ExpandPrime
獲取新的Size - 建立新的陣列,使用陣列拷貝將原陣列元素拷貝過去
- 對所有元素進行重新Hash,重建引用
從這裡大家就可以看出來,如果不指定初始大小的話,HashSet
和Dictionary
這樣的資料結構擴容的成本更高,因為還需要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;
}
從上面的程式碼我們就可以得出HashSet
和Dictionary
每次擴容後的大小就是大於二倍Size的第一個素數,和List直接擴容2倍還是有差別的。
至於為什麼要使用素數來作為擴容的大小,簡單來說是使用素數能讓資料在Hash以後更均勻的分佈在各個桶中(素數沒有其它約數),這不在本文的討論範圍,具體可以戳連結1、連結2、連結3瞭解更多。
總結
從效能的角度來說,強烈建議大家在使用集合型別時指定初始容量,總結了下面的幾個點。
- 如果你知道集合將要存放的元素個數,那麼就直接設定那個大小,那樣效能最高.
- 比如那種分頁介面,頁大小隻可能是50、100
- 或者做Map操作,前後的元素數量是一致的,那就可以直接設定
- 如果你不知道,那麼可以預估一下個數,在2的次方中找一個合適的就可以了.
- 可以儘量的預估多一點點,能避免Resize操作就避免
附錄
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
前言
計劃開一個新的系列,來講一講在工作中經常用到的效能優化手段、思路和如何發現效能瓶頸,後續有時間的話應該會整理一系列的博文出來。
今天要談的一個性能優化的Tips是一個老生常談的點,但是也是很多人沒有注意的一個點。在使用集合型別是,你應該設定一個預估的初始大小,那麼為什麼需要這樣做?我們一起來從原始碼的角度說一說。
集合型別
我們先來聊一聊.NET BCL庫中提供的集合型別,對於這個大家肯定都不陌生,比如List
、HashSet
、Dictionary
、Queue
、Stack
等等,這些都是大家每天都用到,非常熟悉的型別了,那麼大家在使用的時候有沒有注意過它們有一個特殊建構函式呢?像下面程式碼塊中的那樣。
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+1
if ((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;
}
}
}
通過上面的程式碼我們可以得到兩個有意思的結論。
- 一個List元素最大能存放2^31個元素.
- 不設定Capacity的話,預設初始大小為4,後面會以2倍的空間擴容。
List
底層是通過陣列來存放元素的,如果空間不夠會按照2倍大小來擴容,但是它並不能無限制的存放資料。
在元素低於4個的情況下,不設定Capacity不會有任何影響;如果大於4個,那麼就會走擴容流程,不僅需要申請新的陣列,而且還要發生記憶體複製和需要GC回收原來的陣列。
大家必須知道分配記憶體、記憶體複製和GC回收記憶體的代價是巨大的,下面有個示意圖,舉了一個從4擴容到8的例子。
上面列舉了我們從原始碼中看到的情況,那麼不設定初始化的容量,對效能影響到底有多大呢?所以構建了一個Benchmark,來看看在不同量級下的影響,下面的Benchmark主要是探究兩個問題。
- 設定初始值容量和不設定有多大的差別
- 要多少設定多少比較好,還是可以隨意設定一些值
public class ListCapacityBench
{
// 宇宙的真理 42
private 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;
}
從上面的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 rehash
Debug.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);
#endif
for (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-based
bucket = i + 1;
}
}
_entries = entries;
}
從上面的原始碼中可以看到Resize的步驟如下所示。
- 通過
HashHelpers.ExpandPrime
獲取新的Size - 建立新的陣列,使用陣列拷貝將原陣列元素拷貝過去
- 對所有元素進行重新Hash,重建引用
從這裡大家就可以看出來,如果不指定初始大小的話,HashSet
和Dictionary
這樣的資料結構擴容的成本更高,因為還需要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;
}
從上面的程式碼我們就可以得出HashSet
和Dictionary
每次擴容後的大小就是大於二倍Size的第一個素數,和List直接擴容2倍還是有差別的。
至於為什麼要使用素數來作為擴容的大小,簡單來說是使用素數能讓資料在Hash以後更均勻的分佈在各個桶中(素數沒有其它約數),這不在本文的討論範圍,具體可以戳連結1、連結2、連結3瞭解更多。
總結
從效能的角度來說,強烈建議大家在使用集合型別時指定初始容量,總結了下面的幾個點。
- 如果你知道集合將要存放的元素個數,那麼就直接設定那個大小,那樣效能最高.
- 比如那種分頁介面,頁大小隻可能是50、100
- 或者做Map操作,前後的元素數量是一致的,那就可以直接設定
- 如果你不知道,那麼可以預估一下個數,在2的次方中找一個合適的就可以了.
- 可以儘量的預估多一點點,能避免Resize操作就避免
附錄
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」所有。