前言
map是一种快速存取键值对的数据结构,通常能在O(1)的时间内执行增删改查操作。业务中经常用其实现set去重数据,或对一组对象建立映射关系等。除了怎么使用,作为开发者最关心的还是其底层实现,其get,put,扩容怎么实现。不像其他语言(例如java)可以直接看,go需要看源代码编译成汇编代码后调了哪些底层方案。本文对map的原理做简要介绍,分析其定位key,扩容,遍历的实现
map实现
数据结构
在研究各种操作前,需要了解其数据结构,先上map的结构体定义(基于go1.13)
// 位于runtime/map.go
type hmap struct {
// 键值对的数量
count int
// 标记当前是否有协程在写,或遍历
flags uint8
// 桶数组长度 = 2 ^ B。当n=3时,桶数组长度=8
B uint8
noverflow uint16
// hash种子,新建map时生成
hash0 uint32
// 指向桶数组
buckets unsafe.Pointer
// 指向老桶数组,扩容时用
oldbuckets unsafe.Pointer
// 以及搬迁的桶个数,扩容时用
nevacuate uintptr
extra *mapextra // optional fields
}
hmap为new出来的map,底层调用/runtime/map.makemap生成,其存储了map的一些基本信息
hash种子参与计算key的hash值,由于每个map的hash种子都是随机生成,因此就算同样的key在两个map中大概率定位到不同的桶,关于随机性的必要性后文说明
可以看到有两个字段指向桶数组。不扩容时,hmap.buckets指向正常桶数组
扩容时,由于是渐进式扩容,需要同时保存老桶和新桶,因此buckets指向新桶,oldbuckets指向老桶,
两个桶同时用于查找,遍历等操作,当扩容完毕删除oldbuckets
桶的结构体定义如下:
type bmap struct {
// tophash,作用下文说明
topbits [bucketCnt]uint8 // bucketCnt = 8
keys [bucketCnt]keytype
values [bucketCnt]valuetype
pad uintptr
// 指向溢出桶
overflow uintptr
}
可以看到一个桶最多存储8个键值对,若超过8个元素都被定位到该桶时,需建新桶,挂到前一个桶的overflow上,以保证有位置存放元素
来一张整体图:
怎么定位key
根据key获取value,或者存储key,vlaue对都需先在在哈希表中定位key,然后返回value或将value存储到相应的位置,其流程如下:
- 计算hash(key),每种类型的哈希方法由系统初始化, string类型的key哈希函数为strhash,底层调用memhash,详见runtime/alg.go
假设生成的hash值如下(64位机器生成的hash值为64位):
11011010 | 0001010011101000111001101110111010011100111111010011 | 1010
- 根据哈希值定位应该在哪个桶
假设B=4,桶的数量为2^4 = 16,则hash值最后4位用来定位key应该在哪个桶,本应该是
hash % len(buckets)其等价于 len(buckets) ^ (hash后4位),由于取余操作不如位运算操作快,因此一般采用后一种方式,本例定位到10号桶
- 定位槽
当定位到在哪个桶后,需要进一步定位key在桶中哪个槽,一般的方式为挨个遍历比较,但go map为bucket中每个key生成了一个tophash,取值为hash值前8位。由于每个key的tophash大概率不同,因此可以先和tophash的每个值比较,若不等,则key一定不在该槽,若相等,则大概率在,但还需要进一步调用equal() 方法验证。比较整数比比较其他类型速度更快,因此可快速排除不相等的key,提高搜索key的性能
整体布局如下所示:
当tophash相等后,因为key,value皆以数组的方式存放,可根据其在topbits的下标直接通过内存地址计算出key,value的地址进行定位和后续操作
gin使用的路由框架httprouter也使用了类似的思想,当我们定义路由时,httprouter将其组织成压缩字典树的形式,从上到下保存每个节点的信息
例如 /api/order/get , 由3个节点分别保存 api,order,get,代表每级路由关系,若api下有多个路径,则api节点的子节点有多个,此时请求进入服务,需要挨个匹配路径。
httprouter在每个节点用indices存放了所有子节点的首字母,因此大多数情况下可以只比较一个字母进入下一级
- 下一个桶
当前桶遍历完没有key时,若bmap.overflow不为空,需要继续寻找溢出桶,定位方式和之前一样
用到溢出桶,需要一个桶8位塞满8个元素,这在其他语言发生概率较低,比如java的hashmap,默认装置因子0.75表示平均一个桶有0.75个元素就要发生扩容,但go map的扩容条件比较苛刻,装载因子为6.5,因此有一定的概率使用溢出桶
我们以代码val,ok := map[key]实际调用的方法mapaccess2() 来看看key定位流程,以下省略了一些无关代码,因此十分简短易懂
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) {
// 不能同时读写
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
// 获取计算key哈希的方法,每种类型的哈希方法由系统初始化,详见runtime/alg.go
alg := t.key.alg
// 计算hash值
hash := alg.hash(key, uintptr(h.hash0))
// 计算掩码
m := bucketMask(h.B)
// 定位在哪个bucket
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + (hash&m)*uintptr(t.bucketsize)))
// 计算tophash,关于tophash下面说明
top := tophash(hash)
bucketloop:
// 遍历bucket
for ; b != nil; b = b.overflow(t) {
// 遍历bucket每个槽
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))
}
// 若key相等,说明找到,返回value
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
}
}
}
// 没有找到key,返回零值和false
return unsafe.Pointer(&zeroVal[0]), false
}
存放键值对的操作和获取类似,先定位key的内存地址,再进行操作,区别在于存放元素时涉及到扩容逻辑
扩容
当数据结构元素太多,影响了增删改查速度甚至装不下时,需要通过扩容缓解
除此之外,go map中就算元素不多,但空桶较多,会触发等量扩容
判断是否触发扩容的逻辑如下
// 当前没有在扩容,且需要扩容
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again
}
// 元素个数大于8且元素个数大于 6.5 * 桶的数量
func overLoadFactor(count int, B uint8) bool {
return count > bucketCnt && uintptr(count) > loadFactorNum*(bucketShift(B)/loadFactorDen)
}
// 溢出桶过多
func tooManyOverflowBuckets(noverflow uint16, B uint8) bool {
if B > 15 {
B = 15
}
return noverflow >= uint16(1)<<(B&15)
}
元素过多好理解,溢出桶过多也是一种需要扩容的情况,此时哈希表中可能元素不多,但有大量桶,且有大量空槽,这种场景出现的原因一般是先大量添加元素,再大量删除,就会留下一些空洞,定位key需要额外扫描空的位置,降低性能,需要进行处理
hashGrow函数做一些扩容的准备工作,例如创建新的桶数组,若为元素过多,则将数组容量翻倍。否则是溢出桶过多,采用原桶容量(元素不多,不需要更大的容量,只是需要重新整合,消除溢出桶)
将新桶数组挂到buckets,老桶数组则转移到oldbuckets
Go map不是一次性扩容,而是渐进式扩容,每次搬迁2个桶到新桶上,其中一个是key所在的桶,和一个另外的桶。这样扩容的好处是将扩容的复杂度均摊到每次操作,保证在map操作上耗时稳定,缺点是实现复杂。实际扩容发生在赋值,删除操作时
扩容以桶为单位,具体来说是将老桶的元素转移到两个新桶,直到该桶及其溢出桶的元素都被转移完毕
扩容状态会影响mapaccess方法,若当前正在扩容中,且遍历的新桶对应的老桶还没被搬迁,则需要到老桶上找元素,如下所示
// 检查是否正在写
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
// ...
// 当前定位到的桶,默认是新桶
b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + (hash&m)*uintptr(t.bucketsize)))
// 老桶存在,说明正在扩容
if c := h.oldbuckets; c != nil {
if !h.sameSizeGrow() {
// There used to be half as many buckets; mask down one more power of two.
m >>= 1
}
// 定位到老桶
oldb := (*bmap)(unsafe.Pointer(uintptr(c) + (hash&m)*uintptr(t.bucketsize)))
// 老桶没有搬迁
if !evacuated(oldb) {
// 下面使用老桶
b = oldb
}
}
这里有个并发问题:如果决定使用老桶,同时cpu切换到另一个协程搬迁了老桶,则当前协程在老桶根据key定位不到数据,但事实上map中有该数据,只是被转移到新桶了。因此在并发场景下需使用sync.Map
另外若在扩容过程中,写入操作也有变化,写入之前会把该key对应的老bucket迁移,并将数据写入新桶,此时读取默认从新桶读,没有问题
遍历
一般的哈希表遍历比较简单,例如java的hashmap,从第一个桶的第一个元素挨个往后遍历就行,直到遍历完所有桶。但go的map比较特殊,体现在以下两点
- 随机性:每次for-range遍历map的顺序不一样
- 考虑扩容:由于是渐进式扩容,可能遍历的过程中同时扩容也在进行,有些bucket可能已经被搬到新map,有些没有。因此遍历时需要考虑在新老哪个map取数据,相反若扩容方式为一次性,则只用考虑在一个map上取数据
采用如下方式遍历map:
package main
import "fmt"
func main() {
m := map[string]int{
"beijing":1,
"shanghai":2,
}
for k,v := range m {
fmt.Println(k,v)
}
}
底层首先调用runtime.mapiterinit创建hiter对象
每次for循环调用mapiternext获取下一个kv键值对
hiter的作用为记录当前遍历到的kv,遍历到哪,下次该往哪遍历。详细数据结构如下
type hiter struct {
// key,value的地址
key unsafe.Pointer
elem unsafe.Pointer
t *maptype
h *hmap
// 当前遍历的所有桶
buckets unsafe.Pointer
// 当前遍历的桶
bptr *bmap
overflow *[]*bmap
oldoverflow *[]*bmap
// 初始化时的桶
startBucket uintptr
// 每次从桶的哪个位置开始遍历,适用于所有桶
offset uint8
// 是否遍历到末尾又转到第一个桶
wrapped bool
// 长度,2^B = len(map)
B uint8
// 当前遍历到所在桶第i个位置
i uint8
// 当前遍历到第几个桶,
bucket uintptr
// 如果在扩容过程中,需要检查的bucket,详见考虑扩容
checkBucket uintptr
}
和源代码对应关系如下:
随机性
go的map每次调用遍历结果顺序不一样,其实现为开始遍历的桶startBucket不一样,且遍历每个桶时开始位置offset也不同,若offset = 3,则每个桶遍历顺序为 [3,4,5,6,7,0,1,2]
// decide where to start
r := uintptr(fastrand())
if h.B > 31-bucketCntBits {
r += uintptr(fastrand()) << 31
}
// 同随机数生成startBucket和offset
it.startBucket = r & bucketMask(h.B)
it.offset = uint8(r >> h.B & (bucketCnt - 1))
为什么要使遍历顺序随机呢?go官方解释map遍历如下:
The old language specification did not define the order of iteration for maps, and in practice it differed across hardware platforms. This caused tests that iterated over maps to be fragile and non-portable, with the unpleasant property that a test might always pass on one machine but break on another.
总结起来是说以前的编程语言没有定义map的迭代顺序,不同平台的遍历顺序可能不一样,这导致基于特定顺序的代码在不同平台之间的运行结果可能不一致
另外map扩容后,一个bucket的数据会分散到两个bucket,也会导致顺序产生变化
因此为了防止程序员依赖特定的迭代顺序,从go1开始map的迭代就不可预测
考虑扩容
由于可以在每次遍历和写值可以交替执行,因此遍历和扩容有以下几种组合方式
- 遍历开始到结束没发生扩容
- 遍历开始前就正在扩容
- 遍历开始时没有扩容,过程中发生扩容
第一种比较简单,按照顺序遍历就行
第二种遍历扩容后的map,遍历的每个bucket都能对应到old map的一个bucket,首先先查对应老bucket是否已迁移,若已迁移,则遍历当前bucket就行,否则需要回到老bucket找数据。那是否把老bucket的数据取出来挨个遍历呢?
事情并没有这么简单,因为两个新bucket对应一个老bucket,若这里把老bucket数据遍历完,下次到另一个新bucket,又会遍历一次该老bucket,导致重复遍历
因此这里只是遍历该老bucket分裂到当前新bucket的数据,剩余的数据等下次再遍历
相关代码如下:
// checkBucket = 新map的bucket,也就是当前遍历的bucket
// 正在扩容且对应的老bucket没有迁移,且不是等长迁移
if checkBucket != noCheck && !h.sameSizeGrow() {
if t.reflexivekey() || alg.equal(k, k) {
hash := alg.hash(k, uintptr(h.hash0))
// 若老bucket的key未来不转移到该bucket,则continue
if hash&bucketMask(it.B) != checkBucket {
continue
}
第三种方式也比较特殊,初始化时hiter. B保存了当时的桶的个数,hiter.buckets保存了所有桶的指针,相当于生成快照。若为等量扩容,则在新数组上遍历,和组合二类似,否则在老数组上遍历
在老数组上遍历就涉及到,当前的bucket是否已被迁移,若没有迁移,那没什么好说的,直接遍历。有人会说,如果遍历过程中,其他协程的修改导致该桶扩容了,老桶被清空了怎么办?
其实不用担心,迁移当没有其他协程在遍历老桶,才会清空老桶
// 若没有协程在遍历老桶,才会清空
// 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)
}
但此时该key的数据可能被更新或删除了,但没有反应到老桶上,因此需要去新桶查找一番
// 老桶的数据没有被搬迁
if (b.tophash[offi] != evacuatedX && b.tophash[offi] != evacuatedY)
...
// 老桶的数据已被搬迁
} else {
// 去新桶查找
rk, re := mapaccessK(t, h, k)
if rk == nil {
continue // key has been deleted
}
it.key = rk
it.elem = re
}
但如果遍历该桶之前其数据就被清空了,则会出现找不到数据的情况,因此最好不要一边遍历一边修改map
总结
本文介绍了go map的数据结构,定位key的方案,涉及到使用索引,和httprouter进行类比。分析了渐进式扩容时怎么进行查找和遍历的方式,map最好在单协程使用,若需要并发读取使用sync.Map