Dig101:Go之讀懂map的底層設計

Dig101: dig more, simplified more and know morehtml

在golang中,map是一個不可或缺的存在。git

它做爲哈希表,簡單易用,既能自動處理哈希碰撞,又能自動擴容或從新內存整理,避免讀寫性能的降低。github

這些都要歸功於其內部實現的精妙。本文嘗試去經過源碼去分析一下其背後的故事。golang

咱們不會過多在源碼分析上展開,只結合代碼示例對其背後設計實現上作些總結,但願能夠簡單明瞭一些。api

但願看完後,會讓你對 map 的理解有一些幫助。網上也有不少不錯的源碼分析,會附到文末,感興趣的同窗自行查看下。數組

(本文分析基於 Mac 平臺上go1.14beta1版本。長文預警 ... )安全

也能夠移步 微信版 閱讀微信

文章目錄數據結構

  • 0x01 map 的內部結構
  • 0x02 map 的 hash 方式
  • 0x03 map 的擴容方式
  • 0x04 map 的初始化
  • 0x05 map 的讀取
  • 0x06 map 的賦值
  • 0x07 map 的刪除
  • 0x08 map 的遍歷

咱們先簡單過下map實現hash表所用的數據結構,這樣方便後邊討論。併發

0x01 map的內部結構

map數據結構

在這裏咱們先弄清楚map實現的總體結構

map本質是hash表(hmap),指向一堆桶(buckets)用來承接數據,每一個桶(bmap)能存8組k/v。

當有數據讀寫時,會用key的hash找到對應的桶。

爲加速hash定位桶,bmap裏記錄了tophash數組(hash的高8位)

hash表就會有哈希衝突的問題(不一樣key的hash值同樣,即hash後都指向同一個桶),爲此map使用桶後鏈一個溢出桶(overflow)鏈表來解決當桶8個單元都滿了,但還有數據須要存入此桶的問題。

剩下noverflow,oldbuckets,nevacuate,oldoverflow 會用於擴容,暫時先不展開

具體對應的數據結構詳細註釋以下:

(雖然多,先大體過一遍,後邊遇到會在提到)

// runtime/map.go
// A header for a Go map.
type hmap struct {
  //用於len(map)
  count     int
  //標誌位
  // iterator = 1 // 可能有遍歷用buckets
  // oldIterator = 2 // 可能有遍歷用oldbuckets,用於擴容期間
  // hashWriting = 4 // 標記寫,用於併發讀寫檢測
  // sameSizeGrow = 8 // 用於等大小buckets擴容,減小overflow桶
  flags     uint8

  // 表明能夠最多容納loadFactor * 2^B個元素(loadFactor=6.5)
  B         uint8
  // overflow桶的計數,當其接近1<<15 - 1時爲近似值
  noverflow uint16
  // 隨機的hash種子,每一個map不同,減小哈希碰撞的概率
  hash0     uint32

  // 當前桶,長度爲(0-2^B)
  buckets    unsafe.Pointer
  // 若是存在擴容會有擴容前的桶
  oldbuckets unsafe.Pointer
  // 遷移數,標識小於其的buckets已遷移完畢
  nevacuate  uintptr

  // 額外記錄overflow桶信息,不必定每一個map都有
  extra *mapextra
}

// 額外記錄overflow桶信息
type mapextra struct {
  overflow    *[]*bmap
  oldoverflow *[]*bmap

  // 指向下一個可用overflow桶
  nextOverflow *bmap
}

const(
  // 每一個桶8個k/v單元
  BUCKETSIZE  = 8
  // k或v類型大小大於128轉爲指針存儲
  MAXKEYSIZE  = 128
  MAXELEMSIZE = 128
)

