你不知道的Golang map


在開發過程中,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;這個例子我們在搬遷過程還會用到。

內存布局類似於這樣:

 

 
hashmap-buckets

2. 創建 - makemap

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

 
makemap

3. 訪問 - mapaccess

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

 

 
image.png

方法定義為

// 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中查找,這是因為首先要進行擴容判斷和操作;如下:

 

 
assign

擴容是整個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,並沒有減少內存;如下:

 

 
mapdelete

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,其搬遷結果類似這樣。

 
example.png

源碼中有些變量的命名比較簡單,容易擾亂思路,我們注明一下便於理解。

變量 釋義
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地址

搬遷過程如下:

 

 
evacuate

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 方法:

  1. 定義原 hmap 中指向 buckets 數組的指針
  2. 創建 bucket 數組並設置為 hmap 的 bucket 字段
  3. 將 extra 中的 oldoverflow 指向 overflow,overflow 指向 nil
  4. 如果正在 growing 的話,開始漸進式的遷移,在 growWork 方法里是 bucket 中 key/value 的遷移
  5. 在全部遷移完成后,釋放內存

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空間等等。

golang在rehash時,沒有一次性遷移所有的buckets,而是把key的遷移分攤到每次插入或刪除時, 在 bucket 中的 key/value 全部遷移完成釋放oldbucket和extra.oldoverflow( 盡可能不去使用map存儲大量數據;最好在初始化一次性聲明cap,避免頻繁擴容, 多用make,少用new

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM