golang note map

247 阅读4分钟

这是我参与更文挑战的第2天,活动详情查看:更文挑战

一段基本代码:

1  package main
2
3  func main() {
4      m := make(map[int]stringm, 16)
5      m[1] = "hello"
6      m[2] = "world"
7
8      v, ok := m[1]
9      _, _ = fn(v, ok)
10 }
11
12 func fn(v string, ok bool) (string, bool) {
13     return v, ok
14 }

特意给了行数,后期为了追踪函数

具体map中途进过什么函数,需要 go tool compile -N -S testmap.go | grep "CALL";来具体看看函数调用:

0x0071 00113 (testmap.go:4)     CALL    runtime.makemap(SB)
0x0099 00153 (testmap.go:5)     CALL    runtime.mapassign_fast64(SB)
0x00ea 00234 (testmap.go:6)     CALL    runtime.mapassign_fast64(SB)
0x0146 00326 (testmap.go:8)     CALL    runtime.mapaccess2_fast64(SB)
0x0207 00519 (testmap.go:6)     CALL    runtime.gcWriteBarrier(SB)
0x0218 00536 (testmap.go:5)     CALL    runtime.gcWriteBarrier(SB)
0x0222 00546 (testmap.go:3)     CALL    runtime.morestack_noctxt(SB)

这个就很清楚了:

  1. make -> makemap
  2. set -> mapassign,至于fast这个就看你的key类型,这个会在编译器确定
  3. get -> mapaccess2/mapaccess1,这个时候就有comma之分了,语法糖的事了,后面说
  4. 还有一个 写屏障,这个得后面分析(我也不知道为什么有这个)

Get

先从get来看整个结构。大致说一下流程:

  1. 先看map底层存储是否为 nil或是底层都没有数据,直接返回零值
  2. 判断读写冲突 -> concurrent map read and map write,经典错误了
  3. 计算hash值 <- (存储时key使用的hash算法对key进行hash计算,并加入 hash0 做扰动,增加随机性)
  4. 计算m值 <- (1 << B - 1,比如 B=5,m=31,二进制表达上全为1)
  5. 计算 bucket 地址 <- (需要上面的hash&m来确定桶序号:从 h.bucket 基地址出发)
  6. 检测 oldbuckets 是否为nil,有则说明在扩容ing;求一下key在 oldbuckets 中的桶序号
  7. 如果上述求的的 oldb 还没有迁移,则在 oldb 中开始循环查找

目前:确定了桶序号,那就在桶里面开始查询;如果出现hash冲突,则在 overflow bucket 中继续找

  1. 取出 hash 的高8位,作为tophash比较值(可以快速试错,高8位值会和bmap中 tophash[] 循环比较,用于快速试错(循环是廉价操作,而且比较的也只是数字)。如果都没有匹配,则进入当前桶的 overflowbucket)
  2. 匹配上,则定位 key/value 的位置(也是指针偏移操作,和前面找桶序号一致)
  3. 定位到 key 相等则继续定位 value;不相等说明hash冲突

上述就大致介绍了 get 的过程,具体代码:runtime.mapaccess1

总结一下:

  • 寻找桶slot -> hash(key) 的低B位(B是按照当时元素数量计算,对数计算)
  • 寻找slot中的 key_index -> hash(key) 的高8位,会击中头部的 tophash[] 某一个,击中哪一个index,就会偏移几个然后找到这个key/value

扩容

扩容发生在 Set 过程中,所以从 Set 中出发:

func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
	...
    // 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 !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
		hashGrow(t, h)
		goto again
	}
	...
}

可见扩容时间有两种:

  1. 装载因子 > 6.5(具体算法:13/2)
  2. overflowbucket 过多;这里会出现桶过多,元素很少,浪费空间

针对这两种,解决方案如下:

  1. 元素多桶少,简单就是扩桶:B = B+1newbucket = oldbucket_num*2;但是元素还在 oldbucket,需要迁移
  2. 元素少桶多,解决方式:开辟一个newbucket,将oldbucket渐进式移动到newbucket,桶数量没有发生变化

然后正式来看看扩容具体是怎么渐进式扩容(其实这里和 redis 差不多)。扩容入口在 runtime.hashGrow

  1. 先确定扩容标准:看是元素多,还是桶多 -> bigger = 1 ? 0
  2. 原先的 bucket 挂载成 oldbuckets
  3. 根据上面扩容标准申请新的 bucket 空间:h.B+bigger
  4. 最后将其他属性赋值上:
    • h.oldbuckets = oldbuckets
    • h.buckets = newbuckets
    • h.B += bigger -> grow 2倍
    • h.nevacuate = 0 -> 迁移进度(这个会作为后期的一个判断扩容的标志)
    • h.noverflow = 0 -> overflow bucket num
    • h.flags |= sameSizeGrow -> 这个会在 overflow 中被设置(这个在判断扩容标准的时候就被定义出来了,以便后面扩容时是否分出 XY 区做判断)

其实可以发现,整个过程只是准备空间,也就是前期准备工作完成。这个时候整个 hmapoldbucket && newbucket 都是有指向的。

然后开始真正的扩容,数据迁移在 runtime.evacuate 中完成的,它会对传入桶中的元素进行再分配:

同时从上面的分析来看,overflow 扩容是 samesizeGrow;另外一个就真的 doublesizeGrow

  1. 启动扩容是判断 h.oldbuckets != nil,为true -> growWork(),正式开启扩容;每次扩容移动2个 bucket;如果 oldbuckets != nil,那就还得继续这个扩容
  2. 为什么需要搬迁元素?是因为我们访问到这个元素所在 slot,发现他可以搬迁/扩容
  3. 首先定位到 h.oldbuckets 中哪一个 slot 要被扩容/迁移(定位 oldbucket slot)
  4. 这个时候就区分开 samesize || doublesize,会按照之前给定的 h.flags 来判断时哪个:
    • doublesize,会讲 double 后的 h.buckets,分成 XY
    • samesize,默认只使用 X
  5. 先说 doublesize
      1. 因为 h.B 此时已经是 oldB+1,同时我们现在是寻找新的 bucket_slot,按照之前的 低B位,我们需要比之前多前进1位
      1. 多前进1位的数字,就会决定该 key 被移动到 X|Y
      1. 如果是 0,说明没有动 slot_index,还是之前的位置(X区);反之(Y区)
  6. samesize:直接就是把之前在 overflow 中的移动到现有的 slot 下的 bmap

未完待续。。。(还差 Set, Delete)