golang map 實現原理

總體來說golang的map是hashmap,是使用數組+鏈表的形式實現的,使用拉鍊法消除hash衝突。

map的源碼在Go_SDK\go1.17.2\src\runtime\map.go

Golang中map的底層實現是一個哈希表,因此實現map的過程實際上就是實現哈希表的過程。在這個哈希表中主要出現的結構體有兩個,一個叫hmap(a header for a go map),一個叫bmap(a bucket for a Go map通常叫其bucket)

1. 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
}

Golang的map中用於存儲的結構是bucket數組。

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.
}

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

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

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

在golang runtime時,編譯器會動態爲bmap創建一個新結構

type bmap struct {
    topbits  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

哈希表的特點是會有一個哈希函數,對傳進來的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的快速預覽用於快速試錯)

2.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用於在擴容階段儲存舊桶用到的那些溢出桶的地址。

1.翻倍擴容

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

2.等量擴容

如果負載因子沒有超標,但是使用的溢出桶較多也會出發擴容,不過這一次是等量擴容。

那麼用多少溢出桶算多了呢?

如果常規桶的數目不大於 2 ^ 15 ,那麼使用溢出桶的數目超過常規桶就算是多了。 如果常規桶的數目大於 2 ^ 15 ,那麼使用溢出桶的數目一旦超過 2 ^ 15 ,就算是多了。

所謂等量擴容,就是創建和舊桶數目一樣多的新桶。然後把原來的鍵值對遷移到新桶中,但是既然是等量,那來回遷移的又有什麼用呢?

什麼情況下,桶的負載因子沒有超過上限值,卻偏偏使用了很多溢出桶呢?自然是有很多鍵值對被刪除的情況。同樣數目的鍵值對遷移到新桶中,能夠排列的更加緊湊從而減少溢出桶的使用。

這就是等量擴容的意義所在。

參考博客

https://www.bilibili.com/video/BV1Sp4y1U7dJ

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章