.NET中Dictionary<TKey, Tvalue>是非常常用的key-value的數據結構,也就是其實就是傳說中的哈希表。.NET中還有一個叫做Hashtable的類型,兩個類型都是哈希表。這兩個類型都可以實現鍵值對存儲的功能,區別就是一個是泛型一個不是並且內部實現有一些不同。今天就研究一下.NET中的Dictionary<TKey, TValue>以及一些相關問題。
guid:33b4b911-2068-4513-9d98-31b2dab4f70c
文中如有錯誤,望指出。
什么是哈希表
Wikipedia中對於Hash table的定義是這樣的:
In computing, a hash table (also hash map) is a data structure used to implement an associative array, a structure that can map keys to values.
它是一個通過關鍵字直接訪問內存存儲位置的數據結構,這是一個所有數據結構教科書里都會有的一個數據結構,這里不做太多的研究。但是有幾個概念還是要提一下,因為對於我們理解Dictionary的內部實現很大作用。
更多哈希表的內容:Wikipedia,Hashtable 博客
碰撞(Collision)及處理
由於我們在數據結構中采用的哈希算法不是一個完美的哈希算法,同時我們會限制我們用來存儲的內存空間。所以發生碰撞是不可能避免的,所以很處理如何碰撞就是設計哈希表的時候需要考慮的一個很重要的因素。
處理碰撞有很多方法,例如開放尋址法(Open Adressing),分離鏈接法(Separate chaining)。Dictionary中采用的是一個叫做Separate chaining with linked lists的方法。
通過下面這個Wikipedia上的圖就可以很清楚的認識到這是一個什么樣子的方法。
利用兩個數組,buckets數組只保存一個地址,這個地址指向的是entries數組中的一個實例(entry)。當哈希值沖突的時候則需要往當前指向的那個實例的鏈表的末端添加一個新實例即可。
裝填因子(Load Factor)
裝填因子的存在是因為在開放定址方法中,當數組中的內容越來越多的時候則沖突的概率就會越來越大,而在開放定址方法中沖突的解決方案是采用探測法,而這種沖突會帶來性能的極大損失。wikipedia中的這張圖比較了分散鏈接和線性探測法在不同裝填因子的情況下CPU緩存不命中的對應關系。
在Dictionary采用的這種方法里,裝填因子並不是一個重要的因素不會對性能有太大影響,所以Dictionary默認使用了1並認為沒有必要提供任何接口去設置這個值。
Dictionary內部如何實現
先來介紹幾個在Dictionary中重要的變量:
int[] buckets
和Entry[] entries
IEqualityComparer<TKey> comparer
.
- 這兩個就是在上面提到的兩個數組,所謂的 Separate chaining with linked lists。
- 在往Dictionary中添加一對新值的時候需要計算key的Hashcode,沖突的時候也需要判斷兩個value是不是相等。這個comparer就是來做這個事情的,那么這里為什么不直接調用key重載的GetHashCode和Equal方法呢?這個會在下文中講到。
插入
通過一個例子來說明Dictionary在插入的時候做了什么。
Dictionary<int, string=""> dict = new Dictionary<int, string="">(); dict.Add(0, "zero"); dict.Add(12, "twelve"); dict.Add(15, "fiften"); dict.Add(4, "four");
下面這張“圖”能夠看出兩個數組在插入操作中的變化,結合源代碼細細品味就能知道發生了什么。
--------- --------- |buckets| |entries| |-------| |-------| | 0 | | -->| hashcode=0,key=0,next=-1,value="zero" |-------| |-------| | -1 | | empty | |-------| |-------| | -1 | | empty | |-------| |-------| --------- --------- |buckets| |entries| |-------| |-------| | 1 | | -->| hashcode=0,key=0,next=-1,value="zero" |-------| |-------| | -1 | | -->| hashcode=12,key=12,next=0,value="twelve" |-------| |-------| | -1 | | empty | |-------| |-------| --------- --------- |buckets| |entries| |-------| |-------| | 2 | | -->| hashcode=0,key=0,next=-1,value="zero" |-------| |-------| | -1 | | -->| hashcode=12,key=12,next=0,value="twelve" |-------| |-------| | -1 | | -->| hashcode=15,key=15,next=1,value="fiften" |-------| |-------| --------- --------- |buckets| |entries| |-------| |-------| | 0 | | -->| hashcode=0,key=0,next=-1,value="zero" |-------| |-------| | 2 | | -->| hashcode=12,key=12,next=-1,value="twelve" |-------| |-------| | -1 | | -->| hashcode=15,key=15,next=-1,value="fiften" |-------| |-------| | -1 | | -->| hashcode=4,key=4,next=-1,value="four" |-------| |-------| | 3 | | empty | |-------| |-------| | 1 | | empty | |-------| |-------| | -1 | | empty | |-------| |-------|
擴容
上面例子中最后一個插入時,Dictionary就做了一次擴容。它將Dictionary的size從原有的3擴展到了7。可以看到entries中的元素在擴容的時候變化不大,只是next有了些變化,這是因為擴容以后他們的哈希值%length不再映射到同一個值上了,也就不需要共享一個值啦。我覺得這里有兩點值得一提。
- 擴容的下一個size是如何得到的,在上面的例子中為什么是7?
- 擴容是兩個數組的變化。
對於第一個問題,因為Dictionary中數組長度有限,所以是通過key.GetHashCode() % length來獲得一個bucket數組中的位置然后更改entry的next。那么我們就要保證盡可能的減少取模帶來的沖突次數,那么素數就能夠很好的保證取模以后能夠盡可能的分散在數組的各處。
Dictionary擴容的時候會先把當前的容量*2,然后再在一個素數表中找到比這個值大的最近的一個素數。這個素數表是長這樣子的:
public static readonly int[] 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};
至於第二個問題,我們在上文中也提到過了,這個方法在擴容的時候是不需要對哈希表中存儲的內容進行重新哈希。我們只需要將bucket中的分布的元素所對應的哈希值重新進行取模運算,然后放到新的位置上即可,這個操作是極快的。
查找
key.GetHashCode() % length --> 遍歷鏈表找到equal的key
刪除
同查找。
幾個注意事項
性能問題
我們使用Dictionary的時候一般的習慣應該就跟代碼1中那樣,這種使用方法在我們使用內置的類型當key的時候沒有問題,但是如果我們需要將一個自定義的值類型(struct)當作key的時候就需要注意了。這里有一個很容易忽略的問題,會導致使用Dictionary的時候帶來大量不必要的性能開銷。
當我們需要定義一些自定義結構並且要把這些實例放在集合中的時候我們往往會采用值類型而不會定義成一個類,如果這些類型只存在數據的話值類型在性能上比類要好很多。(Choosing Between Class and Struct)
我們先做一個實驗來比較一下值類型和類作為key的性能有多大的差距。實驗代碼如下,這段代碼中我插入1000個到10000個數據來得到所需要的時間。
public class/struct CustomKey { public int Field1; public int Field2; public override int GetHashCode() { return Field1.GetHashCode() ^ Field2.GetHashCode(); } public override bool Equals(object obj) { CustomKey key = (CustomKey)obj; return this.Field1 == key.Field1 && this.Field2 == key.Field2; } } Dictionary<CustomKey, int> dict = new Dictionary<CustomKey, int>(); int tryCount = 50; double totalTime = 0.0; for (int count = 1000; count < 10000; count += 1000) { for (int j = 0; j < tryCount; j++) { Stopwatch watcher = Stopwatch.StartNew(); for (int i = 0; i < count; i++) { CustomKey key = new CustomKey() { Field1 = i * 2, Field2 = i * 2 + 1 }; dict.Add(key, i); } watcher.Stop(); dict.Clear(); totalTime += watcher.ElapsedMilliseconds; } Console.WriteLine("{0},{1}", count, totalTime / tryCount); }
結果是這樣子的:
WTF?為什么和我的預期不一樣,不是應該值類型要快才對不是么?orz....
這里就要提到剛剛在上文中提到的那個IEqualityComparer<TKey> comparer
,Dictioanry內部的比較都是通過這個實例來進行的。但是我們沒有指定它,那么它使用的就是EqualityComparer<TKey>.Default
。讓我們看一下源代碼來了解一下這個Default到底是怎么來的,在CreateComparer
我們可以看到如果我們的類型不是byte
、沒實現IEquatable<T>
接口、不是Nullable<T>
、不是enum
的話,會默認給我們創建一個ObjectEqualityComparer<T>()
。
而ObjectEqualityComparer<T>()
中的Equal和GetHashCode方法看上去也沒啥問題,那到底問題出在哪里呢?
跟值類型有關的性能問題,馬上能夠想到的就是裝箱和拆箱所帶來的性能損耗。這里那里存在這種操作呢?我們來看一下下面的兩段代碼就明白了。
ObjectEqualityComparer.Equals(T x, T y)的IL代碼 // Methods .method public hidebysig virtual instance bool Equals ( !T x, !T y ) cil managed { // Method begins at RVA 0x62a39 // Code size 50 (0x32) .maxstack 8 IL_0000: ldarg.1 IL_0001: box !T IL_0006: brfalse.s IL_0026 IL_0008: ldarg.2 IL_0009: box !T IL_000e: brfalse.s IL_0024 IL_0010: ldarga.s x IL_0012: ldarg.2 IL_0013: box !T IL_0018: constrained. !T IL_001e: callvirt instance bool System.Object::Equals(object) IL_0023: ret IL_0024: ldc.i4.0 IL_0025: ret IL_0026: ldarg.2 IL_0027: box !T IL_002c: brfalse.s IL_0030 IL_002e: ldc.i4.0 IL_002f: ret IL_0030: ldc.i4.1 IL_0031: ret } // end of method ObjectEqualityComparer`1::Equals ObjectEqualityComparer.Equals(T x, T y)的IL代碼 .method public hidebysig virtual instance int32 GetHashCode ( !T obj ) cil managed { .custom instance void System.Runtime.TargetedPatchingOptOutAttribute::.ctor(string) = ( 01 00 3b 50 65 72 66 6f 72 6d 61 6e 63 65 20 63 72 69 74 69 63 61 6c 20 74 6f 20 69 6e 6c 69 6e 65 20 61 63 72 6f 73 73 20 4e 47 65 6e 20 69 6d 61 67 65 20 62 6f 75 6e 64 61 72 69 65 73 00 00 ) // Method begins at RVA 0x62a6c // Code size 24 (0x18) .maxstack 8 IL_0000: ldarg.1 IL_0001: box !T IL_0006: brtrue.s IL_000a IL_0008: ldc.i4.0 IL_0009: ret IL_000a: ldarga.s obj IL_000c: constrained. !T IL_0012: callvirt instance int32 System.Object::GetHashCode() IL_0017: ret } // end of method ObjectEqualityComparer`1::GetHashCode
從上面兩段代碼中可以看到在ObjectEqualityComparer的默認實現中會存在着很多的box(見高亮行)操作,它是用來將值類型裝箱成引用類型的。這個操作是很耗時的,因為它需要創建一個object並將值類型中的值拷貝到新創建的對象中。(CustomKey.Equal方法中也有一個unbox操作)。
怎么破?
我覺得只要避免裝箱不就行了,那我們自己創建一個Comparer。
public class MykeyComparer : IEqualityComparer { #region IEqualityComparer Members public bool Equals(CustomKey x, CustomKey y) { return x.Field1 == y.Field1 && x.Field2 == y.Field2; } public int GetHashCode(CustomKey obj) { return obj.Field1.GetHashCode() ^ obj.Field2.GetHashCode(); } #endregion }
那我們將實驗代碼稍作修改(Dictionary<CustomKey, int> dict = new Dictionary<CustomKey, int>(new MykeyComparer());
)在測試一把。這次的結果顯示性能提高了很多。
線程安全
這貨不是線程安全的,需要多線程操作要么自己維護同步要么使用線程安全的Dictionary-->ConcurrentDictionary<TKey, TValue>
先到這里吧