// 桶結構 (字段會根據key和elem類型動態生成,見下邊bmap)
type bmap struct {
  // 記錄桶內8個單元的高8位hash值,或標記空桶狀態,用於快速定位key
  // emptyRest = 0 // 此單元爲空,且更高索引的單元也爲空
  // emptyOne = 1 // 此單元爲空
  // evacuatedX = 2 // 用於表示擴容遷移到新桶前半段區間
  // evacuatedY = 3 // 用於表示擴容遷移到新桶後半段區間
  // evacuatedEmpty = 4 // 用於表示此單元已遷移
  // minTopHash = 5 // 最小的空桶標記值,小於其則是空桶標誌
  tophash [bucketCnt]uint8
}

// cmd/compile/internal/gc/reflect.go
// func bmap(t *types.Type) *types.Type {
// 每一個桶內k/v單元數是8
type bmap struct{
  topbits [8]uint8 //tophash
  keys [8]keytype
  elems [8]elemtype
  // overflow 桶
  // otyp 類型爲指針*Type,
  // 若keytype及elemtype不含指針,則爲uintptr
  // 使bmap總體不含指針,避免gc去scan此類map
  overflow otyp
}
複製代碼

這裏有幾個字段須要解釋一下:

  • hmap.B

這個爲啥用2的對數來表示桶的數目呢?

這裏是爲了hash定位桶及擴容方便

比方說,hash%n能夠定位桶, 但%操做沒有位運算快。

而利用 n=2^B,則hash%n=hash&(n-1)

則可優化定位方式爲: hash&(1<<B-1)(1<<B-1)即源碼中BucketMask

再比方擴容,hmap.B=hmap.B+1 即爲擴容到二倍

  • bmap.keys, bmap.elems

在桶裏存儲k/v的方式不是一個k/v一組, 而是k放一塊,v放一塊。

這樣的相對k/v相鄰的好處是,方便內存對齊。好比map[int64]int8, v是int8,放一塊就避免須要額外內存對齊。

另外對於大的k/v也作了優化。

正常狀況key和elem直接使用用戶聲明的類型,但當其size大於128(MAXKEYSIZE/MAXELEMSIZE)時,

則會轉爲指針去存儲。(也就是indirectkey、indirectelem

  • hmap.extra

這個額外記錄溢出桶意義在哪?

具體是爲解決讓gc不須要掃描此類bucket

只要bmap內不含指針就不需gc掃描。

mapkeyelem類型都不包含指針時,但其中的overflow是指針。

此時bmap的生成函數會將overflow的類型轉化爲uintptr

uintptr雖然是地址,但不會被gc認爲是指針,指向的數據有被回收的風險。

此時爲保證其中的overflow指針指向的數據存活,就用mapextra結構指向了這些buckets,這樣bmap有被引用就不會被回收了。

關於uintptr可能被回收的例子,能夠看下 go101 - Type-Unsafe Pointers 中 Some Facts in Go We Should Know

0x02 map的hash方式

瞭解map的基本結構後,咱們經過下邊代碼分析下map的hash

var m = map[interface{}]int{}
var i interface{} = []int{}
//panic: runtime error: hash of unhashable type []int
println(m[i])
//panic: runtime error: hash of unhashable type []int
delete(m, i)
複製代碼

爲何不能夠用[]int做爲key呢?

查找源碼中hash的調用鏈註釋以下:

// runtime/map.go
// mapassign,mapaccess1中 獲取key的hash
hash := t.hasher(key, uintptr(h.hash0))

// cmd/compile/internal/gc/reflect.go
func dtypesym(t *types.Type) *obj.LSym {
  switch t.Etype {
    // ../../../../runtime/type.go:/mapType
  case TMAP:
    ...
    // 依據key構建hash函數
    hasher := genhash(t.Key())
    ...
  }
}

// cmd/compile/internal/gc/alg.go
func genhash(t *types.Type) *obj.LSym {
  switch algtype(t) {
  ...
  //具體針對interface調用interhash
  case AINTER:
    return sysClosure("interhash")
  ...
  }
}

// runtime/alg.go
func interhash(p unsafe.Pointer, h uintptr) uintptr {
  //獲取interface p的實際類型t,此處爲slice
  a := (*iface)(p)
  tab := a.tab
  t := tab._type
  // slice類型不可比較,沒有equal函數
  if t.equal == nil {
    panic(errorString("hash of unhashable type " + t.string()))
  }
  ...
}
複製代碼

如上,咱們會發現map的hash函數並不惟一。

它會對不一樣key類型選取不一樣的hash方式,以此加快hash效率

這個例子slice不可比較,因此不能做爲key。

也對,不可比較的類型做爲key的話,找到桶但無法比較key是否相等,那map用這個key讀寫都會是個問題。

還有哪些不可比較?

cmd/compile/internal/gc/alg.goalgtype1 函數中能夠找到返回ANOEQ(不可比較類型)的類型,以下:

  • func,map,slice
  • 內部元素有這三種類型的array和struct類型

0x03 map的擴容方式

map不能夠對其值取地址;

若是值類型爲slicestruct,不能直接操做其內部元素

咱們用代碼驗證以下:

m0 := map[int]int{}
// ❎ cannot take the address of m0[0]
_ = &m0[0]

m := make(map[int][2]int)
// ✅
m[0] = [2]int{1, 0}
// ❎ cannot assign to m[0][0]
m[0][0] = 1
// ❎ cannot take the address of m[0]
_ = &m[0]

type T struct{ v int }
ms := make(map[int]T)
// ✅
ms[0] = T{v: 1}
// ❎ cannot assign to struct field ms[0].v in map
ms[0].v = 1
// ❎ cannot take the address of ms[0]
_ = &ms[0]
}
複製代碼

