您好,登錄后才能下訂單哦!
Golang中實現map底層的原理是什么?相信很多沒有經驗的人對此束手無策,為此本文總結了問題出現的原因和解決方法,通過這篇文章希望你能解決這個問題。
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時,hash2 = 4,hash3 = 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
總結
到目前為止,Golang的map實現細節已經分析完畢,但不包含迭代器相關操作。通過分析,我們了解了map是由數組+鏈表實現的HashTable,其大小和B息息相關,同時也了解了map的創建、查詢、分配、刪除以及擴容搬遷原理。總的來說,Golang通過hashtop快速試錯加快了查找過程,利用空間換時間的思想解決了擴容的問題,利用將8個key(8個value)依次放置減少了padding空間等等。
看完上述內容,你們掌握Golang中實現map底層的原理是什么的方法了嗎?如果還想學到更多技能或想了解更多相關內容,歡迎關注億速云行業資訊頻道,感謝各位的閱讀!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。