在 上一章節 中,數據結構小節裏講解了大量基礎字段,可能你會疑惑須要 #&(!……#(!¥! 來幹嗎?接下來咱們一塊兒簡單瞭解一下基礎概念。再開始研討今天文章的重點內容。我相信這樣你能更好的讀懂這篇文章html
原文地址:深刻理解 Go map:賦值和擴容遷移golang
哈希函數,又稱散列算法、散列函數。主要做用是經過特定算法將數據根據必定規則組合從新生成獲得一個散列值算法
而在哈希表中,其生成的散列值經常使用於尋找其鍵映射到哪個桶上。而一個好的哈希函數,應當儘可能少的出現哈希衝突,以此保證操做哈希表的時間複雜度(可是哈希衝突在目前來說,是沒法避免的。咱們須要 「解決」 它)segmentfault
在哈希操做中,至關核心的一個處理動做就是 「哈希衝突」 的解決。而在 Go map 中採用的就是 "鏈地址法 " 去解決哈希衝突,又稱 "拉鍊法"。其主要作法是數組 + 鏈表的數據結構,其溢出節點的存儲內存都是動態申請的,所以相對更靈活。而每個元素都是一個鏈表。以下圖:數組
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) } ... }
alg.hash
有可能出現 panic
致使異常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.tophash
與 top(高八位)是否一致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
指令,主要是包含一些垃圾回收的信息,由編譯器產生
經過前面幾個階段的分析,咱們可梳理出一些要點。例如:
在全部動做中,擴容規則是你們較關注的點,也是賦值裏很是重要的一環。所以我們將這節拉出來,對這塊細節進行研討
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
過多那麼什麼狀況下會對這兩個 「值」 有影響呢?以下:
load factor
,用途是評估哈希表當前的時間複雜度,其與哈希表當前包含的鍵值對數、桶數量等相關。若是負載因子越大,則說明空間使用率越高,但產生哈希衝突的可能性更高。而負載因子越小,說明空間使用率低,產生哈希衝突的可能性更低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 |
overflow buckets
的桶的百分比這一組數據可以體現出不一樣的負載因子會給哈希表的動做帶來怎麼樣的影響。而在上一章節咱們有提到默認的負載因子是 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)
。第一反應是擴容的時候就立刻申請並初始化內存了嗎?假設涉及大量的內存分配,那挺耗費性能的...
然而並不,內部只會先進行預分配,當使用的時候纔會真正的去初始化
在源碼中,發現第三階段的流轉並無顯式展現。這是由於流轉由底層去作控制了。但經過分析代碼和註釋,可得知由第三階段涉及 growWork
和 evacuate
方法。以下:
func growWork(t *maptype, h *hmap, bucket uintptr) { evacuate(t, h, bucket&h.oldbucketmask()) if h.growing() { evacuate(t, h, h.nevacuate) } }
在該方法中,主要是兩個 evacuate
函數的調用。他們在調用上又分別有什麼區別呢?以下:
另外,在執行擴容動做的時候,能夠發現都是以 bucket/oldbucket 爲單位的,而不是傳統的 buckets/oldbuckets。再結合代碼分析,可得知在 Go map 中擴容是採起增量擴容的方式,並不是一步到位
若是是全量擴容的話,那問題就來了。假設當前 hmap 的容量比較大,直接全量擴容的話,就會致使擴容要花費大量的時間和內存,致使系統卡頓,最直觀的表現就是慢。顯然,不能這麼作
而增量擴容,就能夠解決這個問題。它經過每一次的 map 操做行爲去分攤總的一次性動做。所以有了 buckets/oldbuckets 的設計,它是逐步完成的,而且會在擴容完畢後才進行清空
經過前面三個階段的分析,能夠得知擴容的大體過程。咱們階段性總結一下。主要以下:
這時候又想到,既然遷移是逐步進行的。那若是在途中又要擴容了,怎麼辦?
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
是遷移中的基礎數據結構,其包含以下字段:
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) } }
!evacuated(b)
,則根據擴容的規則的不一樣,當規則爲等量擴容 sameSizeGrow
時,只使用一個 evacDst
桶用於分流。而爲雙倍擴容時,就會使用兩個 evacDst
進行分流操做typedmemmove
函數遷移到指定的目標桶上advanceEvacuationMark
函數中會對遷移進度 hmap.nevacuate
進行累積計數,並調用 bucketEvacuated
對舊桶 oldbuckets 進行不斷的遷移。直至所有遷移完畢。那麼也就表示擴容完畢了,會對 hmap.oldbuckets
和 h.extra.oldoverflow
進行清空總的來說,就是計算獲得所需數據的位置。再根據當前的遷移狀態、擴容規則進行數據分流遷移。結束後進行清理,促進 GC 的回收
在本章節咱們主要研討了 Go map 的幾個核心動做,分別是:「賦值、擴容、遷移」 。而經過本次的閱讀,咱們可以更進一步的認識到一些要點,例如:
最後但願你經過本文的閱讀,能更清楚地瞭解到 Go map 是怎麼樣運做的 :)