本文學習 Golang 的 Map 數據結構,以及map buckets 的數據組織結構。
hash 表是什么
從大學的課本里面,我們學到:hash 表其實就是將key 通過hash算法映射到數組的某個位置,然后把對應的val存放起來。
如果出現了hash沖突(也就是說,不同的key被映射到了相同的位置上時),就需要解決hash沖突。解決hash沖突的方法還是比較多的,比如說開放定址法,再哈希法,鏈地址法,公共溢出區等(復習下大學的基本知識)。
其中鏈地址法比較常見,下面是一個鏈地址法的常見模式:
Position 指通過Key 計算出的數組偏移量。例如當 Position = 6 的位置已經填滿KV后,再次插入一條相同Position的數據將通過鏈表的方式插入到該條位置之后。
在php的Array 中是這么實現的,golang中也基本是這么實現。下面我們學習下Golang中map的實現。
Golang Map 實現的數據結構
Golang的map中,首先把kv 分在了N個桶中,每個桶中的數據有8條(bucketCnt)。如果一個桶滿了(overflow),也會采用鏈地址法解決hash 的沖突。
下面是定義一個hashmap的結構體:
type hmap struct {
// 長度
count int
// map 的標識, 下方做了定義
flags uint8
// 實際buckets 的長度為 2 ^ B
B uint8
// 從bucket中溢出的數量,(存在extra 里面)
noverflow uint16
// hash 種子,做key 哈希的時候會用到
hash0 uint32
// 存儲 buckets 的地方
buckets unsafe.Pointer
// 遷移時oldbuckets中存放部分buckets 的數據
oldbuckets unsafe.Pointer
// 遷移的數量
nevacuate uintptr
// 一些額外的字段,在做溢出處理以及數據增長的時候會用到
extra *mapextra
}
const (
// 有一個迭代器在使用buckets
iterator = 1
// 有一個迭代器在使用oldbuckets
oldIterator = 2
// 並發寫,通過這個標識報panic
hashWriting = 4
sameSizeGrow = 8
)
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}
type bmap struct {
tophash [bucketCnt]uint8
}
表中除了對基本的hash數據結構做了定義外,還對數據遷移、擴容等操作做了定義,這里我們可以忽略,等學習到時我們再深入了解。
深入 桶列表 (buckets)
buckets 字段中是存儲桶數據的地方。正常會一次申請至少2^N長度的數組,數組中每個元素就是一個桶。N 就是結構體中的B。這里面要注意以下幾點:
- 為啥是2的冪次方 為了做完hash后,通過掩碼的方式取到數組的偏移量, 省掉了不必要的計算。
- B 這個數是怎么確定的 這個和我們map中要存放的數據量是有很大關系的。我們在創建map的時候來詳述。
- bucket 的偏移是怎么計算的 hash 方法有多個,在 runtime/alg.go 里面定義了。不同的類型用不同的hash算法。算出來是一個uint32的一個hash 碼,通過和B取掩碼,就找到了bucket的偏移了。下面是取對應bucket的例子:
// 根據key的類型取相應的hash算法
alg := t.key.alg
hash := alg.hash(key, uintptr(h.hash0))
// 根據B拿到一個掩碼
m := bucketMask(h.B)
// 通過掩碼以及hash指,計算偏移得到一個bucket
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
深入 桶 (bucket)
一個桶的示意圖如下:
每個桶里面,可以放8個k,8個v,還有一個overflow指針(就是上面的next),用來指向下一個bucket 的地址。在每個bucket的頭部,還會放置一個tophash,也就是bmap 結構體。這個數組里面存放的是key的hash值,用來對比我們key生成的hash和存出的hash是否一致(當然除了這個還有其他的用途,后面講數據訪問的時候會講到)。 tophash中的數據,是從計算的hash值里面截取的。獲取bucket 是用的低bit位的hash,tophash 使用的是高bit位的hash值(8位)
- 為啥bucket 一次要存8個kv,而不是一個kv放一個bucket,然后鏈地址法做處理就OK了 據我分析,有幾點原因: a, 一次分配8個kv的空間,可以減少內存的分配頻次; b,減少了overflow指針的內存占用,比如說8個kv,采用一個一個存儲的話,需要8 * 8B (64位機) = 64B的數據存下一個的地址,而采用go實現的這種方式,只需要 8B + 8B (bmap的大小) = 16B 的數據就可以了。
- 為啥需要用tophash 一般的hash 實現邏輯是直接和key比較,如果比較成功,這找到相應key的數據。但是這里用到了tophash,好處是可以減少key的比較成本(畢竟key 不一定都是整數形式存在的)
- 為啥是8個 8 * 8B = 64B 整好是64位機的一個最小尋址空間,不過可以通過修改源碼自定義吧。
- 為什么key 和val 要分開放 這個也比較好理解,key 和val 都是用戶可以自定義的。如果key是定長的(比如是數字,或者 指針之類的,大概率是這樣。)內存是比較整齊的,利於尋址吧。
技術總結
golang 實現的map比朴素的hashmap 在很多方面都有優化。
- 使用掩碼方式獲取偏移,減少判斷。
- bucket 存儲方式的優化。
- 通過tophash 先進行一次比較,減少key 比較的成本。
- 當然,有一點是不太明白的,為啥 overflow 指針要放在 kv 后面? 放在tophash 之后的位置豈不是更完美?
今天的作業就交完了。下一篇將學習golang map的數據初始化實現。