深入理解 Go Map

邱佳飛發表於2021-07-12

文章參考:Go語言設計與實現3.3 雜湊表

雜湊表的意義不言而喻,它能提供 O(1) 複雜度的讀寫效能,所以主流程式語言中都內建有雜湊表。

雜湊表的關鍵在於雜湊函式, 好的雜湊函式能減少雜湊碰撞,提供最優秀的讀寫效能。

雜湊碰撞

因為沒有完美的雜湊函式, 所以雜湊碰撞不可避免,一般有開放定址法和拉鍊法,其中拉鍊法是主流

  • 開放定址法:當向雜湊表寫入新的資料時,如果發生了衝突,就會將鍵值對寫入到下一個索引不為空的位置

    image-20210706223505867

  • 拉鍊法:拉鍊法一般使用陣列和連結串列組成,資料經過雜湊函式得到一個桶時,先遍歷桶中的連結串列,存在相同的鍵值對,則更新,不存在則在連結串列末尾追加新鍵值對

    image-20210706223857753

Go 表示雜湊表的資料結構

type hmap struct {
  // 表示雜湊表中元素的數量
	count     int
  flags     uint8
  
  // 表示雜湊表中桶的數量, len(buckets) = 2^B
	B         uint8
  noverflow uint16
  
  // hash函式的種子
  hash0     uint32

	buckets    unsafe.Pointer
  
  // 用於在擴容時儲存之前 buckets
  // 因為每次擴容都是2的倍數,所以 bucket = 2oldbuckets
	oldbuckets unsafe.Pointer
	nevacuate  uintptr

	extra *mapextra
}

type mapextra struct {
	overflow    *[]*bmap
	oldoverflow *[]*bmap
	nextOverflow *bmap
}

image-20210706224402496

雜湊表 hmap 的桶是 bmap,每個 bmap 都能儲存 8 個鍵值對,單個桶裝滿時會使用 nextOverflow 桶儲存溢位的資料

type bmap struct {
	// 儲存了鍵的雜湊的高 8 位
  // 通過比較不同鍵的雜湊的高 8 位可以減少訪問鍵值對次數以提高效能
  tophash [bucketCnt]uint8
}

訪問 map 中的資料

image-20210706225253558

如上圖所示,每一個桶都是一整片的記憶體空間,當發現桶中的 tophash 與傳入鍵的 tophash 匹配之後,我們會通過指標和偏移量獲取雜湊中儲存的鍵 keys[0] 並與 key 比較,如果兩者相同就會獲取目標值的指標 values[0] 並返回

向 map 寫入資料

image-20210706225431579

函式會根據傳入的鍵拿到對應的雜湊和桶,通過遍歷比較桶中儲存的 tophash 和鍵的雜湊,如果找到了相同結果就會返回目標位置的地址,獲得目標地址之後會通過算術計算定址獲得鍵值對 k 和 val, 如果當前鍵值對在雜湊中不存在,雜湊會為新鍵值對規劃儲存的記憶體地址,這期間只會返回記憶體地址,真正的賦值操作是在編譯期間插入的。

00018 (+5) CALL runtime.mapassign_fast64(SB)
00020 (5) MOVQ 24(SP), DI               ;; DI = &value
00026 (5) LEAQ go.string."88"(SB), AX   ;; AX = &"88"
00027 (5) MOVQ AX, (DI)                 ;; *DI = AX

我們通過 LEAQ 指令將字串的地址儲存到暫存器 AX 中,MOVQ 指令將字串 "88" 儲存到了目標地址上完成了這次雜湊的寫入

擴容

隨著雜湊表中元素的逐漸增加,雜湊表的效能會逐漸惡化,當裝載因子 > 6.5 時, 或者 雜湊表建立了太多的溢位桶, 會觸發擴容

裝載因子 = 元素數量 / 桶數量

雜湊表在擴容的過程中會建立一組新桶和溢位桶,隨後將原油的桶陣列設定到 oldbuckets 上,將新桶設定到 buckets 上,新計算舊桶內元素的雜湊到新桶上,

在擴容期間訪問雜湊表時會使用舊桶,整個期間元素再分配的過程也是在呼叫寫操作時增量進行的,不會造成效能的瞬時巨大抖動

相關文章