深刻理解 Go map:初始化和訪問元素

從本文開始我們一塊兒探索 Go map 裏面的奧妙吧,看看它的內在是怎麼構成的,又分別有什麼值得留意的地方?html

第一篇將探討初始化和訪問元素相關板塊,我們帶着疑問去學習,例如:golang

  • 初始化的時候會立刻分配內存嗎?
  • 底層數據是如何存儲的?
  • 底層是如何使用 key 去尋找數據的?
  • 底層是用什麼方式解決哈希衝突的?
  • 數據類型那麼多,底層又是怎麼處理的呢?

...算法

原文地址:深刻理解 Go map:初始化和訪問元素數組

數據結構

首先咱們一塊兒看看 Go map 的基礎數據結構,先有一個大體的印象數據結構

image

hmap

type hmap struct {
    count     int 
    flags     uint8
    B         uint8
    noverflow uint16
    hash0     uint32
    buckets    unsafe.Pointer
    oldbuckets unsafe.Pointer
    nevacuate  uintptr 
    extra *mapextra
}

type mapextra struct {
    overflow    *[]*bmap
    oldoverflow *[]*bmap
    nextOverflow *bmap
}
  • count:map 的大小,也就是 len() 的值。代指 map 中的鍵值對個數
  • flags:狀態標識,主要是 goroutine 寫入和擴容機制的相關狀態控制。併發讀寫的判斷條件之一就是該值
  • B:桶,最大可容納的元素數量,值爲 負載因子(默認 6.5) * 2 ^ B,是 2 的指數
  • noverflow:溢出桶的數量
  • hash0:哈希因子
  • buckets:保存當前桶數據的指針地址(指向一段連續的內存地址,主要存儲鍵值對數據)
  • oldbuckets,保存舊桶的指針地址
  • nevacuate:遷移進度
  • extra:原有 buckets 滿載後,會發生擴容動做,在 Go 的機制中使用了增量擴容,以下爲細項:併發

    • overflowhmap.buckets (當前)溢出桶的指針地址
    • oldoverflowhmap.oldbuckets (舊)溢出桶的指針地址
    • nextOverflow 爲空閒溢出桶的指針地址

在這裏咱們要注意幾點,以下:函數

  1. 若是 keys 和 values 都不包含指針而且容許內聯的狀況下。會將 bucket 標識爲不包含指針,使用 extra 存儲溢出桶就能夠避免 GC 掃描整個 map,節省沒必要要的開銷
  2. 在前面有提到,Go 用了增量擴容。而 bucketsoldbuckets 也是與擴容相關的載體,通常狀況下只使用 bucketsoldbuckets 是爲空的。但若是正在擴容的話,oldbuckets 便不爲空,buckets 的大小也會改變
  3. hint 大於 8 時,就會使用 *mapextra 作溢出桶。若小於 8,則存儲在 buckets 桶中

bmap

image

bucketCntBits = 3
bucketCnt     = 1 << bucketCntBits
...
type bmap struct {
    tophash [bucketCnt]uint8
}
  • tophash:key 的 hash 值高 8 位
  • keys:8 個 key
  • values:8 個 value
  • overflow:下一個溢出桶的指針地址(當 hash 衝突發生時)

實際 bmap 就是 buckets 中的 bucket,一個 bucket 最多存儲 8 個鍵值對性能

tophash

tophash 是個長度爲 8 的數組,代指桶最大可容納的鍵值對爲 8。學習

存儲每一個元素 hash 值的高 8 位,若是 tophash [0] <minTopHash,則 tophash [0] 表示爲遷移進度ui

keys 和 values

在這裏咱們留意到,存儲 k 和 v 的載體並非用 k/v/k/v/k/v/k/v 的模式,而是 k/k/k/k/v/v/v/v 的形式去存儲。這是爲何呢?

map[int64]int8

在這個例子中,若是按照 k/v/k/v/k/v/k/v 的形式存放的話,雖然每一個鍵值對的值都只佔用 1 個字節。可是卻須要 7 個填充字節來補齊內存空間。最終就會形成大量的內存 「浪費」

image

可是若是以 k/k/k/k/v/v/v/v 的形式存放的話,就可以解決因對齊所 "浪費" 的內存空間

所以這部分的拆分主要是考慮到內存對齊的問題,雖然相對會複雜一點,但依然值得如此設計

image

overflow

可能會有同窗疑惑爲何會有溢出桶這個東西?實際上在不存在哈希衝突的狀況下,去掉溢出桶,也就是隻須要桶、哈希因子、哈希算法。也能實現一個簡單的 hash table。可是哈希衝突(碰撞)是不可避免的...

而在 Go map 中當 hmap.buckets 滿了後,就會使用溢出桶接着存儲。咱們結合分析可肯定 Go 採用的是數組 + 鏈地址法解決哈希衝突

image

初始化

用法

m := make(map[int32]int32)

函數原型

經過閱讀源碼可得知,初始化方法有好幾種。函數原型以下:

func makemap_small() *hmap
func makemap64(t *maptype, hint int64, h *hmap) *hmap
func makemap(t *maptype, hint int, h *hmap) *hmap
  • makemap_small:當 hint 小於 8 時,會調用 makemap_small 來初始化 hmap。主要差別在因而否會立刻初始化 hash table
  • makemap64:當 hint 類型爲 int64 時的特殊轉換及校驗處理,後續實質調用 makemap
  • makemap:實現了標準的 map 初始化動做

源碼