爲何呢?

這是由於map內部有漸進式擴容,因此map的值地址不固定,取地址沒有意義。

也所以,對於值類型爲slicestruct, 只有把他們各自當作總體去賦值操做纔是安全的。 go有個issue討論過這個問題:issues-3117

針對擴容的方式,有兩類,分別是:

  • sameSizeGrow

過多的overflow使用,使用等大小的buckets從新整理,回收多餘的overflow桶,提升map讀寫效率,減小溢出桶佔用

這裏藉助hmap.noverflow來判斷溢出桶是否過多

hmap.B<=15 時,判斷是溢出桶是否多於桶數1<<hmap.B

不然只判斷溢出桶是否多於 1<<15

這也就是爲啥hmap.noverflow,當其接近1<<15 - 1時爲近似值, 只要能夠評估是否溢出桶過多不合理就好了

  • biggerSizeGrow

count/size > 6.5 (裝載因子 :overLoadFactor), 避免讀寫效率下降。

擴容一倍,並漸進的在賦值和刪除(mapassign和mapdelete)期間,

對每一個桶從新分流到x(原來桶區間)和y(擴容後的增長的新桶區間)

這裏overLoadFactor (count/size)是評估桶的平均裝載數據能力,即map平均每一個桶裝載多少個k/v。

這個值太大,則桶不夠用,會有太多溢出桶;過小,則分配了太多桶,浪費了空間。

6.5是測試後對map裝載能力最大化的一個的選擇。

源碼中擴容代碼註釋以下:

// mapassign 中建立新bucket時檢測是否須要擴容
if !h.growing() && //非擴容中
  (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
  // 提交擴容,生成新桶,記錄舊桶相關。但不開始
  // 具體開始是後續賦值和刪除期間漸進進行
  hashGrow(t, h)
}

//mapassign 或 mapdelete中 漸進擴容
bucket := hash & bucketMask(h.B)
if h.growing() {
  growWork(t, h, bucket)
}

// 具體遷移工做執行,每次最多兩個桶
func growWork(t *maptype, h *hmap, bucket uintptr) {
  // 遷移對應舊桶
  // 若無迭代器遍歷舊桶,可釋放對應的overflow桶或k/v
  // 所有遷移完則釋放整個舊桶
  evacuate(t, h, bucket&h.oldbucketmask())

  // 若是還有舊桶待遷移,再遷移一個
  if h.growing() {
    evacuate(t, h, h.nevacuate)
  }
}
複製代碼

