無論是實際的專案中,還是在我們學習的過程中,都會重點的應用到Dictionary<TKey, TValue>這個儲存型別。每次對Dictionary<TKey, TValue>的新增都包含一個值和與其關聯的鍵, 使用鍵檢索值的速度非常快,接近 O (1) ,因為 Dictionary<TKey, TValue> 類是作為雜湊表實現的。首先我們來從一個簡單的例子開始,以下是對一個字典的建立和賦值。
1 Dictionary<int, string> openWith = new Dictionary<int, string>(); 2 openWith.Add(1000, "key值為1000"); 3 openWith.Add(1001, "key值為1001");
相信絕大部分的開發人員對以上示例不是會陌生,那麼Dictionary<TKey, TValue>的實現原理是什麼樣的呢?在字典的初始化、賦值、取值、擴容的實現原理是什麼樣的呢?很多時候我們需要知其然,更需要知其所以然。接下來我們將從其記憶體的儲存的資料結構、取值的邏輯、擴容原則等幾個視角進行仔細的瞭解 。那我們就沿著CoreFX中Dictionary<TKey, TValue>的實現原始碼來做一個簡單的學習和思考,這裡需要特別注意一下:
學習和分析原始碼時,不要先入為主,要按照框架和原始碼的邏輯進行解讀,記錄下不懂的地方重點分析,最後將整個邏輯串聯起來。如果我們一開始就設定了邏輯為A-B-C,但是讀到一個階段的時候發現變成了C-B-A,這個時候就無法再繼續進行下去,因為具體的實現過程中會有很多因素造成區域性調整,我們可以在解讀完畢之後,將實際的邏輯與個人前期理解的邏輯的差異進行比較,找出原因並做分析。
一、Dictionary<TKey, TValue>初始化
Dictionary<TKey, TValue>的構造方法較多,我們來看一下其中的基礎實現方法,首先看一下對應的原始碼(原始碼中不必要的部分已經做了部分刪減,保留了核心的實現邏輯)。
1 public Dictionary(int capacity, IEqualityComparer<TKey>? comparer) 2 { 3 if (capacity > 0) Initialize(capacity); 4 if (!typeof(TKey).IsValueType) 5 { 6 _comparer = comparer ?? EqualityComparer<TKey>.Default; 7 if (typeof(TKey) == typeof(string) && NonRandomizedStringEqualityComparer.GetStringComparer(_comparer!) is IEqualityComparer<string> stringComparer) 9 { 10 _comparer = (IEqualityComparer<TKey>)stringComparer; 11 } 12 } 13 else if (comparer is not null && comparer != EqualityComparer<TKey>.Default) 14 { 15 _comparer = comparer; 16 } 17 }
以上的實現邏輯重點包含了兩個部分,第一部分:對Dictionary<TKey, TValue>的容量初始化;第二部分是Dictionary<TKey, TValue>的IEqualityComparer? comparer的初始化,本文重點是對Dictionary<TKey, TValue>的儲存結構進行分析,涉及到比較器的實現邏輯,將放在後續的章節中進行重點介紹。
我們接下來看一下Initialize()的實現邏輯進行一個簡單的介紹,首先一起來看一下對應的原始碼實現(非必要部分已做刪減,方便大家可以直觀的檢視)。
1 private int Initialize(int capacity) 2 { 3 int size = HashHelpers.GetPrime(capacity); 4 int[] buckets = new int[size]; 5 Entry[] entries = new Entry[size]; 6 _freeList = -1; 7 #if TARGET_64BIT 8 _fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)size); 9 #endif 10 _buckets = buckets; 11 _entries = entries; 12 return size; 13 }
從上面的原始碼可以看出,根據傳入的capacity引數來設定字典對應的相關容量大小,其中包含兩部分,第一部分: 根據設定的容量(capacity)大小,計算對應的buckets和entries大小,關於為什麼使用buckets和entries兩個陣列結構,我們將在下一節重點介紹;第二部分:判斷當前機器的位數,計算對應的_fastModMultiplier。我們看一下HashHelpers.GetPrime(capacity)的計算邏輯。(該類在System.Collections名稱空間下,其對應的型別定義為:internal static partial class HashHelpers)
1 public static int GetPrime(int min) 2 { 3 foreach (int prime in Primes) 4 { 5 if (prime >= min) return prime; 6 for (int i = (min | 1); i < int.MaxValue; i += 2) 7 { 8 if (IsPrime(i) && ((i - 1) % HashPrime != 0)) return i; 9 } 10 return min; 11 } 12 }
HashHelpers用於計算和維護雜湊表容量的素數值,為什麼雜湊表需要使用素數?主要是為了減少雜湊衝突(hash collisions)的發生,素數的選擇能夠減少共同的因子,減小雜湊衝突的可能性。此外,選擇素數還能夠確保在雜湊表的容量變化時,不容易出現過多的重複。如果容量選擇為一個合數(非素數),那麼在容量變化時,可能會導致新容量與舊容量有相同的因子,增加雜湊衝突的風險。
接下來我們沿著GetPrime()的呼叫關係來看整個雜湊表容量的計算邏輯,HashHelpers設定了一個Primes[]的只讀素數陣列,具體的元素如下,至於什麼使用這樣的素數的陣列,主要是這些素數在實踐中已經被證明是有效的,適用於許多常見的使用場景,更多的是有助於在雜湊表等資料結構中提供更好的效能。
1 internal static ReadOnlySpan<int> Primes => new int[] 2 { 3 3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239, 293, 353, 431, 521, 631, 761, 919, 4 1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591, 5 17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523, 108631, 130363, 156437, 6 187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827, 807403, 968897, 1162687, 1395263, 7 1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471, 7199369 8 };
GetPrime()會首先迴圈Primes[],依次判斷設定的min大小與素數表元素的關係,若素數表中的元素大於min,則直接去對應的素數,無需後續的計算,如果設定的min不在預定的素數表中,則進行素數的計算。關於素數的計算邏輯,藉助本文開頭的Dictionary<TKey, TValue>的定義和賦值進行說明,首先對min和1進行按位或運算,初始化過程中未對capacity賦值時,則(min | 1)為1,對進行位運算後的i值校驗是否符合素數定義,再進行((i - 1) % HashPrime != 0)運算,其中HashPrime = 101,用於在雜湊演演算法中作為質數因子(101是一個相對小的質數,可以減少雜湊碰撞的可能性,並且在計算雜湊時更加高效),對於初始化未設定容量的Dictionary<TKey, TValue>,計算獲取得到的容量為int size=3。(即3*4*8=72(bit))
(注意:對於已設定了capacity的Dictionary,按照以上的邏輯進行計算對應的size值。這裡就不再做過多介紹)
計算獲取到size值後,設定空閒列表為-1(_freeList = -1)。根據編譯時的執行機器的位數進行分類處理,若機器為非64位,則對buckets和entries兩個陣列進行初始化。若機器為64位是,則需要進行重新計算,獲取_fastModMultiplier,其計算邏輯如下:
public static ulong GetFastModMultiplier(uint divisor) => ulong.MaxValue / divisor + 1;
以上的計算結果返回除數的近似倒數,計算用於快速取模運算的乘法因子。
透過以上的計算過程,我們可以對Dictionary<TKey, TValue>的容量計算有一個簡單的認識,接下來我們來具體看一下用於儲存資料和雜湊索引的兩個陣列。
二、Dictionary<TKey, TValue>的儲存基礎結構
對於Dictionary<TKey, TValue>的兩個重要陣列buckets和entries,我們來具體的分析一下。首先來看一下Entry[]?_entries的實際的資料結構:
1 private struct Entry 2 { 3 public uint hashCode; 4 public int next; 5 public TKey key; 6 public TValue value; 7 }
在Dictionary<TKey, TValue>中實際儲存資料的結構是Entry[],其中陣列的每個元素是一個Entry,該型別為一個結構體,用於在雜湊表內部儲存每個鍵值對的資訊,其中定義的key和value則是我們在設定字典時新增的鍵值對,那麼對於另外兩個屬性需要重點分析一下。
hashCode為在新增key時,將key進行計算獲取得到的雜湊值,雜湊值的計算過程中,需要對key進行按類別進行計算,C#中對數值型別、字串、結構體、物件的雜湊值計算邏輯都不相同,其中對於"數值型別"的雜湊值計算邏輯為"數字型別的雜湊碼生成邏輯通常是將數字型別的值轉換為整數,然後將該整數作為雜湊碼。"對於字串的雜湊值計算邏輯為"預設的字串雜湊碼計算方式採用了所謂的“Jenkins One-at-a-Time Hash”演演算法的變體。"對於結構體和物件的雜湊值計算邏輯就不做具體介紹。
next通常用於處理雜湊衝突,即多個鍵具有相同的雜湊碼的情況。next是一個索引,指向雜湊表中下一個具有相同雜湊碼的元素。其中next=-1時,表示連結串列結束;next=-2 表示空閒列表的末尾,next=-3 表示在空閒列表上的索引 0,next=-4 表示在空閒列表上的索引 1,後續則依次類推。
Entry透過使用結構體而不是類,可以減少記憶體開銷,因為結構體是值型別,而類是引用型別。結構體在棧上分配,而類在堆上分配。
以上介紹了Entry的結構和對應的屬性欄位,接下來我們再來看一下int[] buckets的結構和計算邏輯,buckets是一個簡單的int型別的陣列,這樣的陣列通常用於儲存雜湊桶的資訊。每個桶實際上是一個索引,指向一個連結串列或連結串列的頭部,用於解決雜湊衝突。
1 private ref int GetBucket(uint hashCode) 2 { 3 int[] buckets = _buckets!; 4 #if TARGET_64BIT 5 return ref buckets[HashHelpers.FastMod(hashCode, (uint)buckets.Length, _fastModMultiplier)]; 6 #else 7 return ref buckets[(uint)hashCode % buckets.Length]; 8 #endif 9 }
GetBucket()用於在雜湊表中獲取桶索引,其中引數hashCode為key對應的雜湊碼,在64位目標體系結構下,使用 HashHelpers.FastMod 方法進行快速模運算,而在32位目標體系結構下,使用普通的取模運算。那麼為什麼在Dictionary<TKey, TValue>中維護一個用來儲存雜湊表的桶呢?主要有以下4個目的:
(1)、解決雜湊衝突:兩個或多個不同的鍵經過雜湊函式得到相同的雜湊碼,導致它們應該儲存在雜湊表的相同位置。透過使用桶,可以在同一個位置儲存多個元素,解決了雜湊衝突的問題。
(2)、提供快速查詢:透過雜湊函式計算鍵的雜湊碼,然後將元素儲存在雜湊表的桶中,可以在常數時間內(平均情況下)定位到儲存該元素的位置,實現快速的查詢。
(3)、支援高效的插入和刪除:當插入元素時,透過雜湊函式確定元素應該儲存的桶,然後將其新增到桶的連結串列或其他資料結構中。當刪除元素時,同樣可以快速定位到儲存元素的桶,並刪除該元素。
(4)、平衡負載:雜湊表的效能與負載因子相關,而負載因子是元素數量與桶數量的比值。使用適當數量的桶可以幫助平衡負載,防止雜湊表變得過度擁擠,從而保持其效能。在不同的雜湊表實現可能使用不同的資料結構,如連結串列、樹等,C#的Dictionary中使用一個int[]維護這個雜湊表的桶索引。
三、Dictionary<TKey, TValue>的TryAdd的實現方式
以上主要介紹了Dictionary<TKey, TValue>的初始化、資料對應的儲存和雜湊表桶索引的儲存結構,現在我們具體看一下Dictionary<TKey, TValue>的新增元素的實現方式,下面對C#的實現程式碼進行了精簡,刪除當前並不關注的部分。
本文例項中對key賦值的為整數型別,部分對於非數值型別、除錯程式碼等進行刪減。(由於對於物件或者設定了比較器邏輯相對繁瑣,將在下文中進行介紹)
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; }
以上的原始碼中的實現邏輯中核心包含3個部分,分別是計算hashCode、計算雜湊表桶索引的bucket、Dictionary擴容,上一節中已經介紹了前兩個實現邏輯,本節重點介紹Dictionary<TKey, TValue>的擴容邏輯,我們來看一下Resize()的實現邏輯。
1 private void Resize() => Resize(HashHelpers.ExpandPrime(_count), false); 2 3 private void Resize(int newSize, bool forceNewHashCodes) 4 { 5 Entry[] entries = new Entry[newSize]; 6 int count = _count; 7 Array.Copy(_entries, entries, count); 8 _buckets = new int[newSize]; 9 #if TARGET_64BIT 10 _fastModMultiplier = HashHelpers.GetFastModMultiplier((uint)newSize); 11 #endif 12 for (int i = 0; i < count; i++) 13 { 14 if (entries[i].next >= -1) 15 { 16 ref int bucket = ref GetBucket(entries[i].hashCode); 17 entries[i].next = bucket - 1; 18 bucket = i + 1; 19 } 20 } 21 _entries = entries; 22 }
由以上的原始碼(不涉及數值型別的部分做了刪減)可以看出,HashHelpers.ExpandPrime(_count)計算新的Entry[]大小,那我們來具體看一下這個新的陣列大小的計算邏輯是如何實現的。
1 public static int ExpandPrime(int oldSize) 2 { 3 int newSize = 2 * oldSize; 4 if ((uint)newSize > MaxPrimeArrayLength && MaxPrimeArrayLength > oldSize) return MaxPrimeArrayLength; 5 return GetPrime(newSize); 6 }
對於新的entries陣列的擴容,首先按照原始陣列大小*2,那麼對於能夠擴容的最大數值為MaxPrimeArrayLength=0x7FFFFFC3,對應32位元組的最大值。計算新的陣列大小時,會基於原始陣列2倍的情況下,再取對應的最少素數相乘,即:realSize=2*oldSize*y(素數表中的最少素數)。
【備註:其實在整個C#的擴容邏輯中,絕大數大都是按照2倍進行擴容(按照2倍擴容的方式存在一定的弊端,假設第n次擴容分配了2^n的空間(省略常數C),那麼之前釋放掉的空間總和為:1 + 2 + 2^2 + ... + 2^(n-1) = 2^n - 1 正好放不下2^n的空間。這樣導致的結果就是需要作業系統不斷分配新的記憶體頁,並且陣列的首地址也在不斷變大,造成快取缺失。】
Array.Copy(_entries, entries, count)擴容後的新陣列會將對舊陣列進行Copy()操作,在C#中每次對陣列進行擴容時,都是將就陣列的元素全部複製到新的陣列中,這個過程是比較耗時和浪費資源,如果在實際的開發過程中提前計算好陣列的容量,可以極大限度的提升效能,降低GC的活動頻率。
其中對於初始化為設定Dictionary的capacity時,第一次插入元素時,C#會對兩個陣列進行初始化,其中size=3,即維護的素數表中的最小值,後續超過該陣列大小後,會按照以上的擴容邏輯進行擴容。
四、Dictionary<TKey, TValue>的FindValue的實現方式
介紹完畢Dictionary<TKey, TValue>的元素插入後,我們接下來看一下Dictionary<TKey, TValue>的查詢邏輯,在Dictionary<TKey, TValue>中實現查詢邏輯的核心方法是FindValue(),首先我們來看一下其實現的原始碼。
1 internal ref TValue FindValue(TKey key) 2 { 3 ref Entry entry = ref Unsafe.NullRef<Entry>(); 4 if (_buckets != null) 5 { 6 uint hashCode = (uint)key.GetHashCode(); 7 int i = GetBucket(hashCode); 8 Entry[]? entries = _entries; 9 uint collisionCount = 0; 10 i--; 11 do 12 { 13 if ((uint)i >= (uint)entries.Length) 14 { 15 goto ReturnNotFound; 16 } 17 entry = ref entries[i]; 18 if (entry.hashCode == hashCode && EqualityComparer<TKey>.Default.Equals(entry.key, key)) 19 { 20 goto ReturnFound; 21 } 22 i = entry.next; 23 collisionCount++; 24 } while (collisionCount <= (uint)entries.Length); 25 goto ConcurrentOperation; 26 } 27 goto ReturnNotFound; 28 ConcurrentOperation: 29 ThrowHelper.ThrowInvalidOperationException_ConcurrentOperationsNotSupported(); 30 ReturnFound: 31 ref TValue value = ref entry.value; 32 Return: 33 return ref value; 34 ReturnNotFound: 35 value = ref Unsafe.NullRef<TValue>(); 36 goto Return; 37 }
以上的原始碼中,對於計算hashCode和計算雜湊索引的桶的邏輯就不再贅述,重點關注entry.hashCode == hashCode &&EqualityComparer.Default.Equals(entry.key, key)),在FindValue()中,對已經快取的Entry[]? entries進行迴圈遍歷,然後依次進行比較,其中比較的邏輯包含兩部分。在判斷取值key時,不僅需要判斷傳入key值的hashCode與對應Entry[]? entries中的元素的hashCode值相等,還需要判斷key是否相同,透過EqualityComparer.Default.Equals(entry.key, key)進行比較,關於比較器的邏輯將在下一章中進行介紹。
五、學在最後的思考和感悟
上面介紹了Dictionary<TKey, TValue>的初始化、元素插入、元素插入時的擴容、元素取值的部分邏輯,我們可以發現在Dictionary<TKey, TValue>中維護了nt[] buckets和Entry[]? _entries兩個陣列,其中用於儲存資料的結構為Entry[]? _entries,這個型別為一個結構體,在C#中結構體佔用的記憶體要小於一個物件的記憶體佔用。無論多麼複雜的儲存結構,其內部會盡量將其簡化為一個陣列,然後透過陣列的儲存和讀取特性進行最佳化,規避了陣列在某方面的不足,發揮了其優勢。
以上的部分思考中,我們其實可以發現在實際的編碼過程中,需要注意的幾個事項:
(1)、建立儲存結構時,需要思考其對應的儲存場景和物件,儘量選擇合適的結構進行處理,降低記憶體的佔用情況。
(2)、對於儲存結構,儘量可以提前指定容量,避免頻繁的擴容,每次擴容都會伴隨陣列的複製。
(3)、C#的擴容機制都是按照擴容2倍,在hash儲存結構中,還會按照維護的素數表進行個性化的計算最佳化。
(4)、解讀原始碼時,可以先選擇一個簡單的場景,儘量剔除與需要驗證場景無關的程式碼,集中核心邏輯進行分析,然後再逐步進行擴充套件思考。
以上內容是對CoreFx中Dictionary<TKey, TValue>的儲存和讀取邏輯的簡單介紹,如錯漏的地方,還望指正。