GO 中 map 的實現原理


GO 中 map 的實現原理

嗨,我是小魔童哪吒,我們來回顧一下上一次分享的內容

  • 分享了切片是什么
  • 切片和數組的區別
  • 切片的數據結構
  • 切片的擴容原理
  • 空切片 和 nil 切片的區別

要是對 GO 的slice 原理還有點興趣的話,歡迎查看文章 GO 中 slice 的實現原理

map 是什么?

是 GO 中的一種數據類型,底層實現是 hash 表,看到 hash 表 是不是會有一點熟悉的感覺呢

我們在寫 C/C++ 的時候,里面也有 map 這種數據結構,是 key - value 的形式

可是在這里我們可別搞混了,GO 里面的 map 和 C/C++ 的map 可不是同一種實現方式

  • C/C++ 的 map 底層是 紅黑樹實現的
  • GO 的 map 底層是hash 表實現的

可是別忘了C/C++中還有一個數據類型是 unordered_map,無序map,他的底層實現是 hash 表,與我們GO 里面的 map 實現方式類似

map 的數據結構是啥樣的?

前面說到的 GO 中 string 實現原理,GO 中 slice 實現原理, 都會對應有他們的底層數據結構

哈,沒有例外,今天說的 map 必然也有自己的數據結構, 相對來說會比前者會多一些成員,我們這就來看看吧

map 具體 的實現 源碼位置是:src/runtime/map.go

// A header for a Go map.
type hmap struct {
   // Note: the format of the hmap is also encoded in cmd/compile/internal/gc/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
}

hmap結構中的成員我們來一個一個看看:

字段 含義
count 當前元素保存的個數
flags 記錄幾個特殊的標志位
B hash 具體的buckets數量是 2^B 個
noverflow 溢出桶的近似數目
hash0 hash種子
buckets 一個指針,指向2^B個桶對應的數組指針,若count為0 則這個指針為 nil
oldbuckets 一個指針,指向擴容前的buckets數組
nevacuate 疏散進度計數器,也就是擴容后的進度
extra 可選字段,一般用於保存溢出桶鏈表的地址,或者是還沒有使用過的溢出桶數組的首地址

通過extra字段, 我們看到他是mapextra類型的,我們看看細節

// mapextra holds fields that are not present on all maps.
type mapextra struct {
   // If both key and elem do not contain pointers and are inline, then we mark bucket
   // type as containing no pointers. This avoids scanning such maps.
   // However, bmap.overflow is a pointer. In order to keep overflow buckets
   // alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow.
   // overflow and oldoverflow are only used if key and elem do not contain pointers.
   // overflow contains overflow buckets for hmap.buckets.
   // oldoverflow contains overflow buckets for hmap.oldbuckets.
   // The indirection allows to store a pointer to the slice in hiter.
   overflow    *[]*bmap
   oldoverflow *[]*bmap

   // nextOverflow holds a pointer to a free overflow bucket.
   nextOverflow *bmap
}

點進來,這里主要是要和大家一起看看這個 bmap的數據結構,

這個結構是,GO map 里面桶的實現結構,

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

type bmap struct {
    tophash [8]uint8 //存儲哈希值的高8位
    data    byte[1]  //key value數據:key/key/key/.../value/value/value...
    overflow *bmap   //溢出bucket的地址
}

源碼的意思是這樣的:

tophash 一般存放的是桶內每一個key hash值字節,如果 tophash[0] < minTopHash, tophash[0] 是一個疏散狀態

這里源碼中有一個注意點:

實際上分配內存的時候,內存的前8個字節是 bmap ,后面跟着 8 個 key 、 8 個 value 和 1 個溢出指針

我們來看看圖吧

GO 中 map 底層數據結構成員相對比 string 和 slice 多一些,不過也不是很復雜,咱們畫圖來瞅瞅

