DotNet Dictionary 實現簡介

lulianqi15發表於2022-02-23

一:前言

本來筆者對DotNet的Hashtable及Dictionary認識一直集中在使用上,一個直接用object 一個可以用泛型,以前也只大概看過Hashtable的實現。最近查MSDN時發現有建議開發者使用Dictionary代替Hashtable的描述,出於好奇測試了Hashtable及Dictionary讀寫效能,發現無論讀還是寫Dictionary都大幅領先Hashtable,然後就花時間整理了Dictionary操作邏輯試圖找到這種效能提升的原因(最後會發現實現上的差異帶來的效能明顯提升也算的上是理所當然)。下文實際是介紹的Dictionary的實現(除錯中使用的源是corefx 3.1),其中穿插著對比了Hashtable的實現邏輯。
 
 

二:Dictionary成員介紹

先簡單介紹下 Dictionary裡的主要成員(https://source.dot.net/#System.Private.CoreLib/Dictionary.cs
如果您之前很少有關注過DIctionary或類似集合的實現,下面對成員的解釋可能看起來會有些跳躍,不過您還是可以通過檢視這些成員介紹形成一個大概的印象,後面一章節的內容會較詳細的向您介紹Dictionary的操作細節會反覆涉及到這些關鍵成員。
下文將講述的Dictionary集合的實現都是基於DotNet平臺的,不過主流託管平臺/語言對於BLC基礎庫的實現都有很多相似的地方,即使您不使用DotNet平臺也可以看一下,文中會極少的直接貼上BLC原始碼。

2.1:主要成員

private int[]? _buckets;
  槽列表(HashTable也是靠槽位的概念來快速找到元素的,不過不會有個專門的單獨列表)
 
private Entry[]? _entries;
  實體列表 
 
private int _count;
  被填充的實體數量(不算被刪除的數量,所以Count屬性的值實際是 _count - _freeCount )
 
private int _freeList;
  空實體索引(預設-1表示沒有,如果有在TryInsert的時候就會往這個索引地址填充)
 
private int _freeCount;
  空實體數量(指定entry被填充,然後又被清除就出現一個空實體,後面還沒有被填充的實體不計入此數量)
 
private int _version;
  版本(實體發生實質改動時++,用於遍歷時確認列表有沒有發生變化,如果有變化拋異常)
 
  Key比較器
 
  Key列表(資料來源自_entries)
 
  Value列表(資料來源自_entries) 
 
private const int StartOfFreeList = -3;
  計算free entry 的next 值的一個固定常量(next 預設0)
 

2.2:_buckets

_buckets 上面已經提到,因為他是一個較重要的成員這裡再單獨列出來進一步說明。
_buckets 是一個int陣列,結構比較簡單,陣列大小是當前Dictionary的實際容量大小,不是Count的值(這個很大可能並不是初始化時使用者指定的大小)
_buckets陣列裡的每個元素實際上包含2個關鍵資訊
  • index: 這個索引很重要,通過 [ hashcode(key)%_buckets.len ] 確定指定key應該落到的索引位置(不用遍歷key,通過輕量計算可以快速直接找到資料)
  • value: value為int型別實際上也是一個索引,這個索引指向了_entries陣列裡的真正目標實體(_buckets並沒有直接放資料內容,但HashTable裡是直接把內容都放到bucket[]裡的)

2.3:_entries

_entries是一個Entry結構的陣列,Entry結構如下。
private struct Entry
 {
      public uint hashCode;
      /// <summary>
      /// 0-based index of next entry in chain: -1 means end of chain
      /// also encodes whether this entry _itself_ is part of the free list by changing sign and subtracting 3,
      /// so -2 means end of free list, -3 means index 0 but on free list, -4 means index 1 but on free list, etc.
      /// </summary>
      public int next;
      public TKey key;     // Key of entry
      public TValue value; // Value of entry
}

 

_entries直接存放Dictionary資料實體,加上陣列的索引,每個元素有5個關鍵資訊(索引實際不屬於Entry結構的內容,方便說明放在一起解釋)
  • index:這個索引就是_buckets裡value的對應的值,key算出hashcode後先找到指定的bucket,碰撞傳送時通過其value定位到_entries指定實體
  • hashCode:key的hashcode ,這裡的hashcode是uint(HashTable的hashCode是不用最高位的,他的最高位1表示發生碰撞,而Dictionary使用next標記碰撞,所以會保留hashcode所有位,hashcode預設0,被填充後寫入當前key的hashcode,儲存hashcode是為了對比方便,key的比較先比較code會快很多,code不一樣key肯定不一樣,code一樣key才可能一樣,在當前entry被remove時,hashcode不更新,因為沒有必要更新,int為值型別,資料0與12345654321消耗都是一樣的,而且這裡也沒有用0表示特殊含義)
  • key: 儲存TKey
  • value: 儲存TValue
  • next: 比hashcode多出來的一個資料,不僅標記碰撞還記錄了碰撞資料的完整鏈路,同時也標記了空槽的完整鏈路。
next = -1 : 已經插入了資料,並且他所在的_bucket槽位之前沒有發生過碰撞,如果在查詢碰撞key鏈時,他就是最後一個值
next >= 0 : 當前資料與_entries中那個key發生碰撞,注意這裡的next指向的索引是_entries自己的索引位置(hashcode(key)%_buckets.len 是一樣的即表示碰撞,他們會使用__bucket的同一個位置) (0為預設初始值,_entries不使用next的值判斷這個位置是否為  空閒位置,他僅通過count及_freeCount,_freeList可以快速精確定位下一個合理的空閒地址)
next < -1 : 標記下一個空實體索引 (_freeList = StartOfFreeList - entries[_freeList].next;)即 |next|-3 (-3指向0索引,-4指向1索引,-2則表示已經到達尾部沒有下一個空實體了,後面的資料會根據count確認位置)
 
典型建構函式如下

 

上面的程式碼邏輯我們可以看出較常用的引數為capacity(預設不填是取的0)。
通過Initialize函式,可以看到capacity這個值並不是這個Dictionary的真實容量,真正的大小是GetPrime(capacity) 即大於capacity的最近的一個素數,如果不填capacity那即是大於0的最近的一個素數,為3。
這裡capacity是使用者設定的容量,size是這個Dictionary真正的大小,count為當前Dictionary儲存了多少個真正的資料
比如使用Dictionary(2)初始化時 capacity=2;size=3;count=0.
_freeList在建構函式中被初始化為-1 (隱含的_freeCount初始化為0 ,因為int預設就是0所以這裡不用寫)
_buckets及_entries在Dictionary初始化的時候也完成了初始化,陣列大小已經被確定(值型別的資料資源已經全部被直接分配了)。
 

2.4:官方文件

下面是主要的2份官方文件
 

三:Dictionary 運作過程介紹

本章節重點通過分析Dictionary 新增,刪除,查詢等操作執行過程,讓您理解Dictionary操作邏輯,花時間去推敲一下的話,一定會有不一樣的體會,每一個操作都有其存在的意義,個人認為設計也可以說是相當精妙。
 
Dictionary 中維護資料主要靠2個陣列buckets及entries,前面已經提到過buckets負責記錄槽位資訊,entries儲存鍵值對。
這裡有幾點會被考慮到
  1. 資料是以怎樣的順序存入的
  2. 如何高效查詢資料(不通過遍歷的方式)
  3. 如何處理碰撞
  4. 移除資料產生的空閒位如何重複利用
  5. 如何擴容
要搞清楚上面的問題,主要就是理解Dictionary元素的新增(包括更新)及刪除邏輯。
 

3.1:新增元素(TryInsert)

插入流程概要

「圖-TryInsert」
 
上圖為新增元素的簡單流程圖(對應著TryInsert函式)
 
  • 注意文中流程圖是根據dotnet core 3.1 fx 的實際原始碼省略了部分與元素儲存關聯不強的邏輯繪製出來的 。(不同版本coreFX 可能會略有不同)
  • 涉及到流程圖雖然有簡化部分邏輯,但是重要核心邏輯已多次核驗,這裡建議您可以對照Dotnet Source Browser裡的原始碼一起看。
  • 因為官方原始碼相關邏輯篇幅大且有許多關聯性,如果只貼一部分很難描述全面,所以下文中會盡量避免直接貼程式碼,而是選擇將原始碼轉換為流程圖或圖表進行介紹。
 
 
「圖-entries-0」
上圖以表格形式簡單展示了dictionary內部的核心的buckets及entries陣列結構。
dictionary插入元素的關鍵就是元素應該插入到entries陣列的什麼地方,如何在刪改查時快速定位到元素。
 

分析插入如何執行

如圖「圖-TryInsert」為插入及更新的關鍵程式碼的簡化流程圖,在插入前有基本的引數校驗,值得一提的是預設初始化函式Initialize(0)實際上將建立一個長度為3的儲存空間如圖「圖-entries-0」
這裡提一下Initialize(因為這個函式邏輯比較簡單沒有話流程圖)
可以看到一個細節實際是dictionary的容量並不是我們指定的capacity,而是取的GetPrime的值
GetPrime(capacity) 實際是大於capacity的最小素數
因為這個陣列的容量len會用在 hashcode%len 上計算元素應優先該落在哪個槽位,len使用素數會減少碰撞的發生從而提升讀寫效率(其實自己也沒有搞明白為什麼len使用素數求餘就分佈的更均勻,期待有了解的同學解答)
對TryInsert接下來就會計算Key的hashcode (所有型別的資料都有GetHashCode方法,它繼承自Object,當然您可以為自己的資料型別重寫該函式)
下面是很關鍵的一步計算該key對應的buckets槽位,這裡直接使用hashCode % (uint)_buckets.Length 求餘得到的索引即為buckets的索引號(不同版本的cocefx可能會有差異,這裡除錯使用的是dotnet core 3.1)。比如餘的結果為y那buckets[y]對應的值即是entries的索引,即entries[buckets[y]]將是可能與key發生碰撞的資料實體,應該再此處檢查碰撞(實際會跟複雜一些,後面會講到)
而buckets[y]的值應該是多少即決定著key要先在哪裡進行碰撞(如果buckets[y]的值為0表示該key不會有碰撞)。碰撞完成插入資料時會在dictionary裡在沒有空位的情況下資料是會順序插入到entries裡的,即buckets[y]的值在這種情況下會更新為count的值,在有空位的情況下會優先插入到空位中(這與HashTable不同,因為HashTable內部只維持著一個資料陣列,資料會直接存放在槽位上)
在Dictionary使用中您會發現,如果只插入資料不刪除資料,那遍歷的結果其實是有序的,它會與您插入時的順序維持一致,不過MSDN上明確說明“返回項的順序是不確定的”,因為在刪除發生時,順序就會變的不那麼可控(不過本文將向您描述這種不確定的規則,您會發現它的順序雖然不是完全按照插入的時序排列的,但它是有一個確定的規則的,最終您會發現您在某種程度上是可以完全控制他的順序的
現在請關注「圖-TryInsert」的步驟5,步驟6。這裡會描述buckets[y]的值是如何確認的
i=_buckets[hash%len] -1 (i即為後面entries的索引)
我們知道_buckets為一個init[],其中每個元素的預設初始值都會是0,所以bucket為0即表示初始狀態(之前沒有被任何key命中過)
此時如果在key沒有發生碰撞,那i就是0-1=-1,-1這個索引因為越界是不能定位到_entries上的任意值的(corefx 使用 (uint)i >= (uint)entries.Length 判斷i是否越界,一個負數的轉換為uint是一定大於int的上限的)
這裡_buckets[hash%len] -1 之所以還要-1,也是因為int預設值是0,而0也是一個正常的索引位置,為了讓預設值不能表示任何陣列索引需要-1

現在如果沒有發生碰撞,會進入「圖-TryInsert」的步驟12開始找合適的地方進行插入。(這個我們待會再看)

在這之前我們先來看下碰撞發生時Dictionary的處理機制,現在請關注「圖-TryInsert」的步驟7,步驟11,他們是處理碰撞的關鍵。
檢查到碰撞(如果_buckets[hash%len]是一個大於0的數字即表示有碰撞發生,與這個Key碰撞的資料就是entries[_buckets[hash%len] -1] )可能確實是遇到了一個重複的Key,所以需要在步驟7裡進行檢查,如果Key確實是重複的那很簡單直接更新掉,如果現在是插入模式就丟擲異常即可
注意更新時直接更新value即可,槽位資訊_buckets及entry的hashcode,next值都是不用更新的,因為他們都沒有發生改變,不過注意步驟7裡_version此時會+1 ,這個_version用於遍歷時資料版本的檢查,後面會單獨提到。
當然大部分情況可能是Key並不相同,確實有碰撞發生,這個時候需要通過entry的next去尋找下一個可能存在的碰撞地址。
如「圖-TryInsert」步驟11中的描述發現這種碰撞後,會直接將entries的資料索引移動到entries[i].next (記錄碰撞次數的collisionCount也會加1)
關於next的值,在資料被插入時會進行賦值entry.next = bucket - 1
前面講過bucket的預設值是0,那next的預設值就是0-1=-1
而當有碰撞的情況下,bucket的值就是直接發生的那一個entry的索引+1,這種情況下next值就正好會是與當前entry發生碰撞的第一個entry的索引,entries也就是靠next維持著碰撞鏈路,一個hashcode可能會碰撞許多次,他們利用next形成一個連結串列,查詢時只需要查詢這個連結串列即可,如果連結串列查到底都沒有發現重複的Key,那這個Key就是一個新的Key,直接插入即可。同時新插入的entry會成為前面碰撞連結串列的頭一個資料。
 然後會再次進入「圖-TryInsert」步驟6,並迴圈6 > 7 > 11 > 6 ,直到entry.next為-1 (或者進入之前看過的步驟8)當碰撞完成後最終會進入插入流程。
 
現在我們回到「圖-TryInsert」的步驟12,在這裡dictionary會檢查entries中是否存在空位(空位是由元素刪除產生的,後面會單獨分析刪除的機制)。前面已經提到過_freeCount,他由Dictionary維護,很容易理解大於0就是有空位,我們先看沒有空位的情況。
「圖-TryInsert」的步驟13,15,18即是沒有空位的路徑,如果沒有空位就會使用新位,那就有陣列長度不足的情況,一旦entries空間被佔滿,那在下一次插入時即會發生擴容
 

 

擴容的邏輯相對簡單,新的大小為GetPrime(2*oldSize),需要注意的是老的entries被複制到新陣列後由於len發生了變化_bucket及entries[i].next都需要重新計算並更新。hashcode用新的size求餘得到bucket(這裡的bucket代表的是buckets陣列的一個索引),並將entries[i].next指向bucket之前指向的資料,再更新bucket的值為當前entry的索引,注意這裡並沒有像TryInsert一樣去碰撞鏈中對比Key的實際值是否相等,因為擴容的資料都是老資料是不可能有重複Key的。

entries複製到新陣列後,看起來next似乎是不用更新的,因為這些元素的碰撞鏈看似不會變。但實際上並非如此,因為陣列的長度變了,那每個元素的hashcode%len的值會發生變化,這個碰撞鏈也會隨之發生變化
dictionary擴容是發生在entries被耗盡的下一次插入時,而HashTable的擴容是發生在count>hashsize*loadFactor時,負載因子預設0.72。因為HashTable沒有單獨的_buckets維護槽位資訊,在元素數量接近hashsize時由插入位已被使用而導致碰撞概率將顯著提高,所以需要提前擴容。
前面也有提到過對於沒有空位的entries來說插入是順序插入的,所以這個時候確認插入新資料的位置index=count,同時_count的值加1。
 
在正式填充插入資料前,我們先看下步驟12的另外一個路徑,entries上有空位的情況 「圖-TryInsert」的步驟14,17
一旦發現空位dictionary會先使用空位,index會被設定為最後一個空位的索引值即_freeList,同時_freeCount--
因為空位很可能不止一個,我們現在把_freeList這個空位使用掉了,那就需要把_freeList指向下一個空位StartOfFreeList - entries[_freeList].next ,空位同樣使用next維持著一個空位連結串列,我們使用完第一個,把下給_freeList就可以(這個空位連結串列是如何運作的我們會在下面的Remove中單獨說明)
這裡還有一個細節,在使用空位時,_count其實並沒有+1,實際上_count只是標記著_entries被使用到的位置,並不是整個dictionary的大小,dictionary的Count屬性是通過_count - _freeCount 計算得出來的。
 現在回到「圖-TryInsert」的最後一步 步驟18,因為已經確認index了,現在只需要把資料填充到_entries[index]裡就可以了
  • entry.hashCode = hashCode;       //更新hashCode
  • entry.next = bucket - 1;              //更新next 如果沒有碰撞初始buctet是0,next就會是-1,否則next會是碰撞鏈的下一個索引
  • entry.key = key;                         //填充key
  • entry.value = value;                    //填充value
  • bucket = index + 1;                    //更新bucket的新值指向當前entry,下次再有hashcode命中這個bucket就會先找到當前entry開始碰撞流程
  • _version++;                               //更新資料版本,遍歷時需要確保版本一致
 
 

3.2:刪除元素(Remove)

「圖-Remove」
上圖為移除元素的簡單流程圖(對應著Remove函式)
在前面的新增中我們已經提到插入資料時如果遇到空位是要先插入空位的,而空位就是在Remove中產生的,同時他還負責維護空位連結串列。
如「圖-Remove」步驟1,2,3 與TryInsert是類似的,都是先獲取到hashcode。不過在步驟4中稍有不同,通過hashcode定位到buckets上的槽位,如果槽位值沒有指向entries裡的任何元素(buckets[bucket] - 1>=0)就直接返回false,反之則繼續 
我們知道bucket預設值是0,一旦發生資料插入他的值會被改為entries的index+1。如果一個hashcode對應的bucket的值是0,那說明這個key不可能曾經被插入過,所以不用搜尋也知道key在dictionary裡肯定不存在,直接返回false就可以了。
現在我們來看步驟6(它與TryInsert的步驟7類似)這個時候已經通過bucket找到了位於_entries上的一個值,但是它很可能只是一次碰撞,所以我們需要直接對比Key確認這個元素是不是我們要找的元素,如果不是反覆進行步驟6 > 12 > 13 > 14 > 4 > 6,直到到步驟4到達碰撞鏈結尾如果還是沒有發現目標Key返回false結束函式,或者找到目標Key,進入刪除流程。
 
現在來到步驟7,這是很關鍵的一步,因為目標Key如果存在碰撞鏈中,那它可能是碰撞鏈中的任意一個元素,如果在中間進行刪除那就還需要修復斷鏈。Remove使用一個last區域性變數來標記上一個碰撞鏈中元素的索引(預設為-1,大於-1表示存在上一個空位,即表示會在鏈中進行刪除),如果last為初始值-1就直接進入刪除流程,如果不是則需要在刪除前先修改碰撞鏈 entries[last].next = entry.next (如果被刪除的Key正好是碰撞的尾部,那next將會是-1,同樣這個尾部的標記會轉給entries[last])。這2個流程在步驟8,步驟9中體現
前文TryInsert中還提到的一個空閒鏈也是在Remove裡建立並維護的,在正式刪除時需要將即將刪除的元素變成空閒鏈的鏈首(後面如果會有插入地址將會優先使用) 
這裡的空閒鏈也是通過next來維護的,我們知道next在entries中已經被用來維持碰撞鏈,不過entries對next的利用十分充分,它將同時用來維護空閒鏈。
next=-1 代表當前元素有值且沒有任何碰撞或位於碰撞鏈尾部
next>=0 代表當前元素有值且處於碰撞鏈中,next的值就是下一個碰撞元素的索引
next=-2 代表當前元素已經被刪除,且當前空位位於空閒鏈尾部(如果只有一個空閒,它同時也是首部)
next<-2 代表當前元素已經被刪除, |next|-3 表示空閒鏈的下個(最後一個next一定是-2)
 

 「圖-刪除元素」

上圖簡單的表述了一次刪除中next在碰撞鏈及空閒鏈中的變化(上圖省略了buckets的變化),上圖entries上方的粉紅虛線為空閒鏈,下方紫色實線為一條碰撞鏈(注意在entries中空閒鏈只會有一條,而碰撞鏈會有很多,上圖只畫了一條)。在「圖-刪除元素」我們稱上面刪除發生前的entries為狀態1,下面刪除後的為狀態2。

現在我們先看狀態1中的entries的空閒鏈,它的_freeList指向了entries[6],entries[6]就是空閒鏈的開始,然後-3-entries[6].next =3 就是空閒鏈的下一個索引為3即entries[3],這個時候我們發現entries[3].next已經是-2了,表示它已經是鏈尾了。
然後我們再來看下碰撞鏈,碰撞鏈從entries[10]開始,entries[10]的next是8,它就會連結到entries[8],以此類推一直找到鏈尾entries[2]
雖然我們現在要刪除的是entries[8],不過我們通過key只能先找到entries[10],這是因為我們在插入entries[10]的時候,利用hashcode檢查到碰撞將會將entries[10]放在碰撞鏈首
當刪除發生時,key的hashcode會先引導我們找到entries[10],dictionary發現entries[10]不是目標元素,根據碰撞鏈找到entries[8],發現匹配成功,開始刪除。這個時候我們可以看「圖-刪除元素」狀態2中元素next值的變化(有變化的值已經用紅色標記了),先看entries[10]它的next由8變成了5,因為entries[8]已經被刪除了,現在這個鏈已經被鏈到了下一個即entries[5]身上。再來看下entries[8],它是被刪除的元素其next由5變成了-9,刪除前它是碰撞鏈的一環,刪除後它就是空閒鏈的鏈首,next值指向空閒鏈的下一個(-3-(-9))即entries[6]。最後我們看下_freeList標記,它的指向也從entries[6]變成了entries[8](後面的插入就會先使用entries[8]的空間)。
 
「圖-刪除元素」中只是展示了元素刪除的一種情況,刪除的其他路徑next的變化大家也可以通過前文中的介紹計算出來。
 
對於buckets陣列來說,「圖-刪除元素」的這個流程下來其實他是不會發生變化的,因為要刪除的元素不是位於碰撞鏈首,假設對於上圖中的刪除流程我們要刪除的是entries[10](他是一條碰撞鏈的首部)那在刪除完成後entries[10]對應的bucket的值就會變成entries[10].next+1。
不難發現對應dictionary來說是通過bucket的值,碰撞鏈來共同確認元素是不是被刪除(存在),而對HashTable來說他主要通過使用其Key的值是不是null來確認(當然HashTable也需要藉助自己的碰撞鏈完成確認)
 
 

3.3:元素的獲取(FindEntry)

其實在我們看過dictionary中元素的新增及元素的刪除後,獲取元素就會相對簡單很多(通過流程圖也可以看出來FindEntry函式的流程相對少了很多)

 「圖-FindEntry」

如上圖是dictionary中通過key查詢元素的關鍵程式碼的流程圖,可以看到上面的流程幾乎都在TryInsert及Remove中出現過,因為在新增及刪除時其實都需要通過Key在dictionary裡查詢一遍有沒有同樣的Key,而查詢邏輯是一致的,這裡就不再重複講述了。
 
 

四:解析一組操作的實際處理過程

4.1:例項介紹

通過上文我們已經知道dictionary是如何通過buckets與entries維護資料的,但無論如何2個陣列之間的關係的確有些繞,要完全搞清楚裡面的邏輯最好還是有實際的資料例項,接下來我們通過一個簡單的例子來看下buckets,entries裡的內容是如何變化的。(例項程式碼程式碼會觸發新增,刪除,擴容,碰撞及對空槽位的重複利用等關鍵步驟)
var dc = new Dictionary<string, string>(1);
dc.Add("1", "11");
dc.Add("2", "22");
dc.Add("3", "33");
dc.Add("4", "44");
dc.Remove("1");
dc.Remove("3");
dc.Add("1", "11");
dc.Add("5", "55");

程式碼如上,我們每執行一行,為buckets,entries裡的資料做一次快照,分析其中的關係。

藉助微軟Soucrce Link,現在您不用自己編譯corefx,就可以直接除錯fx裡基礎庫的原始碼。
下文圖例中buckets,entries結構裡的資料都是真實的資料(記憶體裡就是這些確切的值),不是為了演示而創造的示例資料。

4.2:new Dictionary

當執行new Dictionary<string, string="">(1)時,dc完成初始化,執行Initialize,上文已經提到過這個初始化函式它並不是用1來作為dc的容量,它使用大於1的第一個素數即3,所以執行初始化後dc的size會變成3,bucket與entries裡的資料也都是初始值,int型別全部為0,引用型別為null。同時上圖左下角記錄了部分關鍵變數的值,注意_freeList為-1表示沒有由於刪除導致的空位,其餘的都是預設值0

這裡還有一點需要說明,我們在實際應用中我們一般不會指定Dictionary的大小,預設他會使用0去初始化,在這種情況下初始化時是不會去建立buckets及entries陣列的,dictionary會在真實發生插入時再去建立這2個陣列,建立出來的內容與上圖的一致。
 
 

4.3:Add("1", "11")

現在執行Add("1", "11"),我們計算“1”的hashcode為3109563187,然後我們通過3109563187%3的到索引1,表示他要使用buckets[1]這個槽位,這個槽位開始的值是0,表示之前沒有元素使用過,可以直接插入。檢查_freeList為-1,表示沒有空位,開始按_count進行插入,初始_count為0,所以entries[0]就是要插入資料的位置,把hashcode,key,value插入進去,next取buckets[1]-1 為-1表示沒有碰撞,最後更新buckets[1]的值為buckets[1]的索引+1即為1 ,插入完成後需要將_version++,_count也會+1。 (上圖中➀➁➂表示索引資料跟新的順序,通過這個順序可以看出一個細節buckets[1]  雖然一開始就要用到他,而他的值是最後才確定並寫入的。後面的圖例中將不再標註這個順序,hashCode後的【1】表示hashcode%len的求餘結果
 

4.4:Add("2", "22")

現在插入第2個Key,流程與上一個類似。這一次hashcode%len是0,所以對應的是第一個槽位,插入的位置也是以_count為索引,之前插入完成後_count已經+1,同樣完成插入後_count與_version都遞增1。
其實可以看出來在沒有空位的情況下,插入就是按順序一個接一個存放在entries資料陣列裡的,這與HashTable就有很大區別,HashTable實際只有一個buckets陣列,資料及槽位資訊都是放在一起的,HashTable的索引同時指向他們,所以HashTable資料不可能是以類似順序的形式插入。

 

4.4:Add("3", "33")

現在插入Key "3",這個時候稍微有些變化,因為插入時發生了碰撞,3的hashcode%3的值也是0(剛剛Key“2”也是0),所以我們先找到了buckets[0], buckets[0]-1=1 直接找到entries[1]對應的資料位(注意上圖buckets[0]為3是指插入完成之後的值他會更新為3,之前的值直接檢視該圖上部分快照資料即可),然後用entries[1].key與現在插入的key進行對比(避免插入重複key),然後檢查entries[1].next 發現是-1(表示他後面沒有碰撞鏈)那就直接在後面的空位entries[2]插入新資料就可以了(這裡的“後面的空”是指下一個可用空間,並不是指陣列的下一個),同時現在出現了一條碰撞鏈由entries[2]指向entries[1](右下方紅線所示),不過現在buckets[0]的值需要更新,需要更新為entries[2]的索引+1即3。
 

4.6:Add("4", "44")

這一次的插入同樣會有點特殊,因為我們dictionary開始的容量只有3,現在要插入第4個資料時,我們發現entries已經滿了,在插入前,需要先擴容。
  • 擴容
前面講過擴容是按GetPrime(2*oldSize)進行的。這裡就是取2*3後面的第一個素數,即為7。所以在擴容時bukets及entries的長度都會變成7,需要在意的是,entries的值是直接複製到新陣列後面僅對next做了更新。現在我們來看buckets及元素的next的更新,因為現在dictionary的len已經變了,所以每個元素的hashcode%len的值都會發生變化,槽位資訊及碰撞鏈都會發生改變。之前是0,1這2個槽位被使用,現在變成了1,2。同時可以看到碰撞鏈也發生了變化(擴容的具體步驟前文中已經有提到)
可以看到因為dictionary的插入是儘可能的順序插入的,它可以充分利用陣列的容量,可以在陣列完全滿了之後再進行擴容,而HashTable會按負載因子提前很多進行擴容,並且在擴容時dictionary可以儘可能的使用陣列複製來完成,而HashTable則幾乎是對所有元素重新進行一遍插入。
  • 插入
現在開始插入,插入Key:“4”與插入Key:“3”比較類似,在擴容後的entries中Key:“4”與key:“1”的hashcode%len都是1,插入時他們會發生碰撞,同樣會生成一個新的碰撞鏈entries[3]到entries[1],buckets[1]的值也會更新。
 

4.7:dc.Remove("1")

現在我們看一下刪除邏輯同時跟蹤一下空槽的邏輯,刪除Key:“1”也是一樣先計算hashcode(key)%len結果是3,通過buckets[3]的值3,可以先找到元素entries[2]進行對比(2是通過buckets[3]-1計算得出),對比Key後發現entries[2].key不是要找的值,繼續查詢entries[0](0是通過entries[2].next得到),確認entries[0]為目標元素後直接移除key,value對。 

有個細節我們通過上圖可以看到雖然entries[0]元素是被刪除的資料位,不過它的hashCode確並沒有更新,因為對於刪除後的空位其hashCode不會有任何作用,而我們知道對於值型別的資料entry是直接儲存資料本身的,把這個資料置為0會增加開銷而產生不了任何作用,而對於引用型別的資料則一定要置為null,因為他們是以引用索引的形式存在entry上的,如果不把這個引用指標斷開,這些物件在GC時是將無法被釋放。
然後之前entries[2]到entries[0]的碰撞鏈也隨之斷開。不過現在entries上有空位產生了,dictionary將_freeList的值設定為剛剛被刪除的元素entries[0]的索引0,即表示_freeList對應的元素是最新的空位,注意觀察上面「inner global variable」的值,有些值得注意的點,這個_freeList被設定為0剛剛已經提到過了,_freeCount被設定為1,這個也很好理解,空位的數量是1,不過_version這個版本在刪除發生後居然並沒有+1。
  • _version的作用
先簡單理一下_version,他表示的是當前dictionary的版本,在MoveNext()裡會有類似程式碼
可以看到一旦_version發生變化就會報錯,MoveNext()主要用在遍歷中,最常見的foreach就會用到,所以我們說在遍歷時是不能改變集合內容。在我之前的認知裡,foreach時新增,修改,刪除都是不能進行的,那現在我們看到Remove時_version是沒有去改變了,那就是說可以刪除了,當即在工程裡驗證了一下,的確foreach裡進行Remove存在並沒有報錯。
看來之前的認知要改一下了,至少在dotnet core3.0 及以後的版本Dictionary裡foreach時是可以Remove的。(其他的版本沒有去嘗試,大家有興趣也可以去驗證下)
還是一點我們看到_count的值並沒有因為刪除了一個值而發生變化,其實在dictionary裡_count是不會減少的,_count-_freeCount才是集合裡元素的數量。
 

4.8:dc.Remove("3")

現在我們再刪除一個資料,看看空閒鏈的dictionary裡是如何維護的,與前面一步的刪除類似,我們先計算Key:“3”的hashcod,hashcod%7=3 我們找到buckets[3]的值3,用3-1的到2,那entries[2]就是我們要找的鏈首,直接對比entries[2].key發現就是我們要找的元素,與前面的步驟一樣移除。不過這裡entries[2].next在移除前是-1,代表entries[2]其實是沒與集合裡的其他元素有碰撞的,所以沒有碰撞鏈需要更新。不過這裡還有一點與之前刪除Key:“1”不一樣,刪除Key:“1”時被刪除的值是碰撞鏈裡的元素且不是鏈首,所以其實buckets裡的值是不用更新的,不過現在刪除的資料沒有碰撞鏈(如果是碰撞鏈首也是一樣處理),所以需要將buckets[3]的值更新為entries[2].next+1即為0。

現在我們來看下空閒鏈,同樣_freeList現在變成了2 指向剛剛被刪除的元素,_freeCount也遞增1,不過現在有2個空位了,_freeList指向第一個空位,那第二空位(後面的空位)在什麼地方,如何標記呢,現在entry的next屬性又要發揮作用了,他用StartOfFreeList-next的值來標記空閒鏈的下一個(StartOfFreeList就是-3 我們可以用 |next|-3 這個來方便表示及計算),這個處理方式前文已經介紹過,這裡就不重複描述了。我們直接看到
entries[2].next他現在應該變為StartOfFreeList - _freeList (這個_freeList是之前的_freeList為0)即-3-0=-3,所以我們看到當前的next變成了-3。-3 的指向就是 |-3|-3=0,表示此空位的下一個空位是entries[0]
講到這裡我們可以看出來住dictionary裡的entries陣列中僅通過next屬性就可以完全確認碰撞及空位,整個查詢過程都非常簡單只是簡單的+-操作,而在HashTable中就會相對複雜,由於沒有next來標記鏈HashTable裡會反覆通過(int)(((long)bucketNumber + incr) % (uint)_buckets.Length類似計算尋找下一個元素進行對比。

4.9:Add("1", "11")

現在我們再把key:“1”加回來,看一下dictionary是如何利用空位的,前面hashcode的操作都是一樣的,通過hashcode%7找到buckets[3]他的值是0,表示key:“1”沒在集合裡沒有碰撞,直接插入就可以,之前都是直接插入在entries[_count]下的,不過這次_freeList=2,所以它會先使用空位完成插入,插入完成後將_freeList變為空閒鏈的下一個(-3-(-3)=0)即為0,然後_version跟之前一樣加1,注意這裡的_count是同樣沒有變的,如果用空位_count的值也不會遞增。最後buckets[3]的值也要更新為entries[2]的索引+1。

4.10:Add("5", "55")

 
最後一步插入Key:“5”,本次插入會把空位用完,所以_freeList又會變回-1,其他資料的變化之前都描述過(上圖紅圈標誌的地方)這裡就不重複講了,大家可以根據根據前文中講到的規則自己計算。
 

五:Dictionary與Hashtable執行速度簡單對比

通過以上對Dictionary實際操作,然後又分析了其中每一步其內部主要資料的變化,相信大家會對Dictionary的操作邏輯有個清楚的認識。在上文中會時不時把Dictionary與Hashtable做比較,其實MSDN上已經明確不建議使用HashTable。

 

  • Dictionary當然除了在泛型上的優勢外,由於使用了2個陣列維護資料,資料利用率更高,查詢,插入,刪除都會更快(原因上文其實都有對比提到)。只有在資料量小的時候Hashtable少用一個陣列,每個元素也少一個next的屬性,其記憶體佔用可能會小一點,不過隨著資料儲存的越來越多,這個優勢會被抹平,因為Dictionary資料陣列利用率高。
  • 還有一個區別,預設版本Dictionary不是執行緒安全的,而Hashtable是執行緒安全的,這意味著Hashtable可以在多個執行緒在直接被操作,應用開發者不用考慮安全問題。不過這算不上是Hashtable的一個優點,只是它的一個特點,Hashtable在內部實現更新操作有加鎖,而Dictionary沒有,如果想在多執行緒條件下操作Dictionary需要自己加鎖。
 下面對2者讀寫速度做一個簡單對比測試
 1        string[] dataStrs = new string[1000000];
 2             for (int i = 0; i < 1000000; i++)
 3             {
 4                 dataStrs[i] = i.ToString();
 5             }
 6             var testDc = new Dictionary<string, string>();
 7             //var testDc = new Hashtable();
 8             Stopwatch sw = new Stopwatch();
 9             Console.WriteLine("any key to start");
10             Console.ReadLine();
11             Console.WriteLine("ing......");
12 
13             for (int j = 0; j < 10; j++)
14             {
15                 testDc = new Dictionary<string, string>();
16                 //testDc = new Hashtable();
17                 sw.Restart();
18                 sw.Start();
19                 for (int i = 0; i < 1000000; i++)
20                 {
21                     testDc.Add(dataStrs[i], dataStrs[i]);
22                 }
23                 sw.Stop();
24                 Console.WriteLine($"time {sw.ElapsedMilliseconds}");
25                 Console.ReadLine();
26             }
27             Console.WriteLine("end of test");
28             Console.ReadLine();
使用以上程式碼對Dictionary與Hashtable的插入效能進行簡單對比。
 
如上圖測試結果左邊為Hashtable,右邊為Dictionary,可以看到僅從插入效能上來看Dictionary的確是要大幅優於Hashtable。
如果您對讀取效能進行對比測試也會得到類似結果。
前面已經提過Dictionary藉助將buckets槽位資訊與entries資料陣列分離及對新增next屬性的利用讓Dictionary的碰撞查詢計算量大幅低於Hashtable,同時資料空間的利用率也得到了提高,這也是以上測試結果會有如此大差距的主要原因。
 

六:後記

篇幅比較長,但是一直都是圍繞同一個內容進行講述。筆者會盡力讓描述前後有聯絡,並避免過多介紹孤立的無關資訊。事實上文章的草稿(或者叫個人筆記)一年前就完成了(所以大家也看到除錯時其實沒有使用最新的.Net6)。而即便是自己寫的內容時隔一年再來回看,也能發現很多細節也並不是讀一遍自己就能秒懂的。如果之前沒有關注過這些看完會花點時間,但我相信是會有價值的。

本文大多數圖表,示例及描述其實也是經過反覆修改,並對照實現程式碼核對或逐行除錯而得出來的。但即便如此限於自身水平或認知上的限制也難免會有錯誤或不全面的表述,大家在閱讀過程中如果發現有紕漏的地方,可以以任何方式提出( mycllq@hotmail.com 因為部落格園似乎不允許訪客留言,這裡也留個郵箱方便未註冊使用者),我會在確認後第一時間更正。

相關文章