總體來說golang的map是hashmap,是使用數組+鏈表的形式實現的,使用拉鏈法消除hash沖突。
map的內存模型
我的go源碼版本是:go1.17.2
map的源碼在Go_SDK\go1.17.2\src\runtime\map.go中。
首先我們來看一下map最重要的兩個結構:
hmap:
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
bmap:(bucket桶)
// A bucket for a Go map.
type bmap struct {
// tophash generally contains the top byte of the hash value
// for each key in this bucket. If tophash[0] < minTopHash,
// tophash[0] is a bucket evacuation state instead.
tophash [bucketCnt]uint8
// Followed by bucketCnt keys and then bucketCnt elems.
// NOTE: packing all the keys together and then all the elems together makes the
// code a bit more complicated than alternating key/elem/key/elem/... but it allows
// us to eliminate padding which would be needed for, e.g., map[int64]int8.
// Followed by an overflow pointer.
}
實際上在golang runtime時,編譯器會動態為bmap創建一個新結構:
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
Golang中map的底層實現是一個哈希表,因此實現map的過程實際上就是實現哈希表的過程。在這個哈希表中,主要出現的結構體有兩個,一個叫hmap(a header for a go map),一個叫bmap(a bucket for a Go map,通常叫其bucket)。這兩種結構的樣子分別如下所示:
hmap:

圖中有很多字段,但是便於理解map的架構,你只需要關心的只有一個,就是標紅的字段:buckets數組。Golang的map中用於存儲的結構是bucket數組。
bucket:

標紅的字段依然是“核心”,map中的key和value就存儲在這里。“高位哈希值”數組記錄的是當前bucket中key相關的“索引”,稍后會詳細敘述。還有一個字段是一個指向擴容后的bucket的指針,使得bucket會形成一個鏈表結構。例如下圖:

由此看出hmap和bucket的關系是這樣的:

而bucket又是一個鏈表,所以整體的結構應該是這樣的:

哈希表的特點是會有一個哈希函數,對傳進來的key進行哈希運算,得到唯一的值,一般情況下都是一個數值。Golang的map中也有這么一個哈希函數,也會算出唯一的值,對於這個值的使用:
Golang把求得的值按照用途一分為二:高位和低位。

如圖所示,藍色為高位,紅色為低位。 然后低位用於尋找當前key屬於hmap中的哪個bucket,而高位用於尋找bucket中的哪個key。上文中提到:bucket中有個屬性字段是“高位哈希值”數組,這里存的就是藍色的高位值,用來聲明當前bucket中有哪些“key”,便於搜索查找。 需要特別指出的一點是:我們map中的key/value值都是存到同一個數組中的。數組中的順序是這樣的:

並不是key0/value0/key1/value1的形式,這樣做的好處是:在key和value的長度不同的時候,可以消除padding(內存對齊)帶來的空間浪費。
現在,我們可以得到Go語言map的整個的結構圖了:(hash結果的低位用於選擇把KV放在bmap數組中的哪一個bucket中,高位用於key的快速預覽,用於快速試錯)

map的擴容
負載因子
判斷擴充的條件,就是哈希表中的負載因子(即loadFactor)。
每個哈希表的都會有一個負載因子,數值超過負載因子就會為哈希表擴容。
Golang的map的加載因子的公式是:map長度 / 2^B(這是代表bmap數組的長度,B是取的低位的位數)閾值是6.5。其中B可以理解為已擴容的次數。
漸進式擴容
需要擴容時就要分配更多的桶(Bucket),它們就是新桶。需要把舊桶里儲存的鍵值對都遷移到新桶里。如果哈希表存儲的鍵值對較多,一次性遷移所有桶所花費的時間就比較顯著。
所以通常會在哈希表擴容時,先分配足夠多的新桶,然后用一個字段(oldbuckets)記錄舊桶的位置。
再增加一個字段(nevacuate),記錄舊桶遷移的進度。例如記錄下一個要遷移的舊桶編號。
在哈希表每次進行讀寫操作時,如果檢測到當前處於擴容階段,就完成一部分鍵值對遷移任務,直到所有的舊桶遷移完成,舊桶不再使用,才算真正完成一次哈希表的擴容。
像這樣把鍵值對遷移的時間分攤到多次哈希表操作中的方式,就是漸進式擴容,可以避免一次性擴容帶來的性能瞬時抖動。

擴容規則
bmap結構體的最后一個字段是一個bmap型指針,指向一個溢出桶。溢出桶的內存布局與常規桶相同,是為了減少擴容次數而引入的。
當一個桶存滿了,還有可用的溢出桶時,就會在后面鏈一個溢出桶,繼續往這里面存。

實際上如果哈希表要分配的桶數目大於2 ^ 4,就認為要使用到溢出桶的幾率較大,就會預分配2 ^ (B - 4)個溢出桶備用。
這些溢出桶與常規桶在內存中是連續的,只是前2 ^ B個用做常規桶,后面的用作溢出桶。
hmap結構體最后有一個extra字段,指向一個mapextra結構體。里面記錄的都是溢出桶相關的信息。nextoverflow指向下一個空閑溢出桶。
overflow是一個slice,記錄目前已經被使用的溢出桶的地址。noverflower記錄使用的溢出桶數量。oldoverflower用於在擴容階段儲存舊桶用到的那些溢出桶的地址。

翻倍擴容
當負載因子 count / (2 ^ B) > 6.5 ,就會發生翻倍擴容(hmap.B++),分配新桶的數量是舊桶的兩倍。
buckets指向新分配的兩個桶,oldbuckets指向舊桶。nevacuate為0,表示接下來要遷移編號為0的舊桶。
每個舊桶的鍵值對都會分流到兩個新桶中。

等量擴容
如果負載因子沒有超標,但是使用的溢出桶較多,也會出發擴容,不過這一次是等量擴容。
那么用多少溢出桶算多了呢?
- 如果常規桶的數目不大於
2 ^ 15,那么使用溢出桶的數目超過常規桶就算是多了。 - 如果常規桶的數目大於
2 ^ 15,那么使用溢出桶的數目一旦超過2 ^ 15,就算是多了。
所謂等量擴容,就是創建和舊桶數目一樣多的新桶。然后把原來的鍵值對遷移到新桶中,但是既然是等量,那來回遷移的又有什么用呢?
什么情況下,桶的負載因子沒有超過上限值,卻偏偏使用了很多溢出桶呢?自然是有很多鍵值對被刪除的情況。同樣數目的鍵值對,遷移到新桶中,能夠排列的更加緊湊,從而減少溢出桶的使用。這就是等量擴容的意義所在。
參考博客:
https://www.cnblogs.com/maji233/p/11070853.html
https://www.bilibili.com/video/BV1Sp4y1U7dJ?spm_id_from=333.999.0.0
