Go map實現原理

轉自:https://my.oschina.net/renhc/blog/2208417?nocache=1539143037904

1. map數據結構

Golang的map使用哈希表作爲底層實現,一個哈希表裏可以有多個哈希表節點,也即bucket,而每個bucket就保存了map中的一個或一組鍵值對。

map數據結構由runtime/map.go/hmap定義:

type hmap struct {
	count     int // 當前保存的元素個數
	...
	B         uint8  // 指示bucket數組的大小
	...
	buckets    unsafe.Pointer // bucket數組指針,數組的大小爲2^B
	...
}

下圖展示一個擁有4個bucket的map:

本例中, hmap.B=2, 而hmap.buckets長度是2^B爲4. 元素經過哈希運算後會落到某個bucket中進行存儲。查找過程類似。

bucket很多時候被翻譯爲桶,所謂的哈希桶實際上就是bucket。

2. bucket數據結構

bucket數據結構由runtime/map.go/bmap定義:

type bmap struct {
	tophash [8]uint8 //存儲哈希值的高8位
	data    byte[1]  //key value數據:key/key/key/.../value/value/value...
	overflow *bmap   //溢出bucket的地址
}

每個bucket可以存儲8個鍵值對。

  • tophash是個長度爲8的數組,哈希值相同的鍵(準確的說是哈希值低位相同的鍵)存入當前bucket時會將哈希值的高位存儲在該數組中,以方便後續匹配。
  • data區存放的是key-value數據,存放順序是key/key/key/...value/value/value,如此存放是爲了節省字節對齊帶來的空間浪費。
  • overflow 指針指向的是下一個bucket,據此將所有衝突的鍵連接起來。

注意:上述中data和overflow並不是在結構體中顯示定義的,而是直接通過指針運算進行訪問的。

下圖展示bucket存放8個key-value對:

3. 哈希衝突

當有兩個或以上數量的鍵被哈希到了同一個bucket時,我們稱這些鍵發生了衝突。Go使用鏈地址法來解決鍵衝突。
由於每個bucket可以存放8個鍵值對,所以同一個bucket存放超過8個鍵值對時就會再創建一個鍵值對,用類似鏈表的方式將bucket連接起來。

下圖展示產生衝突後的map: 

bucket數據結構指示下一個bucket的指針稱爲overflow bucket,意爲當前bucket盛不下而溢出的部分。事實上哈希衝突並不是好事情,它降低了存取效率,好的哈希算法可以保證哈希值的隨機性,但衝突過多也是要控制的,後面會再詳細介紹。

4. 負載因子

負載因子用於衡量一個哈希表衝突情況,公式爲:

負載因子 = 鍵數量/bucket數量

例如,對於一個bucket數量爲4,包含4個鍵值對的哈希表來說,這個哈希表的負載因子爲1.

哈希表需要將負載因子控制在合適的大小,超過其閥值需要進行rehash,也即鍵值對重新組織:

  • 哈希因子過小,說明空間利用率低
  • 哈希因子過大,說明衝突嚴重,存取效率低

每個哈希表的實現對負載因子容忍程度不同,比如Redis實現中負載因子大於1時就會觸發rehash,而Go則在在負載因子達到6.5時纔會觸發rehash,因爲Redis的每個bucket只能存1個鍵值對,而Go的bucket可能存8個鍵值對,所以Go可以容忍更高的負載因子。

5. 漸進式擴容

5.1 擴容的前提條件

爲了保證訪問效率,當新元素將要添加進map時,都會檢查是否需要擴容,擴容實際上是以空間換時間的手段。
觸發擴容的條件有二個:

  1. 負載因子 > 6.5時,也即平均每個bucket存儲的鍵值對達到6.5個。
  2. overflow數量 > 2^15時,也即overflow數量超過32768時。

5.2 增量擴容

當負載因子過大時,就新建一個bucket,新的bucket長度是原來的2倍,然後舊bucket數據搬遷到新的bucket。
考慮到如果map存儲了數以億計的key-value,一次性搬遷將會造成比較大的延時,Go採用逐步搬遷策略,即每次訪問map時都會觸發一次搬遷,每次搬遷2個鍵值對。

下圖展示了包含一個bucket滿載的map(爲了描述方便,圖中bucket省略了value區域):
 當前map存儲了7個鍵值對,只有1個bucket。此地負載因子爲7。再次插入數據時將會觸發擴容操作,擴容之後再將新插入鍵寫入新的bucket。

當第8個鍵值對插入時,將會觸發擴容,擴容後示意圖如下: 
hmap數據結構中oldbuckets成員指身原bucket,而buckets指向了新申請的bucket。新的鍵值對被插入新的bucket中。 後續對map的訪問操作會觸發遷移,將oldbuckets中的鍵值對逐步的搬遷過來。當oldbuckets中的鍵值對全部搬遷完畢後,刪除oldbuckets。

搬遷完成後的示意圖如下:

數據搬遷過程中原bucket中的鍵值對將存在於新bucket的前面,新插入的鍵值對將存在於新bucket的後面。 實際搬遷過程中比較複雜,將在後續源碼分析中詳細介紹。

5.3 等量擴容

所謂等量擴容,實際上並不是擴大容量,buckets數量不變,重新做一遍類似增量擴容的搬遷動作,把鬆散的鍵值對重新排列一次,以使bucket的使用率更高,進而保證更快的存取。
在極端場景下,比如不斷的增刪,而鍵值對正好集中在一小部分的bucket,這樣會造成overflow的bucket數量增多,但負載因子又不高,從而無法執行增量搬遷的情況,如下圖所示:

上圖可見,overflow的buckt中大部分是空的,訪問效率會很差。此時進行一次等量擴容,即buckets數量不變,經過重新組織後overflow的bucket數量會減少,即節省了空間又會提高訪問效率。

6. 查找過程

查找過程如下:

  1. 跟據key值算出哈希值
  2. 取哈希值低位與hmpa.B取模確定bucket位置
  3. 取哈希值高位在tophash數組中查詢
  4. 如果tophash[i]中存儲值也哈希值相等,則去找到該bucket中的key值進行比較
  5. 當前bucket沒有找到,則繼續從下個overflow的bucket中查找。
  6. 如果當前處於搬遷過程,則優先從oldbuckets查找

注:如果查找不到,也不會返回空值,而是返回相應類型的0值。

7. 插入過程

新員素插入過程如下:

  1. 跟據key值算出哈希值
  2. 取哈希值低位與hmap.B取模確定bucket位置
  3. 查找該key是否已經存在,如果存在則直接更新值
  4. 如果沒找到將key,將key插入
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章