抽絲剝繭—Go哈希Map的鬼魅神功
- Go語言中的哈希Map是江湖上極厲害的一門武功,其入門簡單,即便是掌握到了2、3層也具有四兩撥千斤的神奇功效.因此成爲江湖人士競相研習的技藝,風頭一時無兩.
- 但即便是成名已久的高手,也鮮有能修煉到最高層的.
- 本文不僅介紹了哈希Map基本的使用方式,還深入源碼介紹了哈希Map的至高心法.希望本文有助於你對Go哈希Map的理解臻於化境.
哈希表
- Go語言中的Map,又稱爲Hash map(哈希表)是使用頻率極高的一種數據結構,重要程度高到令人髮指。
- 哈希表的原理是將多個key/value對分散存儲在buckets(桶)中。給定一個key,哈希算法會計算出鍵值對存儲的位置。時常會通過兩步完成,僞代碼如圖所示:
hash = hashfunc(key)
index = hash % array_size
- 在此僞代碼中,第一步計算通過hash算法計算key的hash值,其結果與桶的數量無關。
- 接着通過執行取模運算得到
0 - array_size−1
之間的index序號。 - 在實踐中,我們時常將Map看做o(1)時間複雜度的操作,通過一個鍵key快速尋找其唯一對應的value。
Map基本操作
Map的聲明與初始化
首先,來看一看map的基本使用方式。map聲明的第一種方式如下
var hash map[T]T
其並未對map進行初始化的操作,其值爲nil,因此一旦進行hash[key]=alue
這樣的賦值操作就會報錯。
panic(plainError("assignment to entry in nil map"))
比較意外的是Go語言允許對爲nil的map進行訪問:hash["Go"]
,雖然其結果顯然毫無意義.
map的第二種聲明方式通過make進行。make的第二個參數中代表初始化創建map的長度。當NUMBER爲空時,代表默認長度爲0.
var hash = make(map[T]T,NUMBER)
此種方式可以正常的對map進行訪問與賦值
在map初始化時,還具有字面量形式初始化的方式。其在創建map時即在其中添加了元素。
var country = map[string]string{
"China": "Beijing",
"Japan": "Tokyo",
"India": "New Delhi",
"France": "Paris",
"Italy": "Rome",
}
rating := map[string]float64{"c": 5, "Go": 4.5, "Python": 4.5, "C++": 3}
Map的訪問
map可以進行兩種形式的訪問:
v := hash[key]
以及
v,ok := map[key]
當返回2個參數時,第2個參數代表當前key在map中是否存在。
不用驚訝於爲什麼同樣的訪問可以即返回一個值又返回兩個值,這是在編譯時做到的,後面會介紹。
Map的賦值
map的賦值語法相對簡單
hash[key] = value
其代表將value與給map1哈希表中的key綁定在一起
Map的刪除
map的刪除需要用到delete,其是Go語言中的關鍵字,用於進行map的刪除操作,形如:
delete(hash,key)
可以對相同的key進行多次的刪除操作,而不會報錯
關於map中的key
很容易理解,如果map中的key都沒有辦法比較是否相同,那麼就不能作爲map的key。
關於Go語言中的可比較性,直接閱讀官方文檔即可:https://golang.org/ref/spec#Comparison_operators
下面簡單列出一些類型的可比較性
布爾值是可比較的
整數值可比較的
浮點值是可比較的
複數值是可比較的
字符串值是可比較
指針值是可比較的。如果兩個指針值指向相同的變量,或者兩個指針的值均爲nil,則它們相等。
通道值是可比較的。如果兩個通道值是由相同的make調用創建的,或者兩個值都爲nil。
接口值是可比較的。如果兩個接口值具有相同的動態類型和相等的動態值,或者兩個接口值都爲nil,則它們相等。
如果結構的所有字段都是可比較的,則它們的值是可比較的。
如果數組元素類型的值可比較,則數組值可比較。如果兩個數組的對應元素相等,則它們相等。
切片、函數、map是不可比較的。
關於map併發衝突問題
- 和其他語言有些不同的是,map並不支持併發的讀寫,因此下面的操作是錯誤的
aa := make(map[int]int)
go func() {
for{
aa[0] = 5
}
}()
go func() {
for{
_ = aa[1]
}
}()
報錯:
fatal error: concurrent map read and map write
- Go語言只支持併發的讀取Map.因此下面的函數是沒有問題的
aa := make(map[int]int)
go func() {
for{
_ = aa[2]
}
}()
go func() {
for{
_ = aa[1]
}
}()
Go語言爲什麼不支持併發的讀寫,是一個頻繁被提起的問題。我們可以在Go官方文檔Frequently Asked Questions
找到問題的答案(https://golang.org/doc/faq#atomic_maps)
Map被設計爲不需要從多個goroutine安全訪問,在實際情況下,Map可能是某些已經同步的較大數據結構或計算的一部分。
因此,要求所有Map操作都互斥將減慢大多數程序的速度,而只會增加少數程序的安全性。
即這樣做的目的是爲了大多數情況下的效率。
Map在運行時
- 介紹了Map的基本操作,本節介紹一下Map在運行時的行爲,以便能夠深入Map內部的實現機制。
- 明白了Map的實現機制,有助於更加靈活的使用Map和進行深層次的調優過程。由於代碼裏面的邏輯關係關聯比較複雜。本節會首先用多張圖片幫助讀者有一個抽象的理解。
Go語言Map的底層實現如下所示:
// A header for a Go map.
type hmap struct {
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed
buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)
extra *mapextra // optional fields
}
其中:
- count 代表Map中元素的數量.
- flags 代表當前Map的狀態(是否處於正在寫入的狀態等).
- B 對數形式表示的當前Map中桶的數量, 2^B = Buckets size.
- noverflow 爲Map中溢出桶的數量.當溢出桶太多的時候,Map會進行
same-size map growth
.其實質是爲了避免溢出桶過大導致的內存泄露問題. - hash0 代表生成hash的隨機數種子.
- buckets 指向了當前Map對應的桶的指針.
- oldbuckets 是在Map進行擴容的時候存儲舊桶的.當所有的舊桶中的數據都已經轉移到了新桶,則清空。
- nevacuate 在擴容的時候使用。用於標記當前舊桶中小於nevacuate的桶都已經轉移到了新桶.
- extra存儲Map中的溢出桶
代表桶的bmap
結構在運行時只列出了其首個字段: 即一個固定長度爲8的數組。此字段順序存儲key的哈希值的前8位.
type bmap struct {
tophash [bucketCnt]uint8
}
可能會有疑問,桶中存儲的key和value值哪裏去了? 這是因爲Map在編譯時即確定了map中key,value,桶的大小。因此在運行時僅僅通過指針操作即可找到特定位置的元素。
桶本身在存儲的tophash字段之後,會存儲key數組以及value數組
type bmap struct {
tophash [bucketCnt]uint8
key [bucketCnt]T
value [bucketCnt]T
....
}
Go語言選擇將key與value分開存儲而不是key/value/key/value的形式,是爲了在字節對齊的時候能夠壓縮空間。
在進行hash[key]
此類的的Map訪問操作時,會首先找到桶的位置,如下爲僞代碼操作.
hash = hashfunc(key)
index = hash % array_size
接着遍歷tophash數組,如果數組中找到了相同的hash,那麼就可以接着通過指針的尋址操作找到key與value值
-
在Go語言中還有一個溢出桶的概念,在執行
hash[key] = value
賦值操作時,當指定桶中的數據超過了8個,並不會直接就新開闢一個新桶,而是會將數據放置到溢出桶中每個桶的最後還存儲了overflow
即溢出桶的指針 -
在正常情況下,數據是很少會跑到溢出桶裏面去的。同理,我們也可以知道,在Map的查找操作時,如果key的hash在指定桶的tophash數組中不存在,還會遍歷溢出桶中的數據。
-
後面我們會看到,如果一開始初始化map的數量比較大。則map提前創建好一些溢出桶存儲在
extra *mapextra
字段.
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}
這樣當出現溢出現象是,就可以用提前創建好的桶而不用申請額外的內存空間。當預分配的溢出桶使用完了,溢出桶纔會新建。
當發生以下兩種情況之一,map會進行重建:
- 當Map超過了負載因子大小
- 當溢出桶的數量過多
在哈希表中都有負載因子的概念
負載因子 = 哈希表中元素數量 / 桶的數量
- 因此隨着負載因子的增大,意味着越多的元素會分配到同一個桶中。此時其效率會減慢。
- 試想如果桶的數量只有1個,此時負載因子到達最大,此時的搜索效率就成了遍歷數組。在Go語言中的負載因子爲6.5。
- 當超過了其大小後,Mpa會進行擴容,增大兩倍於舊錶的大小。
- 舊桶的數據會首先存到
oldbuckets
字段,並想辦法分散的轉移到新桶中。
-
當舊桶的數據全部轉移到新桶之後,舊桶數據即會被清空。
-
map的重建還存在第二種情況,即溢出桶的數量太多。這時只會新建和原來的map具有相同大小的桶。進行這樣
same size
的重建爲了是防止溢出桶的數量可能緩慢增長導致的內存泄露. -
當進行map的delete操作時, 和賦值操作類似,會找到指定的桶,如果存在指定的key,那麼就釋放掉key與value引用的內存。同時tophash中指定位置會存儲
emptyOne
,代表當前位置是空的。 -
同時在刪除操作時,會探測到是否當前要刪除的元素之後都是空的。如果是,tophash會存儲爲
emptyRest
. 這樣做的好處是在做查找操作時,遇到emptyRest 可以直接退出,因爲後面的元素都是空的。
Map深入
上一節用多張圖片解釋了Map的實現原理,本節會繼續深入Go語言源碼解釋Map的具體實現細節。問題掌握得有多細緻,理解得就有多透徹。
Map深入: make初始化
如果我們使用make關鍵字初始化Map,在typecheck1類型檢查階段,節點Node的op操作變爲OMAKEMAP
,如果指定了make map的長度,則會將長度常量值類型轉換爲TINT類型.如果未指定長度,則長度爲0。nodintconst(0)
func typecheck1(n *Node, top int) (res *Node) {
...
case TMAP:
if i < len(args) {
l = args[i]
i++
l = typecheck(l, ctxExpr)
l = defaultlit(l, types.Types[TINT])
if l.Type == nil {
n.Type = nil
return n
}
if !checkmake(t, "size", l) {
n.Type = nil
return n
}
n.Left = l
} else {
n.Left = nodintconst(0)
}
n.Op = OMAKEMAP
- 如果make的第二個參數不是整數,則會在類型檢查時報錯。
if !checkmake(t, "size", l) {
n.Type = nil
return n
}
func checkmake(t *types.Type, arg string, n *Node) bool {
if !n.Type.IsInteger() && n.Type.Etype != TIDEAL {
yyerror("non-integer %s argument in make(%v) - %v", arg, t, n.Type)
return false
}
}
- 最後會指定在運行時調用runtime.makemap*函數
func walkexpr(n *Node, init *Nodes) *Node {
fnname := "makemap64"
argtype := types.Types[TINT64]
// Type checking guarantees that TIDEAL hint is positive and fits in an int.
// See checkmake call in TMAP case of OMAKE case in OpSwitch in typecheck1 function.
// The case of hint overflow when converting TUINT or TUINTPTR to TINT
// will be handled by the negative range checks in makemap during runtime.
if hint.Type.IsKind(TIDEAL) || maxintval[hint.Type.Etype].Cmp(maxintval[TUINT]) <= 0 {
fnname = "makemap"
argtype = types.Types[TINT]
}
fn := syslook(fnname)
fn = substArgTypes(fn, hmapType, t.Key(), t.Elem())
n = mkcall1(fn, n.Type, init, typename(n.Type), conv(hint, argtype), h)
}
不管是makemap64還是makemap,最後都調用了makemap函數
func makemap64(t *maptype, hint int64, h *hmap) *hmap {
if int64(int(hint)) != hint {
hint = 0
}
return makemap(t, int(hint), h)
}
- 保證創建map的長度不能超過int大小
if int64(int(hint)) != hint {
hint = 0
}
- makemap函數會計算出需要的桶的數量,即log2(N),並調用
makeBucketArray
函數生成桶和溢出桶 - 如果初始化時生成了溢出桶,會放置到map的
extra
字段裏去
func makemap(t *maptype, hint int, h *hmap) *hmap {
...
B := uint8(0)
for overLoadFactor(hint, B) {
B++
}
h.B = B
if h.B != 0 {
var nextOverflow *bmap
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
- makeBucketArray 會爲Map申請內存大小,這裏需要注意的是,如果map的數量大於了
2^4
,則會在初始化的時候生成溢出桶。溢出桶的大小爲2^(b-4),b爲桶的大小。
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) {
if b >= 4 {
nbuckets += bucketShift(b - 4)
sz := t.bucket.size * nbuckets
up := roundupsize(sz)
if up != sz {
nbuckets = up / t.bucket.size
}
}
if dirtyalloc == nil {
buckets = newarray(t.bucket, int(nbuckets))
} else {
buckets = dirtyalloc
size := t.bucket.size * nbuckets
if t.bucket.ptrdata != 0 {
memclrHasPointers(buckets, size)
} else {
memclrNoHeapPointers(buckets, size)
}
}
if base != nbuckets {
nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize)))
last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize)))
last.setoverflow(t, (*bmap)(buckets))
}
return buckets, nextOverflow
}
Map深入: 字面量初始化
如果是採取了字面量初始化的方式,其最終任然是需要轉換爲make
操作,其長度是字面量的長度。其編譯時的核心邏輯位於:
func anylit(n *Node, var_ *Node, init *Nodes){
...
case OMAPLIT:
if !t.IsMap() {
Fatalf("anylit: not map")
}
maplit(n, var_, init)
}
func maplit(n *Node, m *Node, init *Nodes) {
a := nod(OMAKE, nil, nil)
a.Esc = n.Esc
a.List.Set2(typenod(n.Type), nodintconst(int64(n.List.Len())))
if len(entries) > 25 {
...
}
...
}
- 唯一值得一提的是,如果字面量的個數大於25個,編譯時會構建一個數組循環添加
entries := n.List.Slice()
if len(entries) > 25 {
// loop adding structure elements to map
// for i = 0; i < len(vstatk); i++ {
// map[vstatk[i]] = vstate[i]
// }
}
- 如果字面量的個數小於25個,編譯時會指定會採取直接添加的方式賦值
for _, r := range entries {
map[key] = value
}
Map深入: map訪問
前面介紹過,對map的訪問,具有兩種形式。一種是返回單個值
v := hash[key]
一種是返回多個返回值
v, ok := hash[key]
Go語言沒有函數重載的概念,決定返回一個值還是兩個值很明顯只能夠在編譯時完成。
對於 v:= rating["Go"]
rating[“Go”]會在編譯時解析爲一個node,其中左邊type爲ONAME,存儲名字:,右邊type爲OLITERAL,存儲"Go",節點的op爲"OINDEXMAP"
根據hash[key]
位於賦值號的左邊或右邊,決定要執行訪問還是賦值的操作。訪問操作會在運行時調用運行mapaccess1_XXX函數,賦值操作會在運行時調用mapassign_XXX函數.
if n.IndexMapLValue() {
// This m[k] expression is on the left-hand side of an assignment.
fast := mapfast(t)
if fast == mapslow {
// standard version takes key by reference.
// orderexpr made sure key is addressable.
key = nod(OADDR, key, nil)
}
n = mkcall1(mapfn(mapassign[fast], t), nil, init, typename(t), map_, key)
} else {
// m[k] is not the target of an assignment.
fast := mapfast(t)
if fast == mapslow {
// standard version takes key by reference.
// orderexpr made sure key is addressable.
key = nod(OADDR, key, nil)
}
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)
}
}
- Go編譯器根據map中的key類型和大小選擇不同的mapaccess1_XXX函數進行加速,但是他們在查找邏輯上都是相同的
func mapfast(t *types.Type) int {
// Check runtime/map.go:maxElemSize before changing.
if t.Elem().Width > 128 {
return mapslow
}
switch algtype(t.Key()) {
case AMEM32:
if !t.Key().HasHeapPointer() {
return mapfast32
}
if Widthptr == 4 {
return mapfast32ptr
}
Fatalf("small pointer %v", t.Key())
case AMEM64:
if !t.Key().HasHeapPointer() {
return mapfast64
}
if Widthptr == 8 {
return mapfast64ptr
}
// Two-word object, at least one of which is a pointer.
// Use the slow path.
case ASTRING:
return mapfaststr
}
return mapslow
}
func mkmapnames(base string, ptr string) mapnames {
return mapnames{base, base + "_fast32", base + "_fast32" + ptr, base + "_fast64", base + "_fast64" + ptr, base + "_faststr"}
}
var mapaccess1 = mkmapnames("mapaccess1", "")
最終會在運行時會調用mapaccess1_XXXX的函數。
而對於v, ok := hash[key]
類型的map訪問則有所不同。在編譯時的op操作爲OAS2MAPR.會將其轉換爲在運行時調用的mapaccess2_XXXX前綴的函數。其僞代碼如下:
// var,b = mapaccess2*(t, m, i)
// v = *var
- 需要注意,如果採用
_, ok := hash[key]
形式,則不用對第一個參數進行賦值操作. - 在運行時,會根據key值以及hash種子 計算hash值:
alg.hash(key, uintptr(h.hash0)).
- 接着bucketMask計算出當前桶的個數-1.
m := bucketMask(h.B)
- Go語言採用了一種簡單的方式
hash&m
計算出此key應該位於哪一個桶中.獲取到桶的位置後,tophash(hash)
即可計算出hash的前8位. - 接着此hash 挨個與存儲在桶中的tophash進行對比。如果有hash值相同的話.會找到其對應的key值,查看key值是否相同。如果key值也相同,即說明查找到了結果,返回value哦。
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
alg := t.key.alg
hash := alg.hash(key, uintptr(h.hash0))
m := bucketMask(h.B)
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
top := tophash(hash)
bucketloop:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if alg.equal(key, k) {
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
return e
}
}
}
return unsafe.Pointer(&zeroVal[0])
}
- 函數mapaccess2 的邏輯幾乎是類似的,只是其會返回第二個參數,表明value值是否存在於桶中.
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) {
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)))
top := tophash(hash)
bucketloop:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if alg.equal(key, k) {
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
e = *((*unsafe.Pointer)(e))
}
return e, true
}
}
}
return unsafe.Pointer(&zeroVal[0]), false
}
Map深入: 賦值操作
- 和訪問的情況的比較類似, 最終會調用運行時mapassign*函數。
- 賦值操作,map必須已經進行了初始化。
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
- 同時要注意,由於Map不支持併發的讀寫操作,因此還會檢測是否有協程在訪問此Map,如果是,即會報錯。
if h.flags&hashWriting != 0 {
throw("concurrent map writes")
}
- 和訪問操作一樣,會計算key的hash值
alg := t.key.alg
hash := alg.hash(key, uintptr(h.hash0))
- 標記當前map已經是寫入狀態
h.flags ^= hashWriting
- 如果當前沒有桶,還會常見一個新桶。所以初始化的時候還是定一個長度吧。
if h.buckets == nil {
h.buckets = newobject(t.bucket) // newarray(t.bucket, 1)
}
- 接着找到當前key對應的桶
bucket := hash & bucketMask(h.B)
- 如果發現,當前的map正好在重建,還沒有重建完。會優先完成重建過程,重建的細節後面會介紹。
if h.growing() {
growWork(t, h, bucket)
}
- 計算tophash
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize)))
top := tophash(hash)
- 開始尋找是否有對應的hash,如果找到了,判斷key是否相同,如果相同,會找到對應的value的位置在後面進行賦值
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if isEmpty(b.tophash[i]) && inserti == nil {
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
}
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
if t.indirectkey() {
k = *((*unsafe.Pointer)(k))
}
if !alg.equal(key, k) {
continue
}
// already have a mapping for key. Update it.
if t.needkeyupdate() {
typedmemmove(t.key, k, key)
}
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
goto done
}
ovf := b.overflow(t)
if ovf == nil {
break
}
b = ovf
}
- 要注意的是,如果tophash沒找到,還會去溢出桶裏尋找是否存在指定的hash
- 如果也不存在,會選擇往第一個空元素中插入數據inserti、insertk會記錄此空元素的位置,
if isEmpty(b.tophash[i]) && inserti == nil {
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
}
- 在賦值之前,還需要判斷Map是否需要重建
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again // Growing the table invalidates everything, so try again
}
- 沒有問題後,就會執行最後的操作,將新的key與value值存入數組中
- 這裏需要注意一點是,如果桶中已經沒有了空元素。這時候我們申請一個新的桶給到這個桶。
if inserti == nil {
// all current buckets are full, allocate a new one.
newb := h.newoverflow(t, b)
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.keysize))
}
- 申請的新桶一開始是來自於map中
extra
字段初始化時存儲的多餘溢出桶。如果這些多餘的溢出桶都用完了纔會申請新的內存。一個桶的溢出桶可能會進行延展
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
var inserti *uint8
var insertk unsafe.Pointer
var elem unsafe.Pointer
bucketloop:
for {
// Did not find mapping for key. Allocate new cell & add entry.
// If we hit the max load factor or we have too many overflow buckets,
// and we're not already in the middle of growing, start growing.
if inserti == nil {
// all current buckets are full, allocate a new one.
newb := h.newoverflow(t, b)
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.keysize))
}
// store new key/elem at insert position
if t.indirectkey() {
kmem := newobject(t.key)
*(*unsafe.Pointer)(insertk) = kmem
insertk = kmem
}
if t.indirectelem() {
vmem := newobject(t.elem)
*(*unsafe.Pointer)(elem) = vmem
}
typedmemmove(t.key, insertk, key)
*inserti = top
h.count++
done:
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
h.flags &^= hashWriting
if t.indirectelem() {
elem = *((*unsafe.Pointer)(elem))
}
return elem
}
Map深入: Map重建
當發生以下兩種情況之一,map會進行重建:
- 當Map超過了負載因子大小6.5
- 當溢出桶的數量過多
重建時需要調用hashGrow函數,如果是負載因子超載,會進行雙倍重建。
bigger := uint8(1)
if !overLoadFactor(h.count+1, h.B) {
bigger = 0
h.flags |= sameSizeGrow
}
- 當溢出桶的數量過多,則會進行等量重建。新桶會會存儲到
buckets
字段,舊桶會存儲到oldbuckets
字段。 - map中extra字段的溢出桶也同理的進行了轉移。
if h.extra != nil && h.extra.overflow != nil {
// Promote current overflow buckets to the old generation.
if h.extra.oldoverflow != nil {
throw("oldoverflow is not nil")
}
h.extra.oldoverflow = h.extra.overflow
h.extra.overflow = nil
}
- hashGrow 代碼一覽
func hashGrow(t *maptype, h *hmap) {
// If we've hit the load factor, get bigger.
// Otherwise, there are too many overflow buckets,
// so keep the same number of buckets and "grow" laterally.
bigger := uint8(1)
if !overLoadFactor(h.count+1, h.B) {
bigger = 0
h.flags |= sameSizeGrow
}
oldbuckets := h.buckets
newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil)
flags := h.flags &^ (iterator | oldIterator)
if h.flags&iterator != 0 {
flags |= oldIterator
}
// commit the grow (atomic wrt gc)
h.B += bigger
h.flags = flags
h.oldbuckets = oldbuckets
h.buckets = newbuckets
h.nevacuate = 0
h.noverflow = 0
if h.extra != nil && h.extra.overflow != nil {
// Promote current overflow buckets to the old generation.
if h.extra.oldoverflow != nil {
throw("oldoverflow is not nil")
}
h.extra.oldoverflow = h.extra.overflow
h.extra.overflow = nil
}
if nextOverflow != nil {
if h.extra == nil {
h.extra = new(mapextra)
}
h.extra.nextOverflow = nextOverflow
}
// the actual copying of the hash table data is done incrementally
// by growWork() and evacuate().
}
- 要注意的是, 在這裏並沒有進行實際的將舊桶數據轉移到新桶的過程。數據轉移遵循了
copy on write
(寫時複製)的規則。只有在真正賦值的時候,會選擇是否需要進行數據轉移。核心邏輯位於函數growWork
andevacuate
bucket := hash & bucketMask(h.B)
if h.growing() {
growWork(t, h, bucket)
}
- 在進行寫時複製的時候,意味着並不是所有的數據都會一次性的進行轉移,而只會轉移當前需要的這個舊桶。
bucket := hash & bucketMask(h.B)
得到了當前新桶所在的位置,而要轉移的舊桶的位置位於bucket&h.oldbucketmask()
xy [2]evacDst
用於存儲要轉移到新桶的位置
如果是雙倍重建,那麼舊桶轉移到新桶的位置總是相距舊桶的數量.
如果是等量重建,則簡單的直接轉移即可
- 解決了舊桶要轉移哪一些新桶,我們還需要解決舊桶中的數據要轉移到哪一些新桶.
- 其中有一個非常重要的原則是:如果此數據計算完hash後,
hash & bucketMask <= 舊桶的大小
意味着這個數據必須轉移到和舊桶位置完全對應的新桶中去.理由是現在當前key所在新桶的序號與舊桶是完全相同的。
newbit := h.noldbuckets()
if hash&newbit != 0 {
useY = 1
}
func evacuate(t *maptype, h *hmap, oldbucket uintptr) {
...
// Unlink the overflow buckets & clear key/elem to help GC.
if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 {
b := add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))
// Preserve b.tophash because the evacuation
// state is maintained there.
ptr := add(b, dataOffset)
n := uintptr(t.bucketsize) - dataOffset
memclrHasPointers(ptr, n)
}
}
if oldbucket == h.nevacuate {
advanceEvacuationMark(h, t, newbit)
}
}
Map深入: delete
- 刪除的邏輯在之前介紹過,是比較簡單的。
- 核心邏輯位於
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer)
- 同樣需要計算出hash的前8位、指定的桶等。
- 同樣會一直尋找是否有相同的key,如果找不到,會一直查找當前桶的溢出桶下去,知道到達末尾…
- 如果查找到了指定的key,則會清空數據,hash位設置爲
emptyOne
. 如果發現後面沒有元素,則會設置爲emptyRest
,並循環向上檢查前一個元素是否爲空。
for {
b.tophash[i] = emptyRest
if i == 0 {
if b == bOrig {
break // beginning of initial bucket, we're done.
}
// Find previous bucket, continue at its last entry.
c := b
for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
}
i = bucketCnt - 1
} else {
i--
}
if b.tophash[i] != emptyOne {
break
}
}
- delete代碼一覽
func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) {
alg := t.key.alg
hash := alg.hash(key, uintptr(h.hash0))
h.flags ^= hashWriting
bucket := hash & bucketMask(h.B)
if h.growing() {
growWork(t, h, bucket)
}
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
bOrig := b
top := tophash(hash)
search:
for ; b != nil; b = b.overflow(t) {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if b.tophash[i] == emptyRest {
break search
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
k2 := k
if t.indirectkey() {
k2 = *((*unsafe.Pointer)(k2))
}
if !alg.equal(key, k2) {
continue
}
if t.indirectkey() {
*(*unsafe.Pointer)(k) = nil
} else if t.key.ptrdata != 0 {
memclrHasPointers(k, t.key.size)
}
e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize))
if t.indirectelem() {
*(*unsafe.Pointer)(e) = nil
} else if t.elem.ptrdata != 0 {
memclrHasPointers(e, t.elem.size)
} else {
memclrNoHeapPointers(e, t.elem.size)
}
b.tophash[i] = emptyOne
if i == bucketCnt-1 {
if b.overflow(t) != nil && b.overflow(t).tophash[0] != emptyRest {
goto notLast
}
} else {
if b.tophash[i+1] != emptyRest {
goto notLast
}
}
for {
b.tophash[i] = emptyRest
if i == 0 {
if b == bOrig {
break // beginning of initial bucket, we're done.
}
c := b
for b = bOrig; b.overflow(t) != c; b = b.overflow(t) {
}
i = bucketCnt - 1
} else {
i--
}
if b.tophash[i] != emptyOne {
break
}
}
notLast:
h.count--
break search
}
}
if h.flags&hashWriting == 0 {
throw("concurrent map writes")
}
h.flags &^= hashWriting
}
總結
-
本文首先介紹了Go語言Map增刪查改的基本用法
-
接着介紹了Map使用中key的特性,只有
可比較
的類型才能夠作爲Map中的key -
接着介紹了map禁止併發讀寫的設計原因,即爲了大部分程序的效率而犧牲了小部分程序的安全。
-
最後我們深入源碼介紹了map編譯和運行時的具體細節。Map有多種初始化的方式,如果指定了長度N,在初始化時會生成桶。桶的數量爲log2(N).如果map的長度大於了
2^4
,則會在初始化的時候生成溢出桶。溢出桶的大小爲2^(b-4),b爲桶的大小。 -
在涉及到訪問、賦值、刪除操作時,都會首先計算數據的hash值,接着簡單的&運算計算出數據存儲在桶中的位置。接着會根據hash的前8位與存儲在桶中的hash、key進行比較,完成最後的賦值與訪問操作。如果數據放不下了還會申請放置到溢出桶中
-
當Map超過了負載因子大小會進行雙倍重建,溢出桶太大會進行等量重建。數據的轉移採取了
寫時複製
的策略,即在用到時纔會將舊桶的數據打散放入到新桶中。 -
因此,可以看出Map是簡單高效的kv存儲的利器,它非常快但是卻快不到極致。理論上來說,我們總是可以根據我們數據的特點設計出更好的哈希函數以及映射機制。
-
Map的重建的過程提示我們可以評估放入到Map的數據大小,並在初始化時指定
-
Map在實踐中極少成爲性能的瓶頸,但是卻容易寫出併發衝突的程序。這提示我們進行合理的設計以及進行
race
檢查。 -
see you~