Dig101:Go 之讀懂 map 的底層設計
檢視更多:歷史集錄
Dig101: dig more, simplified more and know more
在 golang 中,map
是一個不可或缺的存在。
它作為雜湊表,簡單易用,既能自動處理雜湊碰撞,又能自動擴容或重新記憶體整理,避免讀寫效能的下降。
這些都要歸功於其內部實現的精妙。本文嘗試去通過原始碼去分析一下其背後的故事。
我們不會過多在原始碼分析上展開,只結合程式碼示例對其背後設計實現上做些總結,希望可以簡單明瞭一些。
希望看完後,會讓你對 map 的理解有一些幫助。網上也有很多不錯的原始碼分析,會附到文末,感興趣的同學自行檢視下。
(本文分析基於 Mac 平臺上 go1.14beta1 版本。長文預警 ... )
我們先簡單過下 map 實現 hash 表所用的資料結構,這樣方便後邊討論。
0x01 map 的內部結構
在這裡我們先弄清楚 map 實現的整體結構
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
}
這裡有幾個欄位需要解釋一下:
- hmap.B
這個為啥用 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
即為擴容到二倍
- bmap.keys, bmap.elems
在桶裡儲存 k/v 的方式不是一個 k/v 一組, 而是 k 放一塊,v 放一塊。
這樣的相對 k/v 相鄰的好處是,方便記憶體對齊。比如map[int64]int8
, v 是int8
,放一塊就避免需要額外記憶體對齊。
另外對於大的 k/v 也做了優化。
正常情況 key 和 elem 直接使用使用者宣告的型別,但當其 size 大於 128(MAXKEYSIZE/MAXELEMSIZE
) 時,
則會轉為指標去儲存。(也就是indirectkey、indirectelem
)
- hmap.extra
這個額外記錄溢位桶意義在哪?
具體是為解決讓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
0x02 map 的 hash 方式
瞭解 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
(不可比較型別)的型別,如下:
- func,map,slice
- 內部元素有這三種型別的 array 和 struct 型別
0x03 map 的擴容方式
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
針對擴容的方式,有兩類,分別是:
- sameSizeGrow
過多的overflow
使用,使用等大小的 buckets 重新整理,回收多餘的overflow
桶,提高 map 讀寫效率,減少溢位桶佔用
這裡藉助hmap.noverflow
來判斷溢位桶是否過多
hmap.B<=15
時,判斷是溢位桶是否多於桶數1<<hmap.B
否則只判斷溢位桶是否多於 1<<15
這也就是為啥hmap.noverflow
,當其接近1<<15 - 1
時為近似值, 只要可以評估是否溢位桶過多不合理就行了
- biggerSizeGrow
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 和擴容,剩下的就是初始化、讀寫、刪除和遍歷了,我們就不詳細展開了,簡單過下。
0x04 map 的初始化
map 不初始化時為 nil,是不可以操作的。可以通過 make 方式初始化
// 不指定大小
s := make(map[int]int)
// 指定大小
b := make(map[int]int,10)
對於這兩種 map 內部呼叫方式是不一樣的
- small map
當不指定大小或者指定大小不大於 8 時,呼叫
func makemap_small() *hmap {
只需要直接在堆上初始化hmap
和 hash 種子(hash0
)就行。
- bigger map
當大小大於 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
桶)
0x05 map 的讀取
對於 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 型別的零值
0x06 map 的賦值
計算 key 的 hash,拿到對應的桶
如果此時處於擴容期間,則執行擴容growWork
對桶 bucket->overflow 連結串列遍歷
若有空桶 (對應 tophash[i] 為空),則準備在此空桶儲存 k/v
若非空,且和 tophash 相等,且 key 相等,則更新對應 elem
若無可用桶,則分配一個新的 overflow 桶來儲存 k/v, 會判斷是否需要擴容
最後若使用了空桶或新overflow
桶,則要將對應tophash
更新回去, 如果需要的話,也更新count
0x07 map 的刪除
獲取待刪除 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
}
...
}
0x08 map 的遍歷
先呼叫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 的內部實現我們就過完了。
裡邊有很多優化點,設計比較巧妙,簡單總結一下:
- 以 2 的對數儲存桶數,便於優化 hash 模運算定位桶,也利於擴容計算
- 每個 map 都隨機 hash 種子,減少雜湊碰撞的機率
- map 以 key 的型別確定 hash 函式,對不同型別針對性優化 hash 計算方式
- 桶內部 k/v 並列儲存,減少不必要的記憶體對齊浪費;對於大的 k/v 也會轉為指標,便於記憶體對齊和控制桶的整體大小
- 桶內增加 tophash 陣列加快單元定位,也方便單元回收(空桶)標記
- 當桶 8 個單元都滿了,還存在雜湊衝突的 k/v,則在桶裡增加 overflow 桶連結串列儲存
- 桶內若只有 overflow 桶連結串列是指標,則 overflow 型別轉為 uintptr,並使用 mapextra 引用該桶,避免桶的 gc 掃描又保證其 overflow 桶存活
- 寫操作增加新桶時如果需要擴容,只記錄提交,具體執行會分散到寫操作和刪除操作中漸進進行,將遷移成本打散
- 雜湊表的裝載因子不滿足要求是,擴容一倍,保證桶的裝載能力
- 雜湊表 overflow 桶過多,則記憶體重新整理,減少不必要的 overflow 桶,提升讀寫效率
- 對指定不同大小的 map 初始化,區別對待,不必要的桶預分配就避免;桶較多的情況下,也增加 overflow 桶的預分配
- 每次遍歷起始位置隨機,嚴格保證 map 無序語義
- 使用 flags 位標記檢測 map 的併發讀寫,發現時 panic,一定程度上預防資料不一致發生
趁熱打鐵,建議你再閱讀一遍原始碼,加深一下理解。
附上幾篇不錯的原始碼分析文章,程式碼對應的go
版本和本文不一致,但變化不大,可以對照著看。
本文程式碼見 NewbMiao/Dig101-Go
歡迎關注,不定期挖點技術
文章首發:公眾號 newbmiao
- 加微信實戰群請加微信(註明:實戰群):gocnio
相關文章
- Dig101:Go之讀懂map的底層設計Go
- Go語言map的底層實現Go
- 《Go 語言程式設計》讀書筆記(十一)底層程式設計Go程式設計筆記
- Go之底層利器-AST遍歷GoAST
- map底層的一些理解
- 一文徹底讀懂 hystrix-go 原始碼Go原始碼
- 一文讀懂 Go sync.Cond 設計Go
- RocketMQ高效能之底層儲存設計MQ
- Dig101: Go之靈活的sliceGo
- Dig101: Go 之靈活的 sliceGo
- Java併發程式設計序列之JUC底層AQSJava程式設計AQS
- 圖解Go的channel底層原理圖解Go
- Go 併發讀寫 sync.map 的強大之處Go
- Dig101:Go之string那些事Go
- Java併發程式設計序列之JUC底層AQS(二)Java程式設計AQS
- 一文讓你讀懂Synchronized底層實現,秒殺面試官synchronized面試
- 一文讀懂Redis常見物件型別的底層資料結構Redis物件型別資料結構
- 讀懂Java中的Socket程式設計Java程式設計
- 分享swoole/go底層內容Go
- 五、GO程式設計模式:MAP-REDUCEGo程式設計設計模式
- GO程式設計模式05:MAP-REDUCEGo程式設計設計模式
- 探索雲端計算容器底層之Cgroup
- Dig101: Go之for-range排坑指南Go
- 這一次,徹底讀懂Mysql執行計劃MySql
- Apache Druid底層儲存設計ApacheUI
- Go語言interface底層實現Go
- Go 基礎篇之 MapGo
- Dig101:Go 之聊聊 struct 的記憶體對齊GoStruct記憶體
- 讀資料工程之道:設計和構建健壯的資料系統05底層設計(上)
- 讀資料工程之道:設計和構建健壯的資料系統06底層設計(下)
- 讀懂「 唱吧 KTVHTTPCache 」設計思想HTTPPCA
- iOS 底層探索之RunloopiOSOOP
- 深入解析 Go 中 Slice 底層實現Go
- 【原創】探索雲端計算容器底層之Cgroup
- iOS底層原理 MVC、MVP、MVVM、分層設計淺談 — (13)iOSMVCMVPMVVM
- 底層原理:垃圾回收演算法是如何設計的演算法
- 讀懂框架設計的靈魂—Java反射機制框架Java反射
- 人人都能讀懂的設計模式(1):建立型模式設計模式