深刻理解 Go map:賦值和擴容遷移

概要

上一章節 中,數據結構小節裏講解了大量基礎字段,可能你會疑惑須要 #&(!……#(!¥! 來幹嗎?接下來咱們一塊兒簡單瞭解一下基礎概念。再開始研討今天文章的重點內容。我相信這樣你能更好的讀懂這篇文章html

原文地址:深刻理解 Go map:賦值和擴容遷移golang

哈希函數

哈希函數,又稱散列算法、散列函數。主要做用是經過特定算法將數據根據必定規則組合從新生成獲得一個散列值算法

而在哈希表中,其生成的散列值經常使用於尋找其鍵映射到哪個桶上。而一個好的哈希函數,應當儘可能少的出現哈希衝突,以此保證操做哈希表的時間複雜度(可是哈希衝突在目前來說,是沒法避免的。咱們須要 「解決」 它)segmentfault

image

鏈地址法

在哈希操做中,至關核心的一個處理動做就是 「哈希衝突」 的解決。而在 Go map 中採用的就是 "鏈地址法 " 去解決哈希衝突,又稱 "拉鍊法"。其主要作法是數組 + 鏈表的數據結構,其溢出節點的存儲內存都是動態申請的,所以相對更靈活。而每個元素都是一個鏈表。以下圖:數組

image

桶/溢出桶

type hmap struct {
    ...
    buckets    unsafe.Pointer
    ...
    extra *mapextra
}

type mapextra struct {
    overflow    *[]*bmap
    oldoverflow *[]*bmap
    nextOverflow *bmap
}

在上章節中,咱們介紹了 Go map 中的桶和溢出桶的概念,在其桶中只能存儲 8 個鍵值對元素。當超過 8 個時,將會使用溢出桶進行存儲或進行擴容數據結構

你可能會有疑問,hint 大於 8 又會怎麼樣?答案很明顯,性能問題,其時間複雜度改變(也就是執行效率出現問題)併發

前言

概要複習的差很少後,接下來咱們將一同研討 Go map 的另外三個核心行爲:賦值、擴容、遷移。正式開始咱們的研討之旅吧 :)app

賦值

m := make(map[int32]string)
m[0] = "EDDYCJY"

函數原型

在 map 的賦值動做中,依舊是針對 32/64 位、string、pointer 類型有不一樣的轉換處理,總的函數原型以下:函數

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

func mapaccess1_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer
func mapaccess2_fast64(t *maptype, h *hmap, key uint64) (unsafe.Pointer, bool)
func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer
func mapassign_fast64ptr(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer
func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer
func mapaccess2_faststr(t *maptype, h *hmap, ky string) (unsafe.Pointer, bool)
func mapassign_faststr(t *maptype, h *hmap, s string) unsafe.Pointer
...

接下來咱們將分紅幾個部分去看看底層在賦值的時候,都作了些什麼處理?性能

源碼

第一階段:校驗和初始化

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
    if h == nil {
        panic(plainError("assignment to entry in nil map"))
    }
    ...
    if h.flags&hashWriting != 0 {
        throw("concurrent map writes")
    }
    alg := t.key.alg
    hash := alg.hash(key, uintptr(h.hash0))

    h.flags |= hashWriting

    if h.buckets == nil {
        h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
    }
    ...    
}
  • 判斷 hmap 是否已經初始化(是否爲 nil)
  • 判斷是否併發讀寫 map,如果則拋出異常
  • 根據 key 的不一樣類型調用不一樣的 hash 方法計算得出 hash 值
  • 設置 flags 標誌位,表示有一個 goroutine 正在寫入數據。由於 alg.hash 有可能出現 panic 致使異常
  • 判斷 buckets 是否爲 nil,如果則調用 newobject 根據當前 bucket 大小進行分配(例如:上章節提到的 makemap_small 方法,就在初始化時沒有初始 buckets,那麼它在第一次賦值時就會對 buckets 分配)

