本文在golang map 數據結構的基礎上,學習map 數據是如何訪問的。
map 創建示例
在golang 中,訪問 map 的方式有兩種,例子如下:
val := example1Map[key1]
val, ok := example1Map[key1]
第一種方式不判斷是否存在key值,直接返回val (可能是空值)
第二種方式會返回一個bool 值,判斷是否存在key 鍵值。(是不是和redis 的空值判斷很類似)
那訪問map 時,底層做了什么,我們一起來探究
對於不同的訪問方式,會使用不同的方法,下面是內部提供的幾種方法,我們一起來學習:
// 迭代器中使用
func mapaccessK(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer){}
// 不返回 bool
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {}
func mapaccess1_fat(t *maptype, h *hmap, key, zero unsafe.Pointer) unsafe.Pointer {}
// 返回 bool
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) {}
func mapaccess2_fat(t *maptype, h *hmap, key, zero unsafe.Pointer) (unsafe.Pointer, bool) {}
這些方法有很大的相關性,下面我們逐一來學習吧。
mapaccess1_fat, mapaccess2_fat
這兩個方法,從字面上來看多了個fat,就是個寬數據。何以為寬,我們從下面代碼找到原因:
//src/cmd/compile/internal/gc/walk.go
if w := t.Elem().Width; w <= 1024 { // 1024 must match runtime/map.go:maxZero
n = mkcall1(mapfn(mapaccess1[fast], t), types.NewPtr(t.Elem()), init, typename(t), map_, key)
} else {
z := zeroaddr(w)
n = mkcall1(mapfn("mapaccess1_fat", t), types.NewPtr(t.Elem()), init, typename(t), map_, key, z)
}
這是構建語法樹時,mapaccess1 相關的代碼(mapaccess2_fat 也類似), 如果val 大於1024byte 的寬度,那會調用fat 后綴的方法。
原因是,在map.go 文件中,定義了val 0值的數組,代碼如下:
const maxZero = 1024 // must match value in cmd/compile/internal/gc/walk.go
var zeroVal [maxZero]byte
但是這個零值只能對寬度小於1024byte的寬度的數據有效,所以對於返回值(val)寬度小於1024 的,直接調用mapaccess1 方法即可,否則需要首先找一個對應的0值數據,然后調用mapaccess1_fat 方法,如果為0,傳出對應的0值數據。
mapaccess1, mapaccess2
mapaccess1 與 mapaccess2 的差別在於是否返回返回值,mapaccess2 將返回bool 類型作為是否不存在相應key的標識,mapaccess1 不會。所以,這里着重分析mapaccess2. 代碼如下:
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) {
// 竟態分析 && 內存掃描
// ...
if h == nil || h.count == 0 {
// map 為空,或者size 為 0, 直接返回
}
if h.flags&hashWriting != 0 {
// 這里會檢查是否在寫,如果在寫直接panic
throw("concurrent map read and map write")
}
// 拿到對應key 的hash,以及 bucket
alg := t.key.alg
hash := alg.hash(key, uintptr(h.hash0))
m := bucketMask(h.B)
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + (hash&m)*uintptr(t.bucketsize)))
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
// There used to be half as many buckets; mask down one more power of two.
m >>= 1
}
oldb := (*bmap)(unsafe.Pointer(uintptr(c) + (hash&m)*uintptr(t.bucketsize)))
if !evacuated(oldb) {
b = oldb
}
}
// 獲取tophash 值
top := tophash(hash)
bucketloop:
// 遍歷解決沖突的鏈表
for ; b != nil; b = b.overflow(t) {
// 遍歷每個bucket 上的kv
for i := uintptr(0); i < bucketCnt; i++ {
// 先匹配 tophash
// ...
// 獲取k
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
// 判斷k是否相等,如果相等直接返回,否則繼續遍歷
if alg.equal(key, k) {
v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
if t.indirectvalue() {
v = *((*unsafe.Pointer)(v))
}
return v, true
}
}
}
return unsafe.Pointer(&zeroVal[0]), false
}
訪問map的流程比較簡單:
- 首先,獲取key 的hash值,並取到相應的bucket
- 其次,遍歷對應的bucket,以及bucket 的鏈表(沖突鏈)
- 對於每個bucket 需要先匹配tophash 數組中的值,如果不匹配,則直接過濾。
- 如果hash 匹配成功,還是需要匹配key 是否相等,相等就返回,不等繼續遍歷。
這里需要注意一點:在tophash 數組中不僅會標識是否匹配hash值,還會標識下個數組中是否還有元素,減少匹配的次數。代碼如下:
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
tophash 的值有多種情況, 如果小於minTopHash,則作為標記使用。下面是標識含義:
emptyRest = 0 // 標記為空,且后面沒有數據了 (包括overflow 和 index)
emptyOne = 1 // 在被刪除的時候設置為空
evacuatedX = 2 // kv 數據被遷移到新hash表的 x 位置
evacuatedY = 3 // kv 數據被遷移到新hash表的 y 位置
evacuatedEmpty = 4 // bucket 被轉移走了,數據是空的
minTopHash = 5 // 閾值標識
enptyRest 是有利於數據遍歷的,減少了對數據的訪問次數
evacuateX 和 evacuateY 與數據遷移有關,我們在賦值部分學習(賦值才有可能遷移)
總結
- map 中,val 如果寬度比較大,0值問題也需要多分配內存。所以,這種情況,使用指針肯定是合理的。(當然,內存拷貝也是一個問題)
- tophash 值的含義參考第一篇 bucket章節
今天的作業就交完了。下一篇將學習golang 賦值的實現。