具體擴容evacuate(遷移)時,判斷是否要將舊桶遷移到新桶後半區間(y)有段代碼比較有趣, 註釋以下:

newbit := h.noldbuckets()
var useY uint8
if !h.sameSizeGrow() {
  // 獲取hash
  hash := t.hasher(k2, uintptr(h.hash0))
  if h.flags&iterator != 0 && !t.reflexivekey() && !t.key.equal(k2, k2) {
  // 這裏 key != key 是指key爲NaNs,
  // 此時 useY = top & 1 意味着有50%的概率到新桶區間
    useY = top & 1
    top = tophash(hash)
  } else {
    if hash&newbit != 0 {
  // 舉例來看 若擴容前h.B=3時, newbit=1<<3
  // hash&newbit != 0 則hash形如 xxx1xxx
  // 新hmap的BucketMask= 1<<4 - 1 (1111: 15)
  // 則 hash&新BucketMask > 原BucketMask 1<<3-1 (111: 7)
  // 因此去新桶區間
      useY = 1
    }
  }
}

// 補充一個 key != key 的代碼示例
n1, n2 := math.NaN(), math.NaN()
m := map[float64]int{}
m[n1], m[n2] = 1, 2
println(n1 == n2, m[n1], m[n2])
// output: false 0 0
// 因此NaN作key沒有意義。。。
複製代碼

弄清楚map的結構、hash和擴容,剩下的就是初始化、讀寫、刪除和遍歷了,咱們就不詳細展開了,簡單過下。

0x04 map的初始化

map不初始化時爲nil,是不能夠操做的。能夠經過make方式初始化

// 不指定大小
s := make(map[int]int)
// 指定大小
b := make(map[int]int,10)
複製代碼

對於這兩種map內部調用方式是不同的

  • small map

當不指定大小或者指定大小不大於8時,調用