第二階段:尋找可插入位和更新既有值

...
again:
    bucket := hash & bucketMask(h.B)
    if h.growing() {
        growWork(t, h, bucket)
    }
    b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
    top := tophash(hash)

    var inserti *uint8
    var insertk unsafe.Pointer
    var val unsafe.Pointer
    for {
        for i := uintptr(0); i < bucketCnt; i++ {
            if b.tophash[i] != top {
                if b.tophash[i] == empty && inserti == nil {
                    inserti = &b.tophash[i]
                    insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
                    val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
                }
                continue
            }
            k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
            if t.indirectkey {
                k = *((*unsafe.Pointer)(k))
            }
            if !alg.equal(key, k) {
                continue
            }
            // already have a mapping for key. Update it.
            if t.needkeyupdate {
                typedmemmove(t.key, k, key)
            }
            val = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
            goto done
        }
        ovf := b.overflow(t)
        if ovf == nil {
            break
        }
        b = ovf
    }

    if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
        hashGrow(t, h)
        goto again // Growing the table invalidates everything, so try again
    }
    ...
  • 根據低八位計算獲得 bucket 的內存地址,並判斷是否正在擴容,若正在擴容中則先遷移再接着處理
  • 計算並獲得 bucket 的 bmap 指針地址,計算 key hash 高八位用於查找 Key
  • 迭代 buckets 中的每個 bucket(共 8 個),對比 bucket.tophash 與 top(高八位)是否一致
  • 若不一致,判斷是否爲空槽。如果空槽(有兩種狀況,第一種是沒有插入過。第二種是插入後被刪除),則把該位置標識爲可插入 tophash 位置。注意,這裏就是第一個能夠插入數據的地方
  • 若 key 與當前 k 不匹配則跳過。但如果匹配(也就是本來已經存在),則進行更新。最後跳出並返回 value 的內存地址
  • 判斷是否迭代完畢,如果則結束迭代 buckets 並更新當前桶位置
  • 若知足三個條件:觸發最大 LoadFactor 、存在過多溢出桶 overflow buckets、沒有正在進行擴容。就會進行擴容動做(以確保後續的動做)

總的來說,這一塊邏輯作了兩件大事,第一是尋找空位,將位置其記錄在案,用於後續的插入動做。第二是判斷 Key 是否已經存在哈希表中,存在則進行更新。而如果第二種場景,更新完畢後就會進行收尾動做,第一種將繼續執行下述的代碼

第三階段:申請新的插入位和插入新值

...
    if inserti == nil {
        newb := h.newoverflow(t, b)
        inserti = &newb.tophash[0]
        insertk = add(unsafe.Pointer(newb), dataOffset)
        val = add(insertk, bucketCnt*uintptr(t.keysize))
    }

    if t.indirectkey {
        kmem := newobject(t.key)
        *(*unsafe.Pointer)(insertk) = kmem
        insertk = kmem
    }
    if t.indirectvalue {
        vmem := newobject(t.elem)
        *(*unsafe.Pointer)(val) = vmem
    }
    typedmemmove(t.key, insertk, key)
    *inserti = top
    h.count++

done:
    ...
    return val

通過前面迭代尋找動做,若沒有找到可插入的位置,意味着當前的全部桶都滿了,將從新分配一個新溢出桶用於插入動做。最後再在上一步申請的新插入位置,存儲鍵值對,返回該值的內存地址

第四階段:寫入

可是這裏又疑惑了?最後爲何是返回內存地址。這是由於隱藏的最後一步寫入動做(將值拷貝到指定內存區域)是經過底層彙編配合來完成的,在 runtime 中只完成了絕大部分的動做

func main() {
    m := make(map[int32]int32)
    m[0] = 6666666
}

對應的彙編部分:

...
0x0099 00153 (test.go:6)    CALL    runtime.mapassign_fast32(SB)
0x009e 00158 (test.go:6)    PCDATA    $2, $2
0x009e 00158 (test.go:6)    MOVQ    24(SP), AX
0x00a3 00163 (test.go:6)    PCDATA    $2, $0
0x00a3 00163 (test.go:6)    MOVL    $6666666, (AX)

這裏分爲了幾個部位,主要是調用 mapassign 函數和拿到值存放的內存地址,再將 6666666 這個值存放進該內存地址中。另外咱們看到 PCDATA 指令,主要是包含一些垃圾回收的信息,由編譯器產生

小結

經過前面幾個階段的分析,咱們可梳理出一些要點。例如:

  • 不一樣類型對應哈希函數不同
  • 高八位用於定位 bucket
  • 低八位用於定位 key,快速試錯後再進行完整對比
  • buckets/overflow buckets 遍歷
  • 可插入位的處理
  • 最終寫入動做與底層彙編的交互

擴容

在全部動做中,擴容規則是你們較關注的點,也是賦值裏很是重要的一環。所以我們將這節拉出來,對這塊細節進行研討

何時擴容

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

在特定條件的狀況下且當前沒有正在進行擴容動做(以判斷 hmap.oldbuckets != nil 爲基準)。哈希表在賦值、刪除的動做下會觸發擴容行爲,條件以下:

  • 觸發 load factor 的最大值,負載因子已達到當前界限
  • 溢出桶 overflow buckets 過多

何時受影響

那麼什麼狀況下會對這兩個 「值」 有影響呢?以下:

  1. 負載因子 load factor,用途是評估哈希表當前的時間複雜度,其與哈希表當前包含的鍵值對數、桶數量等相關。若是負載因子越大,則說明空間使用率越高,但產生哈希衝突的可能性更高。而負載因子越小,說明空間使用率低,產生哈希衝突的可能性更低
  2. 溢出桶 overflow buckets 的斷定與 buckets 總數和 overflow buckets 總數相關聯

因子關係

loadFactor %overflow bytes/entry hitprobe missprobe
4.00 2.13 20.77 3.00 4.00
4.50 4.05 17.30 3.25 4.50
5.00 6.85 14.77 3.50 5.00
5.50 10.55 12.94 3.75 5.50
6.00 15.27 11.67 4.00 6.00
6.50 20.90 10.79 4.25 6.50
7.00 27.14 10.15 4.50 7.00
  • loadFactor:負載因子
  • %overflow:溢出率,具備溢出桶 overflow buckets 的桶的百分比
  • bytes/entry:每一個鍵值對所的字節數開銷
  • hitprobe:查找存在的 key 時,平均須要檢索的條目數量
  • missprobe:查找不存在的 key 時,平均須要檢索的條目數量

這一組數據可以體現出不一樣的負載因子會給哈希表的動做帶來怎麼樣的影響。而在上一章節咱們有提到默認的負載因子是 6.5 (loadFactorNum/loadFactorDen),能夠看出來是通過測試後取出的一個比較合理的因子。可以較好的影響哈希表的擴容動做的時機

源碼剖析

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.oldbuckets = oldbuckets
    h.buckets = newbuckets
    h.nevacuate = 0
    h.noverflow = 0

    if h.extra != nil && h.extra.overflow != nil {
        if h.extra.oldoverflow != nil {
            throw("oldoverflow is not nil")
        }
        h.extra.oldoverflow = h.extra.overflow
        h.extra.overflow = nil
    }
    if nextOverflow != nil {
        if h.extra == nil {
            h.extra = new(mapextra)
        }
        h.extra.nextOverflow = nextOverflow
    }

    // the actual copying of the hash table data is done incrementally
    // by growWork() and evacuate().
}

第一階段:肯定擴容容量規則

在上小節有講到擴容的依據有兩種,在 hashGrow 開頭就進行了劃分。以下:

if !overLoadFactor(h.count+1, h.B) {
    bigger = 0
    h.flags |= sameSizeGrow
}

