文章參考:Go語言設計與實現3.3 哈希表
哈希表的意義不言而喻,它能提供 O(1) 復雜度的讀寫性能,所以主流編程語言中都內置有哈希表。
哈希表的關鍵在於哈希函數, 好的哈希函數能減少哈希碰撞,提供最優秀的讀寫性能。
哈希碰撞
因為沒有完美的哈希函數, 所以哈希碰撞不可避免,一般有開放尋址法和拉鏈法,其中拉鏈法是主流
-
開放尋址法:當向哈希表寫入新的數據時,如果發生了沖突,就會將鍵值對寫入到下一個索引不為空的位置
-
拉鏈法:拉鏈法一般使用數組和鏈表組成,數據經過哈希函數得到一個桶時,先遍歷桶中的鏈表,存在相同的鍵值對,則更新,不存在則在鏈表末尾追加新鍵值對
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
}
哈希表 hmap 的桶是 bmap,每個 bmap 都能存儲 8 個鍵值對,單個桶裝滿時會使用 nextOverflow 桶存儲溢出的數據
type bmap struct {
// 存儲了鍵的哈希的高 8 位
// 通過比較不同鍵的哈希的高 8 位可以減少訪問鍵值對次數以提高性能
tophash [bucketCnt]uint8
}
訪問 map 中的數據
如上圖所示,每一個桶都是一整片的內存空間,當發現桶中的 tophash
與傳入鍵的 tophash
匹配之后,我們會通過指針和偏移量獲取哈希中存儲的鍵 keys[0]
並與 key
比較,如果兩者相同就會獲取目標值的指針 values[0]
並返回
向 map 寫入數據
函數會根據傳入的鍵拿到對應的哈希和桶,通過遍歷比較桶中存儲的 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 上,新計算舊桶內元素的哈希到新桶上,
在擴容期間訪問哈希表時會使用舊桶,整個期間元素再分配的過程也是在調用寫操作時增量進行的,不會造成性能的瞬時巨大抖動