func makemap_small() *hmap {

只須要直接在堆上初始化hmap和hash種子(hash0)就行。

  • bigger map

當大小大於8, 調用

func makemap(t *maptype, hint int, h *hmap) *hmap {

hint溢出則置0

初始化hmap和hash種子

根據overLoadFactor:6.5的要求, 循環增長h.B, 獲取 hint/(1<<h.B) 最接近 6.5的h.B

預分配hashtable的bucket數組

h.B 大於4的話,多分配至少1<<(h.B-4)(須要內存對齊)個bucket,用於可能的overflow桶使用,

並將 h.nextOverflow設置爲第一個可用的overflow桶。

最後一個overflow桶指向h.buckets(方便後續判斷已無overflow桶)

0x05 map的讀取

對於map的讀取有着三個函數,主要區別是返回參數不一樣

mapaccess1: m[k]
mapaccess2: a,b = m[i]
mapaccessk: 在map遍歷時若grow已發生,key可能有更新,需用此函數從新獲取k/v
複製代碼

計算key的hash,定位當前buckets裏桶位置

若是當前處於擴容中,也嘗試去舊桶取對應的桶,需考慮擴容前bucket大小是否爲如今一半,且其所指向的桶未遷移

而後就是按照bucket->overflow鏈表的順序去遍歷,直至找到tophash匹配且key相等的記錄(entry)

期間,若是key或者elem是轉過指針(size大於128),需轉回對應值。

map爲空或無值返回elem類型的零值

0x06 map的賦值

計算key的hash,拿到對應的桶

若是此時處於擴容期間,則執行擴容growWork

對桶bucket->overflow鏈表遍歷

  • 如有空桶(對應tophash[i]爲空),則準備在此空桶存儲k/v

  • 若非空,且和tophash相等,且key相等,則更新對應elem

  • 若無可用桶,則分配一個新的overflow桶來存儲k/v, 會判斷是否須要擴容

最後若使用了空桶或新overflow桶,則要將對應tophash更新回去, 若是須要的話,也更新count

0x07 map的刪除

獲取待刪除key對應的桶,方式和mapassign的查找方式基本同樣,找到則清除k/v。

這裏還有個額外操做:

若是當前tophash狀態是:當前cell爲空(emptyOne),

若其後桶或其後的overflow桶狀態爲:當前cell爲空前索引高於此cell的也爲空(emptyRest),則將當前狀態也更新爲emptyRest

倒着依次往前如此處理,實現 emptyOne -> emptyRest的轉化

這樣有什麼好處呢?

答案是爲了方便讀寫刪除(mapaccess,mapassign,mapdelete)時作桶遍歷(bucketLoop)能減小沒必要要的空bucket遍歷

截取代碼以下:

bucketloop:
  for ; b != nil; b = b.overflow(t) {
    for i := uintptr(0); i < bucketCnt; i++ {
      if b.tophash[i] != top {
        // 減小空cell的遍歷
        if b.tophash[i] == emptyRest {
          break bucketloop
        }
        continue
      }
    ...
  }
複製代碼

0x08 map的遍歷

先調用mapiterinit 初始化用於遍歷的 hiter結構體, 這裏會用隨機定位出一個起始遍歷的桶hiter.startBucket, 這也就是爲啥map遍歷無序。

隨機獲取起始桶的代碼以下:

r := uintptr(fastrand())
// 隨機數不夠用得再加一個32位
if h.B > 31-bucketCntBits {
  r += uintptr(fastrand()) << 31
}
it.startBucket = r & bucketMask(h.B)
複製代碼

在調用mapiternext去實現遍歷, 遍歷中若是處於擴容期間,若是當前桶已經遷移了,那麼就指向新桶,沒有遷移就指向舊桶


至此,map的內部實現咱們就過完了。

裏邊有不少優化點,設計比較巧妙,簡單總結一下:

  • 以2的對數存儲桶數,便於優化hash模運算定位桶,也利於擴容計算
  • 每一個map都隨機hash種子,減小哈希碰撞的概率
  • map以key的類型肯定hash函數,對不一樣類型針對性優化hash計算方式
  • 桶內部k/v並列存儲,減小沒必要要的內存對齊浪費;對於大的k/v也會轉爲指針,便於內存對齊和控制桶的總體大小
  • 桶內增長tophash數組加快單元定位,也方便單元回收(空桶)標記
  • 當桶8個單元都滿了,還存在哈希衝突的k/v,則在桶裏增長overflow桶鏈表存儲
  • 桶內若只有overflow桶鏈表是指針,則overflow類型轉爲uintptr,並使用mapextra引用該桶,避免桶的gc掃描又保證其overflow桶存活
  • 寫操做增長新桶時若是須要擴容,只記錄提交,具體執行會分散到寫操做和刪除操做中漸進進行,將遷移成本打散
  • 哈希表的裝載因子不知足要求是,擴容一倍,保證桶的裝載能力
  • 哈希表overflow桶過多,則內存從新整理,減小沒必要要的overflow桶,提高讀寫效率
  • 對指定不一樣大小的map初始化,區別對待,沒必要要的桶預分配就避免;桶較多的狀況下,也增長overflow桶的預分配
  • 每次遍歷起始位置隨機,嚴格保證map無序語義
  • 使用flags位標記檢測map的併發讀寫,發現時panic,必定程度上預防數據不一致發生

趁熱打鐵,建議你再閱讀一遍源碼,加深一下理解。

附上幾篇不錯的源碼分析文章,代碼對應的go版本和本文不一致,但變化不大,能夠對照着看。

歡迎關注公衆號留言交流。

菜鳥Miao

文章首發地址:blog.newbmiao.com/2020/02/04/…

相關文章
相關標籤/搜索