在開發過程中,map是必不可少的數據結構,在Golang中,使用map或多或少會遇到與其他語言不一樣的體驗,比如訪問不存在的元素會返回其類型的空值、map的大小究竟是多少,為什么會報"cannot take the address of"錯誤,遍歷map的隨機性等等。
本文希望通過研究map的底層實現,以解答這些疑惑。
基於Golang 1.8.3
1. 數據結構及內存管理
hashmap的定義位於 src/runtime/hashmap.go 中,首先我們看下hashmap和bucket的定義:
type hmap struct { count int // 元素的個數 flags uint8 // 狀態標志 B uint8 // 可以最多容納 6.5 * 2 ^ B 個元素,6.5為裝載因子 noverflow uint16 // 溢出的個數 hash0 uint32 // 哈希種子 buckets unsafe.Pointer // 桶的地址 oldbuckets unsafe.Pointer // 舊桶的地址,用於擴容 nevacuate uintptr // 搬遷進度,小於nevacuate的已經搬遷 overflow *[2]*[]*bmap }
其中,overflow是一個指針,指向一個元素個數為2的數組,數組的類型是一個指針,指向一個slice,slice的元素是桶(bmap)的地址,這些桶都是溢出桶;為什么有兩個?因為Go map在hash沖突過多時,會發生擴容操作,為了不全量搬遷數據,使用了增量搬遷,[0]表示當前使用的溢出桶集合,[1]是在發生擴容時,保存了舊的溢出桶集合;overflow存在的意義在於防止溢出桶被gc。
// A bucket for a Go map. type bmap struct { // 每個元素hash值的高8位,如果tophash[0] < minTopHash,表示這個桶的搬遷狀態 tophash [bucketCnt]uint8 // 接下來是8個key、8個value,但是我們不能直接看到;為了優化對齊,go采用了key放在一起,value放在一起的存儲方式, // 再接下來是hash沖突發生時,下一個溢出桶的地址 }
tophash的存在是為了快速試錯,畢竟只有8位,比較起來會快一點。
從定義可以看出,不同於STL中map以紅黑樹實現的方式,Golang采用了HashTable的實現,解決沖突采用的是鏈地址法。也就是說,使用數組+鏈表來實現map。特別的,對於一個key,幾個比較重要的計算公式為:
key | hash | hashtop | bucket index |
---|---|---|---|
key | hash := alg.hash(key, uintptr(h.hash0)) | top := uint8(hash >> (sys.PtrSize*8 - 8)) | bucket := hash & (uintptr(1)<<h.B - 1),即 hash % 2^B |
例如,對於B = 3,當hash(key) = 4時, hashtop = 0, bucket = 4,當hash(key) = 20時,hashtop = 0, bucket = 4;這個例子我們在搬遷過程還會用到。
內存布局類似於這樣:

2. 創建 - makemap
map的創建比較簡單,在參數校驗之后,需要找到合適的B來申請桶的內存空間,接着便是穿件hmap這個結構,以及對它的初始化。

3. 訪問 - mapaccess
對於給定的一個key,可以通過下面的操作找到它是否存在

方法定義為
// returns key, if not find, returns nil func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer // returns key and exist. if not find, returns nil, false func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) // returns both key and value. if not find, returns nil, nil func mapaccessK(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer)
可見在找不到對應key的情況下,會返回nil
4. 分配 - mapassign
為一個key分配空間的邏輯,大致與查找類似;但增加了寫保護和擴容的操作;注意,分配過程和刪除過程都沒有在oldbuckets中查找,這是因為首先要進行擴容判斷和操作;如下:

擴容是整個hashmap的核心算法,我們放在第6部分重點研究。
新建一個溢出桶,並將其拼接在當前桶的尾部,實現了類似鏈表的操作:
// 獲取當前桶的溢出桶 func (b *bmap) overflow(t *maptype) *bmap { return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize)) } // 設置當前桶的溢出桶 func (h *hmap) setoverflow(t *maptype, b, ovf *bmap) { h.incrnoverflow() if t.bucket.kind&kindNoPointers != 0 { h.createOverflow() //重點,這里講溢出桶append到overflow[0]的后面 *h.overflow[0] = append(*h.overflow[0], ovf) } *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize)) = ovf }
5. 刪除 - mapdelete
刪除某個key的操作與分配類似,由於hashmap的存儲結構是數組+鏈表,所以真正刪除key僅僅是將對應的slot設置為empty,並沒有減少內存;如下:

6. 擴容 - growWork
首先,判斷是否需要擴容的邏輯是
func (h *hmap) growing() bool { return h.oldbuckets != nil }
何時h.oldbuckets不為nil呢?在分配assign邏輯中,當沒有位置給key使用,而且滿足測試條件(裝載因子>6.5或有太多溢出通)時,會觸發hashGrow邏輯:
func hashGrow(t *maptype, h *hmap) { //判斷是否需要sameSizeGrow,否則"真"擴 bigger := uint8(1) if !overLoadFactor(int64(h.count), h.B) { bigger = 0 h.flags |= sameSizeGrow } // 下面將buckets復制給oldbuckets oldbuckets := h.buckets newbuckets := newarray(t.bucket, 1<<(h.B+bigger)) flags := h.flags &^ (iterator | oldIterator) if h.flags&iterator != 0 { flags |= oldIterator } // 更新hmap的變量 h.B += bigger h.flags = flags h.oldbuckets = oldbuckets h.buckets = newbuckets h.nevacuate = 0 h.noverflow = 0 // 設置溢出桶 if h.overflow != nil { if h.overflow[1] != nil { throw("overflow is not nil") } // 交換溢出桶 h.overflow[1] = h.overflow[0] h.overflow[0] = nil } }
OK,下面正式進入重點,擴容階段;在assign和delete操作中,都會觸發擴容growWork:
func growWork(t *maptype, h *hmap, bucket uintptr) { // 搬遷舊桶,這樣assign和delete都直接在新桶集合中進行 evacuate(t, h, bucket&h.oldbucketmask()) //再搬遷一次搬遷過程中的桶 if h.growing() { evacuate(t, h, h.nevacuate) } }
6.1 搬遷過程
一般來說,新桶數組大小是原來的2倍(在!sameSizeGrow()條件下),新桶數組前半段可以"類比"為舊桶,對於一個key,搬遷后落入哪一個索引中呢?
假設舊桶數組大小為2^B, 新桶數組大小為2*2^B,對於某個hash值X
若 X & (2^B) == 0,說明 X < 2^B,那么它將落入與舊桶集合相同的索引xi中;
否則,它將落入xi + 2^B中。
例如,對於舊B = 3時,hash1 = 4,hash2 = 20,其搬遷結果類似這樣。

源碼中有些變量的命名比較簡單,容易擾亂思路,我們注明一下便於理解。
變量 | 釋義 |
---|---|
x *bmap | 桶x表示與在舊桶時相同的位置,即位於新桶前半段 |
y *bmap | 桶y表示與在舊桶時相同的位置+舊桶數組大小,即位於新桶后半段 |
xi int | 桶x的slot索引 |
yi int | 桶y的slot索引 |
xk unsafe.Pointer | 索引xi對應的key地址 |
yk unsafe.Pointer | 索引yi對應的key地址 |
xv unsafe.Pointer | 索引xi對應的value地址 |
yv unsafe.Pointer | 索引yi對應的value地址 |
搬遷過程如下:

6.2 擴容
和 slice 一樣,在 map 的元素持續增長時,每個bucket極端情況下會有很多overflow,退化成鏈表,需要 rehash。一般擴容是在 h.count > loadFactor(2^B)
。 負載因子一般是:容量 / bucket數量,golang 的負載因子 loadFactorNum / loadFactorDen = 6.5,為什么不選擇1呢,像 Redis 的 dictentry,只能保存一組鍵值對,golang的話,一個bucket正常情況下可以保存8組鍵值對; 那為什么選擇6.5這個值呢,作者給出了一組數據。
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 |
7.50 | 34.03 | 9.73 | 4.75 | 7.50 |
8.00 | 41.10 | 9.40 | 5.00 | 8.00 |
loadFactor:負載因子;
%overflow:溢出率,有溢出 bucket 的占比;
bytes/entry:每個 key/value 對占用字節比;
hitprobe:找到一個存在的key平均查找個數;
missprobe:找到一個不存在的key平均查找個數;
通常在負載因子 > 6.5時,就是平均每個bucket存儲的鍵值對 超過6.5個或者是overflow的數量 > 2 ^ 15時會發生擴容(遷移)。它分為兩種情況:
第一種:由於map在不斷的insert 和 delete 中,bucket中的鍵值存儲不夠均勻,內存利用率很低,需要進行遷移。(注:bucket數量不做增加)
第二種:真正的,因為負載因子過大引起的擴容,bucket 增加為原 bucket 的兩倍
不論上述哪一種 rehash,都是調用 hashGrow
方法:
- 定義原 hmap 中指向 buckets 數組的指針
- 創建 bucket 數組並設置為 hmap 的 bucket 字段
- 將 extra 中的 oldoverflow 指向 overflow,overflow 指向 nil
- 如果正在 growing 的話,開始漸進式的遷移,在
growWork
方法里是 bucket 中 key/value 的遷移 - 在全部遷移完成后,釋放內存
7.建議
做兩組試驗,第一組是:提前分配好 map 的總容量后追加k/v;另一組是:初始化 0 容量的 map 后做追加
package main import "testing" var count int = 100_000 func addition(m map[int]int) map[int]int { for i := 0; i < count; i++ { m[i] = i } return m } func BenchmarkGrows(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { m := make(map[int]int) addition(m) } } func BenchmarkNoGrows(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { m := make(map[int]int, count) addition(m) } }
sh: go test -bench=. -run=none map_grow_test.go
goos: darwin
goarch: amd64
BenchmarkGrows-4 80 15825209 ns/op
BenchmarkNoGrows-4 160 7235485 ns/op
PASS
ok command-line-arguments 3.944s
提前定義容量的case平均執行時間比未定義容量的快了100% --- 擴容時的數據拷貝和重新哈希成本很高!
再看看內存的分配次數:
sh: go test -bench=. -benchmem -run=none map_grow_test.go
goos: darwin
goarch: amd64
BenchmarkGrows-4 98 11200304 ns/op 5766531 B/op 4004 allocs/op
BenchmarkNoGrows-4 172 9005691 ns/op 2829246 B/op 1679 allocs/op
PASS
ok command-line-arguments 3.366s
提前定義容量的case的內存操作次數要少1倍多。
兩個方法執行相同的次數,GC的次數也會多出一倍
package main var count int = 100_000 func addition(m map[int]int) map[int]int { for i := 0; i < count; i++ { m[i] = i } return m } func main() { for i := 0; i < 4; i++ { println("round ",i ) n := make(map[int]int, count) addition(n) println("0 size map\n") m := make(map[int]int) addition(m) } }
go build -o growth map_grow.go && GODEBUG=gctrace=1 ./growth round 0 0 size map gc 1 @0.009s 0%: 0.008+0.11+0.014 ms clock, 0.035+0.041/0.012/0.11+0.056 ms cpu, 4->4->0 MB, 5 MB goal, 4 P scvg: 0 MB released scvg: inuse: 3, idle: 59, sys: 63, released: 59, consumed: 4 (MB) scvg: 0 MB released scvg: inuse: 2, idle: 61, sys: 63, released: 59, consumed: 4 (MB) scvg: inuse: 5, idle: 58, sys: 63, released: 58, consumed: 5 (MB) scvg: inuse: 5, idle: 58, sys: 63, released: 58, consumed: 5 (MB) scvg: inuse: 5, idle: 58, sys: 63, released: 58, consumed: 5 (MB) scvg: inuse: 5, idle: 58, sys: 63, released: 58, consumed: 5 (MB) gc 2 @0.014s 0%: 0.002+0.16+0.026 ms clock, 0.009+0.051/0.009/0.093+0.10 ms cpu, 5->5->3 MB, 6 MB goal, 4 P round 1 0 size map gc 3 @0.028s 0%: 0.002+0.10+0.017 ms clock, 0.011+0/0.015/0.10+0.070 ms cpu, 7->7->0 MB, 8 MB goal, 4 P scvg: 0 MB released scvg: inuse: 7, idle: 56, sys: 63, released: 55, consumed: 7 (MB) scvg: 0 MB released scvg: inuse: 2, idle: 61, sys: 63, released: 55, consumed: 7 (MB) scvg: 0 MB released scvg: inuse: 2, idle: 60, sys: 63, released: 55, consumed: 7 (MB) gc 4 @0.033s 0%: 0.002+0.18+0.011 ms clock, 0.011+0.074/0.011/0.15+0.046 ms cpu, 5->5->3 MB, 6 MB goal, 4 P round 2 0 size map gc 5 @0.047s 0%: 0.002+0.21+0.011 ms clock, 0.011+0.032/0.016/0.079+0.045 ms cpu, 7->7->0 MB, 8 MB goal, 4 P scvg: 0 MB released scvg: inuse: 7, idle: 56, sys: 63, released: 55, consumed: 8 (MB) scvg: 0 MB released scvg: inuse: 2, idle: 61, sys: 63, released: 55, consumed: 8 (MB) scvg: 0 MB released scvg: inuse: 5, idle: 58, sys: 63, released: 55, consumed: 8 (MB) gc 6 @0.052s 0%: 0.004+0.11+0.003 ms clock, 0.016+0.062/0.008/0.10+0.015 ms cpu, 5->5->3 MB, 6 MB goal, 4 P round 3 gc 7 @0.066s 0%: 0.002+0.12+0.033 ms clock, 0.011+0.047/0.043/0.052+0.13 ms cpu, 6->6->2 MB, 7 MB goal, 4 P scvg: 0 MB released scvg: inuse: 6, idle: 56, sys: 63, released: 55, consumed: 8 (MB) 0 size map scvg: 0 MB released scvg: inuse: 4, idle: 59, sys: 63, released: 55, consumed: 8 (MB) gc 8 @0.070s 0%: 0.002+0.075+0.004 ms clock, 0.010+0.060/0.007/0.061+0.019 ms cpu, 5->5->1 MB, 6 MB goal, 4 P scvg: 0 MB released scvg: inuse: 2, idle: 61, sys: 63, released: 55, consumed: 8 (MB) scvg: 0 MB released scvg: inuse: 2, idle: 61, sys: 63, released: 55, consumed: 7 (MB) scvg: inuse: 4, idle: 58, sys: 63, released: 55, consumed: 7 (MB) gc 9 @0.075s 0%: 0.003+0.16+0.004 ms clock, 0.012+0.11/0.009/0.10+0.019 ms cpu, 4->4->3 MB, 5 MB goal, 4 P
0長度的map每次都觸發gc, 但定長的不會gc.
有個1千萬kv的 map,測試在什么情況下會回收內存
package main import "runtime/debug" var count = 10_000_000 var dict = make(map[int]int, count) func addition() { for i := 0; i < count; i++ { dict[i] = i } } func clear() { for k := range dict { delete(dict, k) } } func main() { addition() println("delete map item") clear() debug.FreeOSMemory() println("delete map") dict = nil debug.FreeOSMemory() }
go build -o growth big_map.go && GODEBUG=gctrace=1 ./growth gc 1 @0.005s 0%: 0.007+0.12+0.012 ms clock, 0.028+0.039/0.014/0.23+0.048 ms cpu, 306->306->306 MB, 307 MB goal, 4 P gc 2 @1.469s 0%: 0.004+1.1+0.009 ms clock, 0.018+0/0.99/0.76+0.036 ms cpu, 307->307->306 MB, 612 MB goal, 4 P delete map item gc 3 @2.101s 0%: 0.003+0.18+0.037 ms clock, 0.012+0/0.077/0.068+0.14 ms cpu, 309->309->306 MB, 612 MB goal, 4 P (forced) forced scvg: 4 MB released forced scvg: inuse: 306, idle: 77, sys: 383, released: 77, consumed: 306 (MB) delete map gc 4 @2.102s 0%: 0.001+0.14+0.002 ms clock, 0.007+0/0.12/0.002+0.011 ms cpu, 306->306->0 MB, 612 MB goal, 4 P (forced) scvg: inuse: 306, idle: 77, sys: 383, released: 77, consumed: 306 (MB) forced scvg: 306 MB released forced scvg: inuse: 0, idle: 383, sys: 383, released: 383, consumed: 0 (MB)
刪除了所有kv,堆大小(goal)並無變化
設置為nil,才會真正釋放map內存。(本身每2分鍾強制 runtime.GC(),每5分鍾 scavenge 釋放內存,其實不必太過糾結是否真正釋放,未真正釋放也是為了后面有可能的重用, 但有時需要真實釋放時,清楚怎么做才能解決問題)
總結
通過分析,我們了解了map是由數組+鏈表實現的HashTable,其大小和B息息相關,同時也了解了map的創建、查詢、分配、刪除以及擴容搬遷原理。總的來說,Golang通過hashtop快速試錯加快了查找過程,利用空間換時間的思想解決了擴容的問題,利用將8個key(8個value)依次放置減少了padding空間等等。