CoreFX中Dictionary<TKey, TValue>的原始碼解讀

2023-11-14 15:01:52
  無論是實際的專案中,還是在我們學習的過程中,都會重點的應用到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>的儲存和讀取邏輯的簡單介紹,如錯漏的地方,還望指正。