若不是負載因子 load factor 超過當前界限,也就是屬於溢出桶 overflow buckets 過多的狀況。所以本次擴容規則將是 sameSizeGrow,便是不改變大小的擴容動做。那要是前者的狀況呢?

bigger := uint8(1)
...
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

結合代碼分析可得出,如果負載因子 load factor 達到當前界限,將會動態擴容當前大小的兩倍做爲其新容量大小

第二階段:初始化、交換新舊 桶/溢出桶

主要是針對擴容的相關數據前置處理,涉及 buckets/oldbuckets、overflow/oldoverflow 之類與存儲相關的字段

...
oldbuckets := h.buckets
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)

flags := h.flags &^ (iterator | oldIterator)
if h.flags&iterator != 0 {
    flags |= oldIterator
}

h.B += bigger
...
h.noverflow = 0

if h.extra != nil && h.extra.overflow != nil {
    ...
    h.extra.oldoverflow = h.extra.overflow
    h.extra.overflow = nil
}
if nextOverflow != nil {
    ...
    h.extra.nextOverflow = nextOverflow
}

這裏注意到這段代碼: newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)。第一反應是擴容的時候就立刻申請並初始化內存了嗎?假設涉及大量的內存分配,那挺耗費性能的...

然而並不,內部只會先進行預分配,當使用的時候纔會真正的去初始化

第三階段:擴容

在源碼中,發現第三階段的流轉並無顯式展現。這是由於流轉由底層去作控制了。但經過分析代碼和註釋,可得知由第三階段涉及 growWorkevacuate 方法。以下:

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

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

在該方法中,主要是兩個 evacuate 函數的調用。他們在調用上又分別有什麼區別呢?以下:

  • evacuate(t, h, bucket&h.oldbucketmask()): 將 oldbucket 中的元素遷移 rehash 到擴容後的新 bucket
  • evacuate(t, h, h.nevacuate): 若是當前正在進行擴容,則再進行多一次遷移

另外,在執行擴容動做的時候,能夠發現都是以 bucket/oldbucket 爲單位的,而不是傳統的 buckets/oldbuckets。再結合代碼分析,可得知在 Go map 中擴容是採起增量擴容的方式,並不是一步到位

爲何是增量擴容?

若是是全量擴容的話,那問題就來了。假設當前 hmap 的容量比較大,直接全量擴容的話,就會致使擴容要花費大量的時間和內存,致使系統卡頓,最直觀的表現就是慢。顯然,不能這麼作

而增量擴容,就能夠解決這個問題。它經過每一次的 map 操做行爲去分攤總的一次性動做。所以有了 buckets/oldbuckets 的設計,它是逐步完成的,而且會在擴容完畢後才進行清空

小結

經過前面三個階段的分析,能夠得知擴容的大體過程。咱們階段性總結一下。主要以下:

  • 根據需擴容的緣由不一樣(overLoadFactor/tooManyOverflowBuckets),分爲兩類容量規則方向,爲等量擴容(不改變原有大小)或雙倍擴容
  • 新申請的擴容空間(newbuckets/newoverflow)都是預分配,等真正使用的時候纔會初始化
  • 擴容完畢後(預分配),不會立刻就進行遷移。而是採起增量擴容的方式,當有訪問到具體 bukcet 時,纔會逐漸的進行遷移(將 oldbucket 遷移到 bucket)

這時候又想到,既然遷移是逐步進行的。那若是在途中又要擴容了,怎麼辦?

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

在這裏注意到 goto again 語句,結合上下文可得若正在進行擴容,就會不斷地進行遷移。待遷移完畢後纔會開始進行下一次的擴容動做

遷移

在擴容的完整閉環中,包含着遷移的動做,又稱 「搬遷」。所以咱們繼續深刻研究 evacuate 函數。接下來一塊兒打開遷移世界的大門。以下:

