Dig101: dig more, simplified more and know more
在golang中,map
是一個不可或缺的存在。html
它做爲哈希表,簡單易用,既能自動處理哈希碰撞,又能自動擴容或從新內存整理,避免讀寫性能的降低。android
這些都要歸功於其內部實現的精妙。本文嘗試去經過源碼去分析一下其背後的故事。git
咱們不會過多在源碼分析上展開,只結合代碼示例對其背後設計實現上作些總結,但願能夠簡單明瞭一些。github
但願看完後,會讓你對 map 的理解有一些幫助。網上也有不少不錯的源碼分析,會附到文末,感興趣的同窗自行查看下。golang
(本文分析基於 Mac 平臺上go1.14beta1版本。長文預警 ... )segmentfault
<!--more-->api
咱們先簡單過下map實現hash表所用的數據結構,這樣方便後邊討論。數組
<!-- 文章目錄 -->
<!-- [[TOC]] -->安全
在這裏咱們先弄清楚map實現的總體結構session
map本質是hash表(hmap
),指向一堆桶(buckets
)用來承接數據,每一個桶(bmap
)能存8組k/v。
當有數據讀寫時,會用key
的hash找到對應的桶。
爲加速hash定位桶,bmap
裏記錄了tophash
數組(hash的高8位)
hash表就會有哈希衝突的問題(不一樣key的hash值同樣,即hash後都指向同一個桶),爲此map使用桶後鏈一個溢出桶(overflow
)鏈表來解決當桶8個單元都滿了,但還有數據須要存入此桶的問題。
剩下noverflow,oldbuckets,nevacuate,oldoverflow
會用於擴容,暫時先不展開
具體對應的數據結構詳細註釋以下:
(雖然多,先大體過一遍,後邊遇到會在提到)
// runtime/map.go // A header for a Go map. type hmap struct { //用於len(map) count int //標誌位 // iterator = 1 // 可能有遍歷用buckets // oldIterator = 2 // 可能有遍歷用oldbuckets,用於擴容期間 // hashWriting = 4 // 標記寫,用於併發讀寫檢測 // sameSizeGrow = 8 // 用於等大小buckets擴容,減小overflow桶 flags uint8 // 表明能夠最多容納loadFactor * 2^B個元素(loadFactor=6.5) B uint8 // overflow桶的計數,當其接近1<<15 - 1時爲近似值 noverflow uint16 // 隨機的hash種子,每一個map不同,減小哈希碰撞的概率 hash0 uint32 // 當前桶,長度爲(0-2^B) buckets unsafe.Pointer // 若是存在擴容會有擴容前的桶 oldbuckets unsafe.Pointer // 遷移數,標識小於其的buckets已遷移完畢 nevacuate uintptr // 額外記錄overflow桶信息,不必定每一個map都有 extra *mapextra } // 額外記錄overflow桶信息 type mapextra struct { overflow *[]*bmap oldoverflow *[]*bmap // 指向下一個可用overflow桶 nextOverflow *bmap } const( // 每一個桶8個k/v單元 BUCKETSIZE = 8 // k或v類型大小大於128轉爲指針存儲 MAXKEYSIZE = 128 MAXELEMSIZE = 128 ) // 桶結構 (字段會根據key和elem類型動態生成,見下邊bmap) type bmap struct { // 記錄桶內8個單元的高8位hash值,或標記空桶狀態,用於快速定位key // emptyRest = 0 // 此單元爲空,且更高索引的單元也爲空 // emptyOne = 1 // 此單元爲空 // evacuatedX = 2 // 用於表示擴容遷移到新桶前半段區間 // evacuatedY = 3 // 用於表示擴容遷移到新桶後半段區間 // evacuatedEmpty = 4 // 用於表示此單元已遷移 // minTopHash = 5 // 最小的空桶標記值,小於其則是空桶標誌 tophash [bucketCnt]uint8 } // cmd/compile/internal/gc/reflect.go // func bmap(t *types.Type) *types.Type { // 每一個桶內k/v單元數是8 type bmap struct{ topbits [8]uint8 //tophash keys [8]keytype elems [8]elemtype // overflow 桶 // otyp 類型爲指針*Type, // 若keytype及elemtype不含指針,則爲uintptr // 使bmap總體不含指針,避免gc去scan此類map overflow otyp }
這裏有幾個字段須要解釋一下:
這個爲啥用2的對數來表示桶的數目呢?
這裏是爲了hash定位桶及擴容方便
比方說,hash%n
能夠定位桶, 但%
操做沒有位運算快。
而利用 n=2^B,則hash%n=hash&(n-1)
則可優化定位方式爲: hash&(1<<B-1)
, (1<<B-1)
即源碼中BucketMask
再比方擴容,hmap.B=hmap.B+1
即爲擴容到二倍
在桶裏存儲k/v的方式不是一個k/v一組, 而是k放一塊,v放一塊。
這樣的相對k/v相鄰的好處是,方便內存對齊。好比map[int64]int8
, v是int8
,放一塊就避免須要額外內存對齊。
另外對於大的k/v也作了優化。
正常狀況key和elem直接使用用戶聲明的類型,但當其size大於128(MAXKEYSIZE/MAXELEMSIZE
)時,
則會轉爲指針去存儲。(也就是indirectkey、indirectelem
)
這個額外記錄溢出桶意義在哪?
具體是爲解決讓gc
不須要掃描此類bucket
。
只要bmap內不含指針就不需gc掃描。
當map
的key
和elem
類型都不包含指針時,但其中的overflow
是指針。
此時bmap的生成函數會將overflow
的類型轉化爲uintptr
。
而uintptr
雖然是地址,但不會被gc
認爲是指針,指向的數據有被回收的風險。
此時爲保證其中的overflow
指針指向的數據存活,就用mapextra
結構指向了這些buckets
,這樣bmap有被引用就不會被回收了。
關於uintptr可能被回收的例子,能夠看下 go101 - Type-Unsafe Pointers 中 Some Facts in Go We Should Know
瞭解map的基本結構後,咱們經過下邊代碼分析下map的hash
var m = map[interface{}]int{} var i interface{} = []int{} //panic: runtime error: hash of unhashable type []int println(m[i]) //panic: runtime error: hash of unhashable type []int delete(m, i)
爲何不能夠用[]int
做爲key呢?
查找源碼中hash的調用鏈註釋以下:
// runtime/map.go // mapassign,mapaccess1中 獲取key的hash hash := t.hasher(key, uintptr(h.hash0)) // cmd/compile/internal/gc/reflect.go func dtypesym(t *types.Type) *obj.LSym { switch t.Etype { // ../../../../runtime/type.go:/mapType case TMAP: ... // 依據key構建hash函數 hasher := genhash(t.Key()) ... } } // cmd/compile/internal/gc/alg.go func genhash(t *types.Type) *obj.LSym { switch algtype(t) { ... //具體針對interface調用interhash case AINTER: return sysClosure("interhash") ... } } // runtime/alg.go func interhash(p unsafe.Pointer, h uintptr) uintptr { //獲取interface p的實際類型t,此處爲slice a := (*iface)(p) tab := a.tab t := tab._type // slice類型不可比較,沒有equal函數 if t.equal == nil { panic(errorString("hash of unhashable type " + t.string())) } ... }
如上,咱們會發現map的hash函數並不惟一。
它會對不一樣key類型選取不一樣的hash方式,以此加快hash效率
這個例子slice
不可比較,因此不能做爲key。
也對,不可比較的類型做爲key的話,找到桶但無法比較key是否相等,那map用這個key讀寫都會是個問題。
還有哪些不可比較?
cmd/compile/internal/gc/alg.go
的 algtype1
函數中能夠找到返回ANOEQ
(不可比較類型)的類型,以下:
map
不能夠對其值取地址;
若是值類型爲slice
或struct
,不能直接操做其內部元素
咱們用代碼驗證以下:
m0 := map[int]int{} // ❎ cannot take the address of m0[0] _ = &m0[0] m := make(map[int][2]int) // ✅ m[0] = [2]int{1, 0} // ❎ cannot assign to m[0][0] m[0][0] = 1 // ❎ cannot take the address of m[0] _ = &m[0] type T struct{ v int } ms := make(map[int]T) // ✅ ms[0] = T{v: 1} // ❎ cannot assign to struct field ms[0].v in map ms[0].v = 1 // ❎ cannot take the address of ms[0] _ = &ms[0] }
爲何呢?
這是由於map
內部有漸進式擴容,因此map
的值地址不固定,取地址沒有意義。
也所以,對於值類型爲slice
和struct
, 只有把他們各自當作總體去賦值操做纔是安全的。 go有個issue討論過這個問題:issues-3117
針對擴容的方式,有兩類,分別是:
過多的overflow
使用,使用等大小的buckets從新整理,回收多餘的overflow
桶,提升map讀寫效率,減小溢出桶佔用
這裏藉助hmap.noverflow
來判斷溢出桶是否過多
hmap.B<=15
時,判斷是溢出桶是否多於桶數1<<hmap.B
不然只判斷溢出桶是否多於 1<<15
這也就是爲啥hmap.noverflow
,當其接近1<<15 - 1
時爲近似值, 只要能夠評估是否溢出桶過多不合理就好了
count/size > 6.5
(裝載因子 :overLoadFactor
), 避免讀寫效率下降。
擴容一倍,並漸進的在賦值和刪除(mapassign和mapdelete
)期間,
對每一個桶從新分流到x
(原來桶區間)和y
(擴容後的增長的新桶區間)
這裏overLoadFactor
(count/size)是評估桶的平均裝載數據能力,即map平均每一個桶裝載多少個k/v。
這個值太大,則桶不夠用,會有太多溢出桶;過小,則分配了太多桶,浪費了空間。
6.5是測試後對map裝載能力最大化的一個的選擇。
源碼中擴容代碼註釋以下:
// mapassign 中建立新bucket時檢測是否須要擴容 if !h.growing() && //非擴容中 (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { // 提交擴容,生成新桶,記錄舊桶相關。但不開始 // 具體開始是後續賦值和刪除期間漸進進行 hashGrow(t, h) } //mapassign 或 mapdelete中 漸進擴容 bucket := hash & bucketMask(h.B) if h.growing() { growWork(t, h, bucket) } // 具體遷移工做執行,每次最多兩個桶 func growWork(t *maptype, h *hmap, bucket uintptr) { // 遷移對應舊桶 // 若無迭代器遍歷舊桶,可釋放對應的overflow桶或k/v // 所有遷移完則釋放整個舊桶 evacuate(t, h, bucket&h.oldbucketmask()) // 若是還有舊桶待遷移,再遷移一個 if h.growing() { evacuate(t, h, h.nevacuate) } }
具體擴容evacuate
(遷移)時,判斷是否要將舊桶遷移到新桶後半區間(y
)有段代碼比較有趣, 註釋以下:
newbit := h.noldbuckets() var useY uint8 if !h.sameSizeGrow() { // 獲取hash hash := t.hasher(k2, uintptr(h.hash0)) if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) { // 這裏 key != key 是指key爲NaNs, // 此時 useY = top & 1 意味着有50%的概率到新桶區間 useY = top & 1 top = tophash(hash) } else { if hash&newbit != 0 { // 舉例來看 若擴容前h.B=3時, newbit=1<<3 // hash&newbit != 0 則hash形如 xxx1xxx // 新hmap的BucketMask= 1<<4 - 1 (1111: 15) // 則 hash&新BucketMask > 原BucketMask 1<<3-1 (111: 7) // 因此去新桶區間 useY = 1 } } } // 補充一個 key != key 的代碼示例 n1, n2 := math.NaN(), math.NaN() m := map[float64]int{} m[n1], m[n2] = 1, 2 println(n1 == n2, m[n1], m[n2]) // output: false 0 0 // 因此NaN作key沒有意義。。。
弄清楚map的結構、hash和擴容,剩下的就是初始化、讀寫、刪除和遍歷了,咱們就不詳細展開了,簡單過下。
map不初始化時爲nil,是不能夠操做的。能夠經過make方式初始化
// 不指定大小 s := make(map[int]int) // 指定大小 b := make(map[int]int,10)
對於這兩種map內部調用方式是不同的
當不指定大小或者指定大小不大於8時,調用
func makemap_small() *hmap {
只須要直接在堆上初始化hmap
和hash種子(hash0
)就行。
當大小大於8, 調用
func makemap(t *maptype, hint int, h *hmap) *hmap {
hint
溢出則置0
初始化hmap
和hash種子
根據overLoadFactor:6.5
的要求, 循環增長h.B
, 獲取 hint/(1<<h.B)
最接近 6.5的h.B
預分配hashtable的bucket數組
h.B
大於4的話,多分配至少1<<(h.B-4)
(須要內存對齊)個bucket,用於可能的overflow
桶使用,
並將 h.nextOverflow
設置爲第一個可用的overflow
桶。
最後一個overflow
桶指向h.buckets
(方便後續判斷已無overflow
桶)
對於map的讀取有着三個函數,主要區別是返回參數不一樣
mapaccess1: m[k] mapaccess2: a,b = m[i] mapaccessk: 在map遍歷時若grow已發生,key可能有更新,需用此函數從新獲取k/v
計算key的hash,定位當前buckets裏桶位置
若是當前處於擴容中,也嘗試去舊桶取對應的桶,需考慮擴容前bucket大小是否爲如今一半,且其所指向的桶未遷移
而後就是按照bucket->overflow鏈表的順序去遍歷,直至找到tophash
匹配且key相等的記錄(entry)
期間,若是key或者elem是轉過指針(size大於128),需轉回對應值。
map爲空或無值返回elem類型的零值
計算key的hash,拿到對應的桶
若是此時處於擴容期間,則執行擴容growWork
對桶bucket->overflow鏈表遍歷
最後若使用了空桶或新overflow
桶,則要將對應tophash
更新回去, 若是須要的話,也更新count
獲取待刪除key對應的桶,方式和mapassign的查找方式基本同樣,找到則清除k/v。
這裏還有個額外操做:
若是當前tophash狀態是:當前cell爲空(emptyOne
),
若其後桶或其後的overflow桶狀態爲:當前cell爲空前索引高於此cell的也爲空(emptyRest
),則將當前狀態也更新爲emptyRest
倒着依次往前如此處理,實現 emptyOne -> emptyRest
的轉化
這樣有什麼好處呢?
答案是爲了方便讀寫刪除(mapaccess,mapassign,mapdelete
)時作桶遍歷(bucketLoop
)能減小沒必要要的空bucket遍歷
截取代碼以下:
bucketloop: for ; b != nil; b = b.overflow(t) { for i := uintptr(0); i < bucketCnt; i++ { if b.tophash[i] != top { // 減小空cell的遍歷 if b.tophash[i] == emptyRest { break bucketloop } continue } ... }
先調用mapiterinit
初始化用於遍歷的 hiter
結構體, 這裏會用隨機定位出一個起始遍歷的桶hiter.startBucket
, 這也就是爲啥map遍歷無序。
隨機獲取起始桶的代碼以下:
r := uintptr(fastrand()) // 隨機數不夠用得再加一個32位 if h.B > 31-bucketCntBits { r += uintptr(fastrand()) << 31 } it.startBucket = r & bucketMask(h.B)
在調用mapiternext
去實現遍歷, 遍歷中若是處於擴容期間,若是當前桶已經遷移了,那麼就指向新桶,沒有遷移就指向舊桶
至此,map的內部實現咱們就過完了。
裏邊有不少優化點,設計比較巧妙,簡單總結一下:
趁熱打鐵,建議你再閱讀一遍源碼,加深一下理解。
附上幾篇不錯的源碼分析文章,代碼對應的go
版本和本文不一致,但變化不大,能夠對照着看。
<!-- 若有問題歡迎關注留言交流。
本文代碼見 NewbMiao/Dig101-Go
文章首發公衆號: newbmiao (歡迎關注,獲取及時更新內容)推薦閱讀:Dig101-Go系列