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/…

相关文章
相关标签/搜索