詳解C#中Dictionary<TKey,TValue>的存儲結構
無論是實際的項目中,還是在我們學習的過程中,都會重點的應用到Dictionary<TKey, TValue>這個存儲類型。每次對Dictionary<TKey, TValue>的添加都包含一個值和與其關聯(lián)的鍵, 使用鍵檢索值的速度非???,接近 O (1) ,因為 Dictionary<TKey, TValue> 類是作為哈希表實現(xiàn)的。首先我們來從一個簡單的例子開始,以下是對一個字典的創(chuàng)建和賦值。
Dictionary<int, string> openWith = new Dictionary<int, string>(); openWith.Add(1000, "key值為1000"); openWith.Add(1001, "key值為1001");
相信絕大部分的開發(fā)人員對以上示例不是會陌生,那么Dictionary<TKey, TValue>的實現(xiàn)原理是什么樣的呢?在字典的初始化、賦值、取值、擴容的實現(xiàn)原理是什么樣的呢?很多時候我們需要知其然,更需要知其所以然。接下來我們將從其內存的存儲的數(shù)據(jù)結構、取值的邏輯、擴容原則等幾個視角進行仔細的了解 。那我們就沿著CoreFX中Dictionary<TKey, TValue>的實現(xiàn)源碼來做一個簡單的學習和思考,這里需要特別注意一下:
學習和分析源碼時,不要先入為主,要按照框架和源碼的邏輯進行解讀,記錄下不懂的地方重點分析,最后將整個邏輯串聯(lián)起來。如果我們一開始就設定了邏輯為A-B-C,但是讀到一個階段的時候發(fā)現(xiàn)變成了C-B-A,這個時候就無法再繼續(xù)進行下去,因為具體的實現(xiàn)過程中會有很多因素造成局部調整,我們可以在解讀完畢之后,將實際的邏輯與個人前期理解的邏輯的差異進行比較,找出原因并做分析。
一、Dictionary<TKey, TValue>初始化
Dictionary<TKey, TValue>的構造方法較多,我們來看一下其中的基礎實現(xiàn)方法,首先看一下對應的源碼(源碼中不必要的部分已經做了部分刪減,保留了核心的實現(xiàn)邏輯)。
public Dictionary(int capacity, IEqualityComparer<TKey>? comparer)
{
if (capacity > 0) Initialize(capacity);
if (!typeof(TKey).IsValueType)
{
_comparer = comparer ?? EqualityComparer<TKey>.Default;
if (typeof(TKey) == typeof(string) && NonRandomizedStringEqualityComparer.GetStringComparer(_comparer!) is IEqualityComparer<string> stringComparer)
{
_comparer = (IEqualityComparer<TKey>)stringComparer;
}
}
else if (comparer is not null && comparer != EqualityComparer<TKey>.Default)
{
_comparer = comparer;
}
}以上的實現(xiàn)邏輯重點包含了兩個部分,第一部分:對Dictionary<TKey, TValue>的容量初始化;第二部分是Dictionary<TKey, TValue>的IEqualityComparer? comparer的初始化,本文重點是對Dictionary<TKey, TValue>的存儲結構進行分析,涉及到比較器的實現(xiàn)邏輯,將放在后續(xù)的章節(jié)中進行重點介紹。
我們接下來看一下Initialize()的實現(xiàn)邏輯進行一個簡單的介紹,首先一起來看一下對應的源碼實現(xiàn)(非必要部分已做刪減,方便大家可以直觀的查看)。
private int Initialize(int capacity)
{
int size = HashHelpers.GetPrime(capacity);
int[] buckets = new int[size];
Entry[] entries = new Entry[size];
_freeList = -1;
#if TARGET_64BIT
_fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)size);
#endif
_buckets = buckets;
_entries = entries;
return size;
}從上面的源碼可以看出,根據(jù)傳入的capacity參數(shù)來設定字典對應的相關容量大小,其中包含兩部分,第一部分: 根據(jù)設定的容量(capacity)大小,計算對應的buckets和entries大小,關于為什么使用buckets和entries兩個數(shù)組結構,我們將在下一節(jié)重點介紹;第二部分:判斷當前機器的位數(shù),計算對應的_fastModMultiplier。我們看一下HashHelpers.GetPrime(capacity)的計算邏輯。(該類在System.Collections命名空間下,其對應的類型定義為:internal static partial class HashHelpers)
public static int GetPrime(int min)
{
foreach (int prime in 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;
}
}HashHelpers用于計算和維護哈希表容量的素數(shù)值,為什么哈希表需要使用素數(shù)?主要是為了減少哈希沖突(hash collisions)的發(fā)生,素數(shù)的選擇能夠減少共同的因子,減小哈希沖突的可能性。此外,選擇素數(shù)還能夠確保在哈希表的容量變化時,不容易出現(xiàn)過多的重復。如果容量選擇為一個合數(shù)(非素數(shù)),那么在容量變化時,可能會導致新容量與舊容量有相同的因子,增加哈希沖突的風險。
接下來我們沿著GetPrime()的調用關系來看整個哈希表容量的計算邏輯,HashHelpers設定了一個Primes[]的只讀素數(shù)數(shù)組,具體的元素如下,至于什么使用這樣的素數(shù)的數(shù)組,主要是這些素數(shù)在實踐中已經被證明是有效的,適用于許多常見的使用場景,更多的是有助于在哈希表等數(shù)據(jù)結構中提供更好的性能。
internal static ReadOnlySpan<int> Primes => new int[]
{
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
};GetPrime()會首先循環(huán)Primes[],依次判斷設定的min大小與素數(shù)表元素的關系,若素數(shù)表中的元素大于min,則直接去對應的素數(shù),無需后續(xù)的計算,如果設置的min不在預定的素數(shù)表中,則進行素數(shù)的計算。關于素數(shù)的計算邏輯,借助本文開頭的Dictionary<TKey, TValue>的定義和賦值進行說明,首先對min和1進行按位或運算,初始化過程中未對capacity賦值時,則(min | 1)為1,對進行位運算后的i值校驗是否符合素數(shù)定義,再進行((i - 1) % HashPrime != 0)運算,其中HashPrime = 101,用于在哈希算法中作為質數(shù)因子(101是一個相對小的質數(shù),可以減少哈希碰撞的可能性,并且在計算哈希時更加高效),對于初始化未設置容量的Dictionary<TKey, TValue>,計算獲取得到的容量為int size=3。(即3*4*8=72(bit))
(注意:對于已設定了capacity的Dictionary,按照以上的邏輯進行計算對應的size值。這里就不再做過多介紹)
計算獲取到size值后,設置空閑列表為-1(_freeList = -1)。根據(jù)編譯時的運行機器的位數(shù)進行分類處理,若機器為非64位,則對buckets和entries兩個數(shù)組進行初始化。若機器為64位是,則需要進行重新計算,獲取_fastModMultiplier,其計算邏輯如下:
public static ulong GetFastModMultiplier(uint divisor) => ulong.MaxValue / divisor + 1;
以上的計算結果返回除數(shù)的近似倒數(shù),計算用于快速取模運算的乘法因子。
通過以上的計算過程,我們可以對Dictionary<TKey, TValue>的容量計算有一個簡單的認識,接下來我們來具體看一下用于存儲數(shù)據(jù)和哈希索引的兩個數(shù)組。
二、Dictionary<TKey, TValue>的存儲基礎結構
對于Dictionary<TKey, TValue>的兩個重要數(shù)組buckets和entries,我們來具體的分析一下。首先來看一下Entry[]?_entries的實際的數(shù)據(jù)結構:
private struct Entry
{
public uint hashCode;
public int next;
public TKey key;
public TValue value;
}在Dictionary<TKey, TValue>中實際存儲數(shù)據(jù)的結構是Entry[],其中數(shù)組的每個元素是一個Entry,該類型為一個結構體,用于在哈希表內部存儲每個鍵值對的信息,其中定義的key和value則是我們在設置字典時添加的鍵值對,那么對于另外兩個屬性需要重點分析一下。
hashCode為在添加key時,將key進行計算獲取得到的哈希值,哈希值的計算過程中,需要對key進行按類別進行計算,C#中對數(shù)值類型、字符串、結構體、對象的哈希值計算邏輯都不相同,其中對于"數(shù)值類型"的哈希值計算邏輯為"數(shù)字類型的哈希碼生成邏輯通常是將數(shù)字類型的值轉換為整數(shù),然后將該整數(shù)作為哈希碼。"對于字符串的哈希值計算邏輯為"默認的字符串哈希碼計算方式采用了所謂的“Jenkins One-at-a-Time Hash”算法的變體。"對于結構體和對象的哈希值計算邏輯就不做具體介紹。
next通常用于處理哈希沖突,即多個鍵具有相同的哈希碼的情況。next是一個索引,指向哈希表中下一個具有相同哈希碼的元素。其中next=-1時,表示鏈表結束;next=-2 表示空閑列表的末尾,next=-3 表示在空閑列表上的索引 0,next=-4 表示在空閑列表上的索引 1,后續(xù)則依次類推。
Entry通過使用結構體而不是類,可以減少內存開銷,因為結構體是值類型,而類是引用類型。結構體在棧上分配,而類在堆上分配。
以上介紹了Entry的結構和對應的屬性字段,接下來我們再來看一下int[] buckets的結構和計算邏輯,buckets是一個簡單的int類型的數(shù)組,這樣的數(shù)組通常用于存儲哈希桶的信息。每個桶實際上是一個索引,指向一個鏈表或鏈表的頭部,用于解決哈希沖突。
private ref int GetBucket(uint hashCode)
{
int[] buckets = _buckets!;
#if TARGET_64BIT
return ref buckets[HashHelpers.FastMod(hashCode, (uint)buckets.Length, _fastModMultiplier)];
#else
return ref buckets[(uint)hashCode % buckets.Length];
#endif
}GetBucket()用于在哈希表中獲取桶索引,其中參數(shù)hashCode為key對應的哈希碼,在64位目標體系結構下,使用 HashHelpers.FastMod 方法進行快速模運算,而在32位目標體系結構下,使用普通的取模運算。那么為什么在Dictionary<TKey, TValue>中維護一個用來存儲哈希表的桶呢?主要有以下4個目的:
(1)、解決哈希沖突:兩個或多個不同的鍵經過哈希函數(shù)得到相同的哈希碼,導致它們應該存儲在哈希表的相同位置。通過使用桶,可以在同一個位置存儲多個元素,解決了哈希沖突的問題。
(2)、提供快速查找:通過哈希函數(shù)計算鍵的哈希碼,然后將元素存儲在哈希表的桶中,可以在常數(shù)時間內(平均情況下)定位到存儲該元素的位置,實現(xiàn)快速的查找。
(3)、支持高效的插入和刪除:當插入元素時,通過哈希函數(shù)確定元素應該存儲的桶,然后將其添加到桶的鏈表或其他數(shù)據(jù)結構中。當刪除元素時,同樣可以快速定位到存儲元素的桶,并刪除該元素。
(4)、平衡負載:哈希表的性能與負載因子相關,而負載因子是元素數(shù)量與桶數(shù)量的比值。使用適當數(shù)量的桶可以幫助平衡負載,防止哈希表變得過度擁擠,從而保持其性能。在不同的哈希表實現(xiàn)可能使用不同的數(shù)據(jù)結構,如鏈表、樹等,C#的Dictionary中使用一個int[]維護這個哈希表的桶索引。
三、Dictionary<TKey, TValue>的TryAdd的實現(xiàn)方式
以上主要介紹了Dictionary<TKey, TValue>的初始化、數(shù)據(jù)對應的存儲和哈希表桶索引的存儲結構,現(xiàn)在我們具體看一下Dictionary<TKey, TValue>的添加元素的實現(xiàn)方式,下面對C#的實現(xiàn)代碼進行了精簡,刪除當前并不關注的部分。
本文實例中對key賦值的為整數(shù)類型,部分對于非數(shù)值類型、調試代碼等進行刪減。(由于對于對象或者設置了比較器邏輯相對繁瑣,將在下文中進行介紹)
private bool TryInsert(TKey key, TValue value, InsertionBehavior behavior)
{
Entry[]? entries = _entries;
uint hashCode = (uint) key.GetHashCode() ;
uint collisionCount = 0;
ref int bucket = ref GetBucket(hashCode);
int i = bucket - 1;
int index;
if (_freeCount > 0)
{
index = _freeList;
_freeList = StartOfFreeList - entries[_freeList].next;
_freeCount--;
}
else
{
int count = _count;
if (count == entries.Length)
{
Resize();
bucket = ref GetBucket(hashCode);
}
index = count;
_count = count + 1;
entries = _entries;
}
ref Entry entry = ref entries![index];
entry.hashCode = hashCode;
entry.next = bucket - 1;
entry.key = key;
entry.value = value;
bucket = index + 1;
_version++;
return true;
}以上的源碼中的實現(xiàn)邏輯中核心包含3個部分,分別是計算hashCode、計算哈希表桶索引的bucket、Dictionary擴容,上一節(jié)中已經介紹了前兩個實現(xiàn)邏輯,本節(jié)重點介紹Dictionary<TKey, TValue>的擴容邏輯,我們來看一下Resize()的實現(xiàn)邏輯。
private void Resize() => Resize(HashHelpers.ExpandPrime(_count), false);
private void Resize(int newSize, bool forceNewHashCodes)
{
Entry[] entries = new Entry[newSize];
int count = _count;
Array.Copy(_entries, entries, count);
_buckets = new int[newSize];
#if TARGET_64BIT
_fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)newSize);
#endif
for (int i = 0; i < count; i++)
{
if (entries[i].next >= -1)
{
ref int bucket = ref GetBucket(entries[i].hashCode);
entries[i].next = bucket - 1;
bucket = i + 1;
}
}
_entries = entries;
}由以上的源碼(不涉及數(shù)值類型的部分做了刪減)可以看出,HashHelpers.ExpandPrime(_count)計算新的Entry[]大小,那我們來具體看一下這個新的數(shù)組大小的計算邏輯是如何實現(xiàn)的。
public static int ExpandPrime(int oldSize)
{
int newSize = 2 * oldSize;
if ((uint)newSize > MaxPrimeArrayLength && MaxPrimeArrayLength > oldSize) return MaxPrimeArrayLength;
return GetPrime(newSize);
}對于新的entries數(shù)組的擴容,首先按照原始數(shù)組大小*2,那么對于能夠擴容的最大數(shù)值為MaxPrimeArrayLength=0x7FFFFFC3,對應32字節(jié)的最大值。計算新的數(shù)組大小時,會基于原始數(shù)組2倍的情況下,再取對應的最少素數(shù)相乘,即:realSize=2*oldSize*y(素數(shù)表中的最少素數(shù))。
【備注:其實在整個C#的擴容邏輯中,絕大數(shù)大都是按照2倍進行擴容(按照2倍擴容的方式存在一定的弊端,假設第n次擴容分配了2^n的空間(省略常數(shù)C),那么之前釋放掉的空間總和為:1 + 2 + 2^2 + ... + 2^(n-1) = 2^n - 1 正好放不下2^n的空間。這樣導致的結果就是需要操作系統(tǒng)不斷分配新的內存頁,并且數(shù)組的首地址也在不斷變大,造成緩存缺失?!?/p>
Array.Copy(_entries, entries, count)擴容后的新數(shù)組會將對舊數(shù)組進行Copy()操作,在C#中每次對數(shù)組進行擴容時,都是將就數(shù)組的元素全部拷貝到新的數(shù)組中,這個過程是比較耗時和浪費資源,如果在實際的開發(fā)過程中提前計算好數(shù)組的容量,可以極大限度的提升性能,降低GC的活動頻率。
其中對于初始化為設置Dictionary的capacity時,第一次插入元素時,C#會對兩個數(shù)組進行初始化,其中size=3,即維護的素數(shù)表中的最小值,后續(xù)超過該數(shù)組大小后,會按照以上的擴容邏輯進行擴容。
四、Dictionary<TKey, TValue>的FindValue的實現(xiàn)方式
介紹完畢Dictionary<TKey, TValue>的元素插入后,我們接下來看一下Dictionary<TKey, TValue>的查詢邏輯,在Dictionary<TKey, TValue>中實現(xiàn)查詢邏輯的核心方法是FindValue(),首先我們來看一下其實現(xiàn)的源碼。
internal ref TValue FindValue(TKey key)
{
ref Entry entry = ref Unsafe.NullRef<Entry>();
if (_buckets != null)
{
uint hashCode = (uint)key.GetHashCode();
int i = GetBucket(hashCode);
Entry[]? entries = _entries;
uint collisionCount = 0;
i--;
do
{
if ((uint)i >= (uint)entries.Length)
{
goto ReturnNotFound;
}
entry = ref entries[i];
if (entry.hashCode == hashCode && EqualityComparer<TKey>.Default.Equals(entry.key, key))
{
goto ReturnFound;
}
i = entry.next;
collisionCount++;
} while (collisionCount <= (uint)entries.Length);
goto ConcurrentOperation;
}
goto ReturnNotFound;
ConcurrentOperation:
ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported();
ReturnFound:
ref TValue value = ref entry.value;
Return:
return ref value;
ReturnNotFound:
value = ref Unsafe.NullRef<TValue>();
goto Return;
}以上的源碼中,對于計算hashCode和計算哈希索引的桶的邏輯就不再贅述,重點關注entry.hashCode == hashCode &&EqualityComparer.Default.Equals(entry.key, key)),在FindValue()中,對已經緩存的Entry[]? entries進行循環(huán)遍歷,然后依次進行比較,其中比較的邏輯包含兩部分。在判斷取值key時,不僅需要判斷傳入key值的hashCode與對應Entry[]? entries中的元素的hashCode值相等,還需要判斷key是否相同,通過EqualityComparer.Default.Equals(entry.key, key)進行比較,關于比較器的邏輯將在下一章中進行介紹。
五、學在最后的思考和感悟
上面介紹了Dictionary<TKey, TValue>的初始化、元素插入、元素插入時的擴容、元素取值的部分邏輯,我們可以發(fā)現(xiàn)在Dictionary<TKey, TValue>中維護了nt[] buckets和Entry[]? _entries兩個數(shù)組,其中用于存儲數(shù)據(jù)的結構為Entry[]? _entries,這個類型為一個結構體,在C#中結構體占用的內存要小于一個對象的內存占用。無論多么復雜的存儲結構,其內部會盡量將其簡化為一個數(shù)組,然后通過數(shù)組的存儲和讀取特性進行優(yōu)化,規(guī)避了數(shù)組在某方面的不足,發(fā)揮了其優(yōu)勢。
以上的部分思考中,我們其實可以發(fā)現(xiàn)在實際的編碼過程中,需要注意的幾個事項:
(1)、創(chuàng)建存儲結構時,需要思考其對應的存儲場景和對象,盡量選擇合適的結構進行處理,降低內存的占用情況。
(2)、對于存儲結構,盡量可以提前指定容量,避免頻繁的擴容,每次擴容都會伴隨數(shù)組的復制。
(3)、C#的擴容機制都是按照擴容2倍,在hash存儲結構中,還會按照維護的素數(shù)表進行個性化的計算優(yōu)化。
(4)、解讀源碼時,可以先選擇一個簡單的場景,盡量剔除與需要驗證場景無關的代碼,集中核心邏輯進行分析,然后再逐步進行擴展思考。
到此這篇關于詳解C#中Dictionary<TKey,TValue>的存儲結構的文章就介紹到這了,更多相關C# Dictionary<TKey,TValue>內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
C#實現(xiàn)按數(shù)據(jù)庫郵件列表發(fā)送郵件的方法
這篇文章主要介紹了C#實現(xiàn)按數(shù)據(jù)庫郵件列表發(fā)送郵件的方法,涉及C#讀取數(shù)據(jù)庫及通過自定義函數(shù)發(fā)送郵件的相關技巧,具有一定參考借鑒價值,需要的朋友可以參考下2015-07-07
C#動態(tài)創(chuàng)建button按鈕的方法實例詳解
這篇文章主要介紹了C#動態(tài)創(chuàng)建button按鈕的方法實例詳解的相關資料,需要的朋友可以參考下2017-06-06

