【Go進階—資料結構】map

與昊發表於2021-10-19

雜湊表是除了陣列之外,最常見的資料結構。Go 語言中的 map 底層就是雜湊表,可以很方便地提供鍵值對的對映。

特性

未初始化的 map 的值為 nil,向值為 nil 的 map 新增元素會觸發 panic,這是新手容易犯的錯誤之一。

map 操作不是原子的,多個協程同時操作 map 時有可能產生讀寫衝突,此時會觸發 panic 導致程式退出。如果需要併發讀寫,可以使用鎖來保護 map,也可以使用標準庫 sync 包中的 sync.Map。

實現原理

資料結構

map 的底層資料結構由 runtime/map.go/hmap 定義:

type hmap struct {
    count     int             // 元素個數,呼叫 len(map) 時,直接返回此值
    flags     uint8
    B         uint8           // buckets 陣列長度的對數
    noverflow uint16          // overflow 的 bucket 近似數
    hash0     uint32          // 雜湊種子,為雜湊函式的結果引入隨機性,在呼叫雜湊函式時作為引數傳入
    
    buckets    unsafe.Pointer // 指向 buckets 陣列,大小為 2^B,元素個數為0時為 nil
    oldbuckets unsafe.Pointer // 在擴容時用於儲存舊 buckets 陣列,大小為 buckets 的一半
    nevacuate  uintptr        // 指示擴容進度,小於此地址的 buckets 都已遷移完成
    extra *mapextra           // 附加項
}

buckets 陣列中儲存了 bucket 的指標,bucket 很多時候被翻譯為桶,它是 map 鍵值對的真正載體。它的資料結構定義如下:

type bmap struct {
    tophash [bucketCnt]uint8    // 儲存鍵的 hash 值的高 8 位
}

在執行期間,bmap 結構體其實不止包含 tophash 欄位,因為雜湊表中可能儲存不同型別的鍵值對,而且 Go 語言(1.17之前)也不支援泛型,所以鍵值對佔據的記憶體空間大小隻能在編譯時進行推導。bmap 中的其他欄位在執行時也都是通過計算記憶體地址的方式訪問的,所以它的定義中就不包含這些欄位。在執行時,bmap 搖身一變,成了下面的樣子:

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

整體的 map 結構圖大致如下所示:

image.png

bmap 的內部組成類似於下圖:

image.png

HOB Hash 指的就是 top hash。key 和 value 是各自放在一起的,並不是 key/value/key/value/... 這樣的形式。這樣的好處是在某些情況下可以省略掉 padding 欄位,節省記憶體空間。

每個 bucket 設計成最多隻能放 8 個 key-value 對,如果有第 9 個 key-value 落入當前的 bucket,那就需要再構建一個 bucket,通過 overflow 指標連線起來。

相關操作

查詢

key 經過雜湊計算後得到雜湊值,共 64 個 bit 位,計算它到底要落在哪個桶時,只會用到最後 B 個 bit 位。還記得前面提到過的 B 嗎?B 等於桶的數量,也就是 buckets 陣列長度的對數。

例如,現在有一個 key 經過雜湊函式計算後,得到的雜湊結果是:

10010111 | 000011110110110010001111001010100010010110010101010 │ 00110

用最後的 5 個 bit 位,也就是 00110,定位到 6 號桶。這個操作實際上就是取餘操作,但是取餘開銷太大,所以程式碼實現上用位操作代替。再用雜湊值的高 8 位,找到此 key 在 bucket 中的位置。如下圖所示:

image.png

因為可能存在雜湊衝突,所以在定位到 key 在 bucket 中的位置後,還需要獲取對應的 key 值與待查詢的 key 進行比較,不相等的話則繼續上面的定位過程。如果在當前的 bucket 中沒找到,並且 overflow 不為空,還要繼續去 overflow bucket 中尋找,直到找到或是所有的 key 槽位都找遍了。如果未找到,也不會返回 nil,而是返回相應型別的零值。

注:如果當前處於搬遷過程(擴容),則優先從 oldbuckets 中查詢。

賦值

