文章目录
Dig101: dig more, simplified more and know more
在golang中,map
是一个不可或缺的存在。
它作为哈希表,简单易用,既能自动处理哈希碰撞,又能自动扩容或重新内存整理,避免读写性能的下降。
这些都要归功于其内部实现的精妙。本文尝试去通过源码去分析一下其背后的故事。
我们不会过多在源码分析上展开,只结合代码示例对其背后设计实现上做些总结,希望可以简单明了一些。
希望看完后,会让你对 map 的理解有一些帮助。网上也有很多不错的源码分析,会附到文末,感兴趣的同学自行查看下。
(本文分析基于 Mac 平台上go1.14beta1版本。长文预警 … )
我们先简单过下map实现hash表所用的数据结构,这样方便后边讨论。
0x01 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
会用于扩容,暂时先不展开
具体对应的数据结构详细注释如下:
(虽然多,先大致过一遍,后边遇到会在提到)
1 | // runtime/map.go |
这里有几个字段需要解释一下:
- 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扫描。
当map
的key
和elem
类型都不包含指针时,但其中的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
1 | var m = map[interface{}]int{} |
为什么不可以用[]int
作为key呢?
查找源码中hash的调用链注释如下:
1 | // runtime/map.go |
如上,我们会发现map的hash函数并不唯一。
它会对不同key类型选取不同的hash方式,以此加快hash效率
这个例子slice
不可比较,所以不能作为key。
也对,不可比较的类型作为key的话,找到桶但没法比较key是否相等,那map用这个key读写都会是个问题。
还有哪些不可比较?
cmd/compile/internal/gc/alg.go
的 algtype1
函数中可以找到返回ANOEQ
(不可比较类型)的类型,如下:
- func,map,slice
- 内部元素有这三种类型的array和struct类型
0x03 map的扩容方式
map
不可以对其值取地址;
如果值类型为slice
或struct
,不能直接操作其内部元素
我们用代码验证如下:
1 | m0 := map[int]int{} |
为什么呢?
这是因为map
内部有渐进式扩容,所以map
的值地址不固定,取地址没有意义。
也因此,对于值类型为slice
和struct
, 只有把他们各自当做整体去赋值操作才是安全的。 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装载能力最大化的一个的选择。
源码中扩容代码注释如下:
1 | // mapassign 中创建新bucket时检测是否需要扩容 |
具体扩容evacuate
(迁移)时,判断是否要将旧桶迁移到新桶后半区间(y
)有段代码比较有趣, 注释如下:
1 | newbit := h.noldbuckets() |
弄清楚map的结构、hash和扩容,剩下的就是初始化、读写、删除和遍历了,我们就不详细展开了,简单过下。
0x04 map的初始化
map不初始化时为nil,是不可以操作的。可以通过make方式初始化
1 | // 不指定大小 |
对于这两种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的读取有着三个函数,主要区别是返回参数不同
1 | mapaccess1: m[k] |
计算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遍历
截取代码如下:
1 | bucketloop: |
0x08 map的遍历
先调用mapiterinit
初始化用于遍历的 hiter
结构体, 这里会用随机定位出一个起始遍历的桶hiter.startBucket
, 这也就是为啥map遍历无序。
随机获取起始桶的代码如下:
1 | r := uintptr(fastrand()) |
在调用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
版本和本文不一致,但变化不大,可以对照着看。
本文代码见 NewbMiao/Dig101-Go
如有疑问,请文末留言交流或邮件:newbvirgil@gmail.com 本文链接 : https://newbmiao.github.io/2020/02/04/dig101-golang-map.html