.NET中Dictionary<TKey, TValue>淺析

來源:互聯網
上載者:User

標籤:style   blog   http   color   使用   資料   

.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中重要的變數:

  1. int[] bucketsEntry[] entries
  2. IEqualityComparer<TKey> comparer

.

  1. 這兩個就是在上面提到的兩個數組,所謂的 Separate chaining with linked lists。
  2. 在往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不再映射到同一個值上了,也就不需要共用一個值啦。我覺得這裡有兩點值得一提。

  1. 擴容的下一個size是如何得到的,在上面的例子中為什麼是7?
  2. 擴容是兩個數組的變化。

對於第一個問題,因為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 8IL_0000: ldarg.1IL_0001: box !TIL_0006: brfalse.s IL_0026IL_0008: ldarg.2IL_0009: box !TIL_000e: brfalse.s IL_0024IL_0010: ldarga.s xIL_0012: ldarg.2IL_0013: box !TIL_0018: constrained. !TIL_001e: callvirt instance bool System.Object::Equals(object)IL_0023: retIL_0024: ldc.i4.0IL_0025: retIL_0026: ldarg.2IL_0027: box !TIL_002c: brfalse.s IL_0030IL_002e: ldc.i4.0IL_002f: retIL_0030: ldc.i4.1IL_0031: ret} // end of method ObjectEqualityComparer`1::EqualsObjectEqualityComparer.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 6372 69 74 69 63 61 6c 20 74 6f 20 69 6e 6c 69 6e65 20 61 63 72 6f 73 73 20 4e 47 65 6e 20 69 6d61 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 8IL_0000: ldarg.1IL_0001: box !TIL_0006: brtrue.s IL_000aIL_0008: ldc.i4.0IL_0009: retIL_000a: ldarga.s objIL_000c: constrained. !TIL_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>

先到這裡吧

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.