type evacDst struct {
    b *bmap          
    i int            
    k unsafe.Pointer 
    v unsafe.Pointer 
}

evacDst 是遷移中的基礎數據結構,其包含以下字段:

  • b: 當前目標桶
  • i: 當前目標桶存儲的鍵值對數量
  • k: 指向當前 key 的內存地址
  • v: 指向當前 value 的內存地址
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
    b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
    newbit := h.noldbuckets()
    if !evacuated(b) {
        var xy [2]evacDst
        x := &xy[0]
        x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize)))
        x.k = add(unsafe.Pointer(x.b), dataOffset)
        x.v = add(x.k, bucketCnt*uintptr(t.keysize))

        if !h.sameSizeGrow() {
            y := &xy[1]
            y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize)))
            y.k = add(unsafe.Pointer(y.b), dataOffset)
            y.v = add(y.k, bucketCnt*uintptr(t.keysize))
        }

        for ; b != nil; b = b.overflow(t) {
            ...
        }

        if h.flags&oldIterator == 0 && t.bucket.kind&kindNoPointers == 0 {
            b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
            ptr := add(b, dataOffset)
            n := uintptr(t.bucketsize) - dataOffset
            memclrHasPointers(ptr, n)
        }
    }

    if oldbucket == h.nevacuate {
        advanceEvacuationMark(h, t, newbit)
    }
}
  • 計算並獲得 oldbucket 的 bmap 指針地址
  • 計算 hmap 在增加以前的桶數量
  • 判斷當前的遷移(搬遷)狀態,以便流轉後續的操做。若沒有正在進行遷移 !evacuated(b) ,則根據擴容的規則的不一樣,當規則爲等量擴容 sameSizeGrow 時,只使用一個 evacDst 桶用於分流。而爲雙倍擴容時,就會使用兩個 evacDst 進行分流操做
  • 當分流完畢後,須要遷移的數據都會經過 typedmemmove 函數遷移到指定的目標桶上
  • 若當前不存在 flags 使用標誌、使用 oldbucket 迭代器、bucket 不爲指針類型。則取消連接溢出桶、清除鍵值
  • 在最後 advanceEvacuationMark 函數中會對遷移進度 hmap.nevacuate 進行累積計數,並調用 bucketEvacuated 對舊桶 oldbuckets 進行不斷的遷移。直至所有遷移完畢。那麼也就表示擴容完畢了,會對 hmap.oldbucketsh.extra.oldoverflow 進行清空

總的來說,就是計算獲得所需數據的位置。再根據當前的遷移狀態、擴容規則進行數據分流遷移。結束後進行清理,促進 GC 的回收

總結

在本章節咱們主要研討了 Go map 的幾個核心動做,分別是:「賦值、擴容、遷移」 。而經過本次的閱讀,咱們可以更進一步的認識到一些要點,例如:

  • 賦值的時候會觸發擴容嗎?
  • 負載因子是什麼?太高會帶來什麼問題?它的變更會對哈希表操做帶來什麼影響嗎?
  • 溢出桶越多會帶來什麼問題?
  • 是否要擴容的基準條件是什麼?
  • 擴容的容量規則是怎麼樣的?
  • 擴容的步驟是怎麼樣的?涉及到了哪些數據結構?
  • 擴容是一次性擴容仍是增量擴容?
  • 正在擴容的時候又要擴容怎麼辦?
  • 擴容時的遷移分流動做是怎麼樣的?
  • 在擴容動做中,底層彙編承擔了什麼角色?作了什麼事?
  • 在 buckets/overflow buckets 中尋找時,是如何 「快速」 定位值的?低八位、高八位的用途?
  • 空槽有可能出如今任意位置嗎?假設已經沒有空槽了,可是又有新值要插入,底層會怎麼處理

最後但願你經過本文的閱讀,能更清楚地瞭解到 Go map 是怎麼樣運做的 :)

相關文章
相關標籤/搜索