咱們的 hmap的結構是這樣的,可以關注桶數組(hmap.buckets

若圖中的 B = 3的話的,那么桶數組長度 就是 8

上面看到每一個 bucket ,最多可以存放 8 個key / value

如果超出了 8 個的話, 那么就會溢出,此時就會鏈接到額外的溢出桶

理解起來是這個樣子的

嚴格來說,每一個桶里面只會有8 個鍵值對,若多余 8 的話,就會溢出,溢出的指針就會指向另外一個桶對應的 8個鍵值對

這里我們結合一下上面 bmap 的數據結構:

  • tophash 是個長度為8的數組

哈希值低位相同的鍵存入當前bucket時,會將哈希值的高位存儲在該數組中,便於后續匹配

  • data里面存放的是 key-value 數據

存放順序是8個key依次排開,8個value依次排開這是為啥呢?

因為GO 里面為了字節對齊,節省空間

  • overflow 指針,指向的是另外一個 桶

這里是解決了 2 個問題,第一是解決了溢出的問題,第二是解決了沖突問題

啥是哈希沖突?

上述我們說到 hash 沖突,我們來看看啥是hash 沖突,以及如何解決呢

關鍵字值不同的元素可能會映象到哈希表的同一地址上就會發生哈希沖突

簡單對應到我們的上述數據結構里面來,我們可以這樣理解

當有兩個或以上的鍵(key)被哈希到了同一個bucket時,這些鍵j就發生了沖突

關於解決hash 沖突的方式大體有如下 4 個,網上查找的資料,咱們引用一下,梳理一波看看:

  • 開放定址法

當沖突發生時,使用某種探查(亦稱探測)技術在散列表中形成一個探查(測)序列。

沿此序列逐個單元地查找,直到找到給定 的關鍵字,或者碰到一個開放的地址(即該地址單元為空)為止(若要插入,在探查到開放的地址,則可將待插入的新結點存人該地址單元)。

查找時探查到開放的 地址則表明表中無待查的關鍵字,即查找失敗。

  • 再哈希法

同時構造多個不同的哈希函數。

  • 鏈地址法

將所有哈希地址為i的元素構成一個稱為同義詞鏈的單鏈表,並將單鏈表的頭指針存在哈希表的第 i 個單元中

因而查找、插入和刪除主要在同義詞鏈中進行。鏈地址法適用於經常進行插入和刪除的情況。

  • 建立公共溢出區

將哈希表分為基本表和溢出表兩部分,凡是和基本表發生沖突的元素,一律填入溢出表。

細心的小伙伴看到這里,有沒有看出來 GO 中的map 是如何解決 hash 沖突的?

沒錯,GO 中的map 解決hash 沖突 就是使用的是 鏈地址法來解決鍵沖突

再來一個圖,咱們看看他是咋鏈 的,其實咱們上述說的溢出指針就已經揭曉答案了

如上圖,每一個bucket 里面的溢出指針 會指向另外一個 bucket ,每一個bucket 里面存放的是 8 個 key 和 8 個 value ,bucket 里面的溢出指針又指向另外一個bucket,用類似鏈表的方式將他們連接起來

GO 中 map 的基本操作有哪些?

map 的應用比較簡單,感興趣的可以在搜索引擎上查找相關資料,知道 map 具體實現原理之后,再去應用就會很簡單了

  • map 的初始化
  • map 的增、刪、改、查

GO 中 map 可以擴容嗎?

當然可以擴容,擴容分為如下兩種情況:

  • 增量擴容
  • 等量擴容

咱們 map 擴容也是有條件的,不是隨隨便便就能擴容的。

當一個新的元素要添加進map的時候,都會檢查是否需要擴容,擴容的觸發條件就有 2 個:

  • 當負載因子 > 6.5的時候,也就是平均下來,每個bucket存儲的鍵值對達到6.5個的時候,就會擴容
  • 當溢出的數量 > 2^15 的時候,也會擴容

這里說一下啥是負載因子呢?

有這么一個公式,來計算負載因子:

負載因子 = 鍵的數量 / bucket 數量

舉個例子:

若有bucket有8個,鍵值對也有8個,則這個哈希表的負載因子就是 1

哈希表也是要對負載因子進行控制的,不能讓他太大,也不能太小,要在一個合適的范圍內,具體的合適范圍根據不同的組件有不同的值,若超過了這個合適范圍,哈希表就會觸發再哈希(rehash

例如

  • 哈希因子太小的話,這就表明空間利用率低
  • 哈希因子太大的話,這就表明哈希沖突嚴重,存取效率比較低

注意了,在 Go 里面,負載因子達到6.5時會觸發rehash

啥是增量擴容

就是當負載因子過大,也就是哈希沖突嚴重的時候,會做如下 2 個步驟

  • 新建一個 bucket,新的bucket 是原 bucket 長度的 double
  • 再將原來的 bucket 數據 搬遷到 新的 bucket 中

可是我們想一想,如果有上千萬,上億級別鍵值對,那么遷移起來豈不是很耗時

所以GO 還是很聰明的,他采用的逐步搬遷的方法,每次訪問map,都會觸發一次遷移

咱們畫個圖來瞅瞅

咱畫一個hmap,里面有 1 個bucket0,這個桶的滿載是 4個 key-value,此時的負載因子是 4

實際上是不會觸發擴容的,因為GO 的默認負載因子是 6.5

但是我們為了演示方便,模擬一下擴容的效果

當再插入一個鍵值對的時候,就會觸發擴容操作,擴容之后再把新插入的鍵值對,放到新的bucket中,即bucket1,而舊的bucket指針就會指向原來的那個bucket

最后,再做一個遷移,將舊的bucket,遷移到新的bucket上面來,刪掉舊的bucket

根據上述的數據搬遷圖,我們可以知道

在數據搬遷的過程中,原來的bucket中的鍵值對會存在於新的bucket的前面

新插入的鍵值對,會存在與另外一個bucket中,自然而然的會放到原來 bucket 的后面了

啥是等量擴容

等量擴容,等量這個名字感覺像是,擴充的容量和原來的容量是一一對齊的,也就是說成倍增長

其實不然,等量擴容,其實buckets數量沒有變化

只是對bucket的鍵值對重新排布,整理的更加有條理,讓其使用率更加的高

例如 等量擴容后,對於一些 溢出的 buckets,且里面的內容都是空的鍵值對,這時,就可以把這些降低效率且無效的buckets清理掉

這樣,是提高buckets效率的一種有效方式

總結

  • 分享 map 是什么
  • map 的底層數據結構是啥樣的
  • 什么是哈希沖突,並且如何解決
  • GO 的map 擴容方式,以及畫圖進行理解

歡迎點贊,關注,收藏

朋友們,你的支持和鼓勵,是我堅持分享,提高質量的動力

好了,本次就到這里,下一次 GO 中 Chan 的實現原理分享

技術是開放的,我們的心態,更應是開放的。擁抱變化,向陽而生,努力向前行。

我是小魔童哪吒,歡迎點贊關注收藏,下次見~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM