Golang Map實現(一)
阿新 • • 發佈:2020-04-26
本文學習 Golang 的 Map 資料結構,以及map buckets 的資料組織結構。
# hash 表是什麼
從大學的課本里面,我們學到:hash 表其實就是將key 通過hash演算法對映到陣列的某個位置,然後把對應的val存放起來。
如果出現了hash衝突(也就是說,不同的key被對映到了相同的位置上時),就需要解決hash衝突。解決hash衝突的方法還是比較多的,比如說開放定址法,再雜湊法,鏈地址法,公共溢位區等(複習下大學的基本知識)。
其中鏈地址法比較常見,下面是一個鏈地址法的常見模式:
![](https://img2020.cnblogs.com/blog/527714/202004/527714-20200426093755804-765999692.jpg)
Position 指通過Key 計算出的陣列偏移量。例如當 Position = 6 的位置已經填滿KV後,再次插入一條相同Position的資料將通過連結串列的方式插入到該條位置之後。
在php的Array 中是這麼實現的,golang中也基本是這麼實現。下面我們學習下Golang中map的實現。
## Golang Map 實現的資料結構
Golang的map中,首先把kv 分在了N個桶中,每個桶中的資料有8條(bucketCnt)。如果一個桶滿了(overflow),也會採用鏈地址法解決hash 的衝突。
下面是定義一個hashmap的結構體:
```golang
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。這裡面要注意以下幾點:
1. **為啥是2的冪次方** 為了做完hash後,通過掩碼的方式取到陣列的偏移量, 省掉了不必要的計算。
2. **B 這個數是怎麼確定的** 這個和我們map中要存放的資料量是有很大關係的。我們在建立map的時候來詳述。
3. **bucket 的偏移是怎麼計算的** hash 方法有多個,在 runtime/alg.go 裡面定義了。不同的型別用不同的hash演算法。算出來是一個uint32的一個hash 碼,通過和B取掩碼,就找到了bucket的偏移了。下面是取對應bucket的例子:
```golang
// 根據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)
一個桶的示意圖如下:
![](https://img2020.cnblogs.com/blog/527714/202004/527714-20200426093845638-584258581.jpg)
每個桶裡面,可以放8個k,8個v,還有一個overflow指標(就是上面的next),用來指向下一個bucket 的地址。在每個bucket的頭部,還會放置一個tophash,也就是bmap 結構體。這個數組裡面存放的是key的hash值,用來對比我們key生成的hash和存出的hash是否一致(當然除了這個還有其他的用途,後面講資料訪問的時候會講到)。 tophash中的資料,是從計算的hash值裡面擷取的。獲取bucket 是用的低bit位的hash,tophash 使用的是高bit位的hash值(8位)
1. **為啥bucket 一次要存8個kv,而不是一個kv放一個bucket,然後鏈地址法做處理就OK了** 據我分析,有幾點原因: a, 一次分配8個kv的空間,可以減少記憶體的分配頻次; b,減少了overflow指標的記憶體佔用,比如說8個kv,採用一個一個儲存的話,需要8 * 8B (64位機) = 64B的資料存下一個的地址,而採用go實現的這種方式,只需要 8B + 8B (bmap的大小) = 16B 的資料就可以了。
2. **為啥需要用tophash** 一般的hash 實現邏輯是直接和key比較,如果比較成功,這找到相應key的資料。但是這裡用到了tophash,好處是可以減少key的比較成本(畢竟key 不一定都是整數形式存在的)
3. **為啥是8個** 8 * 8B = 64B 整好是64位機的一個最小定址空間,不過可以通過修改原始碼自定義吧。
4. **為什麼key 和val 要分開放** 這個也比較好理解,key 和val 都是使用者可以自定義的。如果key是定長的(比如是數字,或者 指標之類的,大概率是這樣。)記憶體是比較整齊的,利於定址吧。
## 技術總結
golang 實現的map比樸素的hashmap 在很多方面都有優化。
1. 使用掩碼方式獲取偏移,減少判斷。
2. bucket 儲存方式的優化。
3. 通過tophash 先進行一次比較,減少key 比較的成本。
4. 當然,有一點是不太明白的,為啥 overflow 指標要放在 kv 後面? 放在tophash 之後的位置豈不是更完美?
今天的作業就交完了。下一篇將學習golang map的資料初始化實現。
## 參考
[1] [深入理解 Go map:初始化和訪問](https://eddycjy.com/posts/go/map/2019-03-05-map-a