GO 中 map 的實現原理

小魔童哪吒發表於2021-06-19

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

  • 分享了切片是什麼
  • 切片和陣列的區別
  • 切片的資料結構
  • 切片的擴容原理
  • 空切片 和 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 的實現原理分享

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

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

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章