func makemap(t *maptype, hint int, h *hmap) *hmap {
    if hint < 0 || hint > int(maxSliceCap(t.bucket.size)) {
        hint = 0
    }

    if h == nil {
        h = new(hmap)
    }
    h.hash0 = fastrand()

    B := uint8(0)
    for overLoadFactor(hint, B) {
        B++
    }
    h.B = B

    if h.B != 0 {
        var nextOverflow *bmap
        h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
        if nextOverflow != nil {
            h.extra = new(mapextra)
            h.extra.nextOverflow = nextOverflow
        }
    }

    return h
}
  • 根據傳入的 bucket 類型,獲取其類型可以申請的最大容量大小。並對其長度 make(map[k]v, hint) 進行邊界值檢驗
  • 初始化 hmap
  • 初始化哈希因子
  • 根據傳入的 hint,計算一個能夠放下 hint 個元素的桶 B 的最小值
  • 分配並初始化 hash table。若是 B 爲 0 將在後續懶惰分配桶,大於 0 則會立刻進行分配
  • 返回初始化完畢的 hmap

在這裏能夠注意到,(當 hint 大於等於 8 )第一次初始化 map 時,就會經過調用 makeBucketArray 對 buckets 進行分配。所以咱們經常會說,在初始化時指定一個適當大小的容量。可以提高性能。

若該容量過少,而新增的鍵值對又不少。就會致使頻繁的分配 buckets,進行擴容遷移等 rehash 動做。最終結果就是性能直接的降低(敲黑板)

而當 hint 小於 8 時,這種問題相對就不會凸顯的太明顯,以下:

func makemap_small() *hmap {
    h := new(hmap)
    h.hash0 = fastrand()
    return h
}

圖示

image

訪問

用法

v := m[i]
v, ok := m[i]

函數原型

在實現 map 元素訪問上有好幾種方法,主要是包含針對 32/64 位、string 類型的特殊處理,總的函數原型以下:

mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool)

mapaccessK(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer)

mapaccess1_fat(t *maptype, h *hmap, key, zero unsafe.Pointer) unsafe.Pointer
mapaccess2_fat(t *maptype, h *hmap, key, zero unsafe.Pointer) (unsafe.Pointer, bool)

mapaccess1_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer
mapaccess2_fast32(t *maptype, h *hmap, key uint32) (unsafe.Pointer, bool)
mapassign_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer
mapassign_fast32ptr(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer

mapaccess1_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer
...

mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer
...
  • mapaccess1:返回 h[key] 的指針地址,若是鍵不在 map 中,將返回對應類型的零值
  • mapaccess2:返回 h[key] 的指針地址,若是鍵不在 map 中,將返回零值和布爾值用於判斷

源碼

func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    ...
    if h == nil || h.count == 0 {
        return unsafe.Pointer(&zeroVal[0])
    }
    if h.flags&hashWriting != 0 {
        throw("concurrent map read and map write")
    }
    alg := t.key.alg
    hash := alg.hash(key, uintptr(h.hash0))
    m := bucketMask(h.B)
    b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
    if c := h.oldbuckets; c != nil {
        if !h.sameSizeGrow() {
            // There used to be half as many buckets; mask down one more power of two.
            m >>= 1
        }
        oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
        if !evacuated(oldb) {
            b = oldb
        }
    }
    top := tophash(hash)
    for ; b != nil; b = b.overflow(t) {
        for i := uintptr(0); i < bucketCnt; i++ {
            if b.tophash[i] != top {
                continue
            }
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            if t.indirectkey {
                k = *((*unsafe.Pointer)(k))
            }
            if alg.equal(key, k) {
                v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
                if t.indirectvalue {
                    v = *((*unsafe.Pointer)(v))
                }
                return v
            }
        }
    }
    return unsafe.Pointer(&zeroVal[0])
}
  • 判斷 map 是否爲 nil,長度是否爲 0。如果則返回零值
  • 判斷當前是否併發讀寫 map,如果則拋出異常
  • 根據 key 的不一樣類型調用不一樣的 hash 方法計算得出 hash 值
  • 肯定 key 在哪個 bucket 中,並獲得其位置
  • 判斷是否正在發生擴容(h.oldbuckets 是否爲 nil),若正在擴容,則到老的 buckets 中查找(由於 buckets 中可能尚未值,搬遷未完成),若該 bucket 已經搬遷完畢。則到 buckets 中繼續查找
  • 計算 hash 的 tophash 值(高八位)
  • 根據計算出來的 tophash,依次循環對比 buckets 的 tophash 值(快速試錯)
  • 若是 tophash 匹配成功,則計算 key 的所在位置,正式完整的對比兩個 key 是否一致
  • 若查找成功並返回,若不存在,則返回零值

在上述步驟三中,提到了根據不一樣的類型計算出 hash 值,另外會計算出 hash 值的高八位和低八位。低八位會做爲 bucket index,做用是用於找到 key 所在的 bucket。而高八位會存儲在 bmap tophash 中

其主要做用是在上述步驟七中進行迭代快速定位。這樣子能夠提升性能,而不是一開始就直接用 key 進行一致性對比

圖示

image

總結

在本章節,咱們介紹了 map 類型的如下知識點:

  • map 的基礎數據結構
  • 初始化 map
  • 訪問 map

從閱讀源碼中,得知 Go 自己對於一些不一樣大小、不一樣類型的屬性,包括哈希方法都有編寫特定方法去運行。總的來講,這塊的設計隱含較多的思路,有很多點值得細細品嚐 :)

注:本文基於 Go 1.11.5

相關文章
相關標籤/搜索