賦值操作最終呼叫的是 mapassign 函式,它的初始流程和上面介紹的查詢類似,也是通過 key 找到對應的 bucket 中的位置。準備兩個指標,一個(inserti)指向 key 的 hash 值在 tophash 陣列所處的位置,另一個 (insertk) 指向 cell 的位置(也就是 key 最終放置的地址)。在迴圈的過程中,inserti 和 insertk 分別指向第一個找到的空閒的 cell,如果最終沒有找到 key 的話,就在此位置插入。如果當前桶已經滿了,會呼叫 newoverflow 建立新桶或者使用 hmap 預先在 noverflow 中建立好的桶來儲存資料,新建立的桶不僅會被追加到已有桶的末尾,還會增加 hmap 的 noverflow 計數器。

如果當前 key 在 map 中存在,那麼就會直接返回目標區域的記憶體地址;如果不存在,則會為新鍵值對規劃儲存的記憶體地址,通過 typedmemmove 將鍵移動到對應的記憶體空間中並返回鍵對應值的地址。map 並不會在 mapassign 這個執行時函式中將值拷貝到桶中,該函式只會返回記憶體地址,真正的賦值操作是在編譯期間插入的。

擴容

前面在介紹 map 的寫入過程時其實省略了擴容操作,隨著 map 中元素的逐漸增加,效能會逐漸惡化,所以需要更多的桶和更大的記憶體保證 map 的效能。在向 map 插入新 key 的時候,會進行條件檢測,符合的話就會觸發擴容:

if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
    hashGrow(t, h)
    goto again
}

func overLoadFactor(count int, B uint8) bool {
    return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}

func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
    if B > 15 {
        B = 15
    }
    return noverflow >= uint16(1)<<(B&15)
}

由原始碼可以看出,觸發擴容的條件為下面二者之一:

  1. 裝載因子超過閾值,原始碼裡定義的閾值是 6.5;
  2. overflow 的 bucket 數量過多:當 B 小於 15,也即 bucket 總數小於 2^15 時,overflow 的 bucket 數量超過 2^B;當 B >= 15,也即 bucket 總數大於等於 2^15時,overflow 的 bucket 數量超過 2^15。

對於條件一,說明 bucket 數量太少,此時需要擴充 bucket 的數量,稱之為增量擴容;對於條件二,說明 bucket 裡面鍵值對太稀疏,此時並不需要擴充 bucket 的數量,稱之為等量擴容。無論是那種情況,都需要開闢一個新的 bucket 陣列,將舊的 bucket 陣列中的鍵值對移動到新的中來,只不過增量擴容時 bucket 的數量變為了之前的 2 倍。我們來看下擴容的入口 hashGrow 函式的核心程式碼:

func hashGrow(t *maptype, h *hmap) {
    bigger := uint8(1)
    if !overLoadFactor(h.count+1, h.B) {
        bigger = 0
        h.flags |= sameSizeGrow
    }
    oldbuckets := h.buckets
    newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

    h.B += bigger
    h.flags = flags
    h.oldbuckets = oldbuckets
    h.buckets = newbuckets
    h.nevacuate = 0
    h.noverflow = 0

    ...
}

我們可以看到,hashGrow 函式只是分配好了新的 buckets,並將老的 buckets 掛到了 oldbuckets 欄位上,並沒有真正地遷移資料。這是因為如果有大量的鍵值對需要遷移,會非常影響效能。因此 map 的擴容採取了一種“漸進式”的方式,原有的 key 並不會一次性遷移完畢,每次最多隻會遷移 2 個 bucket。在插入或修改、刪除 key 的時候,都會先檢查 oldbuckets 是否遷移完畢,具體來說就是檢查 oldbuckets 是否為 nil。如果為假則執行遷移,也即呼叫 growWork 函式。growWork 函式的程式碼如下:

func growWork(t *maptype, h *hmap, bucket uintptr) {
    evacuate(t, h, bucket&h.oldbucketmask())

    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}

evacuate 函式就是執行遷移的函式,它會對傳入桶中的元素進行再分配,大致邏輯如下:該函式會建立用於儲存分配上下文的 evacDst 結構體,等量擴容只會建立一個,增量擴容則會建立兩個,每個 evacDst 分別指向一個新桶。等量擴容的話,每個 key 都遷移到和之前同一序號的桶中;增量擴容的話,會根據雜湊值和新的掩碼分流到兩個桶中。最後會增加 map 的 nevacuate 計數器並在所有的舊桶都被分流後清空 map 的 oldbuckets 和 oldoverflow。

相關文章