3.哈希表map

map hash 冲突的常见解决方案

  1. 开放地址法

    • 放入元素,如果发生冲突,就往后找没有元素的位置

    • 底层数据结构就是数组

    • index := hash("xxx") % len(array); for (冲突) index++;

    • 开放寻址法中对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率达到 100%,整个哈希表就会完全失效,这时查找和插入元素的时间复杂度退化为 𝑂(𝑛) ,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。

  2. 链表法 / 拉链法

    • hashcode 相同的串成链表。Go、JAVA 采用的方法

    • 一般数据结构为数组+链表

  3. 再哈希

    • 用另一个方法计算 hashcode。布谷鸟过滤器使用的方法

  4. 公共溢出区

    • 发生冲突的元素,一律填入溢出表

数据结构

Go 语言运行时同时使用了多个数据结构组合表示哈希表,其中 runtime.hmap 是最核心的结构体,我们先来了解一下该结构体的内部字段:

buckets 是一个指针,指向的是一个 bmap 数组。下文将 bmap 称为 。每个桶里有 8 个位置可以放 keyvalue,下文称这 8 个位置为 格子.

这个结构有点像火车,每个 map 里有 2^B 辆火车 buckets,每条火车由一堆车厢 bmap 串联。每个车厢里 8 个座位。

源码里的 bmap 结构只有一行:

编译时会根据 map 的元素类型推导,这是实际编译以后的结构:

bmap 就是我们常说的桶,桶里面会最多装 8key。在桶内,又会根据 key 计算出来的 hash 值的高 B 位来查找 key,这高 B 位称为 tophash

如果桶里的元素要超过 8 个了,这时候需要在桶后面挂上 溢出桶 overflow bucket。溢出桶是在 Go 语言还使用 C 语言实现时使用的设计,由于它能够减少扩容的频率所以一直使用至今。但一般来说一个 map 中不会有太多溢出桶,因为桶中元素超过8个才挂溢出桶,而 Go 的设计里平均超过 6.5 个时就会触发扩容。

注意到 keyvalue 是各自放在一起 k/k/.../v/v/... 这样的形式,并不是 k/v/k/v/... 这样的。这样的好处是在某些情况下可以避免内存对齐,节省空间。

比如 map[int64]int8 这样的 map,如果按照 k/v/k/v/... 这样的模式存储,那在每一个 key/value 对之后都要额外对齐 7 个字节;而 k/k/.../v/v/... 这种形式则只需要在最后添加对齐空格。

img

初始化

使用 make 创建哈希,Go 语言编译器都会在类型检查期间将它们转换成 runtime.makemap。这个函数会按照下面的步骤执行:

  1. 计算哈希占用的内存是否溢出或者超出能分配的最大值;

  2. 调用 runtime.fastrand 获取一个随机的哈希种子;

  3. 根据传入的 hint 计算出需要的最小需要的桶的数量;

  4. 使用 runtime.makeBucketArray 创建用于保存桶的数组;

访问

在编译的类型检查期间,hash[key] 以及类似的操作都会被转换成哈希的 OINDEXMAP 操作,中间代码生成阶段会在 cmd/compile/internal/gc.walkexpr 函数中将这些 OINDEXMAP 操作根据一个还是两个接收参数转换成 mapaccess1mapaccess2 函数:

runtime.mapaccess1 会先通过哈希表设置的哈希函数、种子获取当前键对应的哈希,再通过 runtime.bucketMaskruntime.add 对哈希的低B位进行与操作,拿到该键值对所在的桶序号。

如果遇到 map 正在扩容的情况,那查到的桶需要可能是旧桶,则去旧桶里查找。

找到桶后,再取 hashcode 的高8位(tophash),遍历桶中的元素,逐个比较 tophash。如果 tophash 相同,再比较 key 的字面值

例如,现在有一个 key 经过哈希函数计算后,得到的哈希结果是:

用最后的 5bit00110 作为桶下标,也就是10进制下的 6 号桶。

再用哈希值的高 8 位,快速比较桶内各格子的 tophash。如果找到一样的,再进一步比较 key 的原值。

如果在桶中没找到,并且 overflow 不为空,则循环继续去桶链表里的下一个,即溢出桶中寻找。

为什么是 2^B 个桶?

这样取低 B 位可以直接通过与操作得到桶下标,计算简单快速,不用取模;扩容搬迁时也只需根据扩容多出来的二进制位,将原桶元素分流到两个,详见后面。

另外,根据 key 的不同类型,编译器还会将查找、插入、删除的函数用更具体的函数替换,以优化效率。由于提前知晓了 key 的类型,所以内存布局是很清楚的,能节省很多操作,提高效率。

扩容

时机

可以看到,一个桶后面可能串了好几个溢出桶。查找时需要先定位桶,再遍历溢出桶。如果溢出桶过多,会导致查询性能下降。因此,需要有一个指标来衡量前面描述的情况,这就是负载因子Go 里的 负载因子 = 元素个数 / 桶数

扩容的时机:在向 map 插入新 key 后,会进行条件检测,符合下面这 2 个条件,就会触发扩容:

  1. 装载因子超过阈值,源码里定义的阈值是 6.5

  2. 溢出的桶数量过多,超过正常桶数量或 2^15 个(插入很多元素、再删除时,可能导致溢出桶很多,即稀疏。因为删除元素时不会删桶)

    1. B < 15 && noverflow >= 2^B

    2. B >= 15 && noverflow >= 2^15

这两种条件下都会发生扩容。但是扩容的策略并不相同,毕竟两种条件应对的场景不同。

  1. 对于条件1,元素太多,而桶数量太少,很简单:将 B1,新申请一个 2^(B+1) 的桶数组,桶数量直接变成原来的 2 倍。这种扩容叫 增量扩容

  2. 对于条件2,其实元素没那么多,但是溢出桶数量特别多,说明很多桶都没装满。解决办法就是新建一个相同大小的新桶数组,将老桶数组中的元素移动到新 桶数组,使得同一个桶中的 key 排列地更紧密。这种叫 等量扩容。严格上说其实不算扩容,算整理碎片。

由于扩容需要将原有的 key/value 重新搬迁到新的内存地址,如果有大量的 key/value 需要搬迁,会非常影响性能。因此 Go map 的扩容采取了 渐进式扩容 的方式,类似 redis 的扩容,原有的 key 并不会一次性搬迁完毕。

为啥负载因子是 6.5 ?

太小会导致极易触发扩容,造成空间浪费;太多会导致极不易触发扩容,造成 overflow 过多。

作者测试了各负载因子下的平均 overflow 数、命中率、miss 率等指标,最终取了一个中间数 6.5。

扩容过程

扩容其实分为两步:hashGrow() 扩容 和 growWork() 搬迁。

hashGrow() 函数实际上并没有真正地搬迁,它只是分配好了新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上。

搬迁过程

搬迁的动作在 growWork() 函数中,而调用 growWork() 函数的动作是在 mapassignmapdelete 函数中。

也就是插入或修改、删除 key 的时候,都会尝试进行搬迁 buckets 的工作。先检查 key 所在的 oldbuckets 是否搬迁完毕,如果没有,则先对其进行搬迁。然后再检查其他搬迁状态过程中的桶,如果有,协助进行搬迁。

搬迁的关键函数是 evacuate

这里面比较主要的逻辑是增量扩容时的分流,比如原来桶中8个kv,增量扩容后分到2个新桶中,那么要解决2个问题:1. 如何定位新桶位置,2. 如何将原桶里的kv分流到2个新桶中。

如何定位新桶:比如原来有4个桶,扩容后变为8个桶,原来的1号桶变为新的1号和5号桶。这个5号地址怎么得呢,用原来的1号 + (旧桶大小) 就可得到

然后是旧1号桶里的元素如何摊到1和5号两个新的桶里:这里使用 2^B 作为掩码,和 hashcode 进行与操作,根据与结果是否为 0 判断落入1还是5号桶里。旧一号桶里的元素的 hashcode % 4 = 1,也即 hashcode & 3 = 1,就是说二进制后3位的与操作结果都是一样的,那么只需比较二进制第4位,就可以将他们分成两部分了。

搬迁期间,如果查询时发现 key 落入旧桶,则会从旧桶中查找。

遍历

理解了上面桶序号的变化,我们就可以回答另一个问题了:为什么遍历 map 是无序的?

  1. map 根本就没有维护 key 的顺序,计算 key 的桶时是用 hashB 位算的,本来有序的 keyhash 后就无序了。

  2. map 在扩容后,会发生 key 的搬迁。有的 key 在旧桶里,有的在新桶里。并且有的 key 桶序号也会变,会加上 2^B。因此,遍历、修改 map、再遍历,两次得到的遍历顺序就可能不一样了。

当然,Go 做得更绝,遍历 map 时,并不是固定地从 0 号桶开始遍历,而是通过 fastrand 算了个随机数,从一个随机桶开始,并且是从这个桶内的随机格子开始遍历。特意设计成了无序迭代的结果,以避免程序员依赖 map 有序遍历的结果。

key 计算 hash 值,根据 hash 值按照之前的流程,找到要赋值的位置。源码大体和查找的类似。核心还是一个双层循环,外层遍历 bucket 和它的 overflow bucket,内层遍历整个 bucket 的各个 cell。如果 key 上有值,就执行 update,否则执行 insert

函数首先会检查 map 的标志位 flags。如果 flags 的写标志位此时被置 1 了,说明有其他协程在执行写操作,进而导致程序 panic。这也说明了 map 对协程是不安全的。

另外通过前文我们知道扩容是渐进式的,如果 map 处在扩容的过程中,那么这次会先协助扩容,完了才向新桶里写数据。

插入元素后,会计算负载因子,并判断是否需要扩容。

在编译期间,delete 关键字会被转换成操作为 ODELETE 的节点,而 cmd/compile/internal/gc.walkexpr 会将 ODELETE 节点转换成 runtime.mapdelete 函数簇中的一个,包括 runtime.mapdeletemapdelete_faststrmapdelete_fast32mapdelete_fast64

删除逻辑和写类似,只不过删除逻辑是把元素置为 nil。并将 tophash 置为 已删除 状态。

删除并不会释放 cell,只是将 celltophash 标记为了 emptyOne 状态。

后续写入新元素时,可以复用 emptyOne 状态的 cell

和 redis 哈希表的异同

数据结构

rediskv 是包装在 dictEntry 结构里的,gokv 是长度为 8 的 kkkkkkkk/vvvvvvvv 数组。

冲突解决

均为拉链法。但 redis 每个桶只存1个元素,桶用链表相连。Go 里每个桶可以存8个元素,超过后才通过链表相连。因此 Go 的负载因子为 6.5,远大于 redis 的 1,难触发得多。

哈希查找

均为根据 hash 查桶。Go 里多了个通过 tophash 快速试错的优化。

扩容时机

redis:每个桶存储 >= 1 个 key 时扩容。<= 0.1 个 key 时缩容。(BGSAVE 时为了更稳定,变为 5,难以触发)

go:每个桶存储 >= 6.5 个 key 时扩容。溢出桶 >= 正常桶数量时 等量扩容。不支持缩容。

渐进式扩容

扩容期间查找时均为先查找老桶。判断老桶无数据才去新桶找。

redis:增删改查时均会触发。

go:仅在增删时触发。

总结

数据结构:Go 语言中,通过数组+链表实现 map,用链表法解决哈希冲突。利用将 8个 key / 8个value 依次放置的做法减少了对齐所需的空间。

查找:通过 key 的哈希值将 key 散落到不同的桶中。比如说有 2^5=32 个桶,就用哈希值的低 5 位判断落入哪个桶。再用哈希值的高 8 位和 key 值比对桶中元素

扩容:当向桶中添加了很多 key,造成元素过多(每个桶内的元素数超过 6.5),或者溢出桶太多(超过桶数量或 2^15),就会触发扩容。

  • 对前一种情况,加桶的个数就可以了,对应的是 2 倍容量的 增量扩容,扩容期间需要 rehash 重新分桶。

  • 后者是由于大量写入和删除元素造成的数据空洞,只需要重新整理下溢出桶里的数据就可以,称为 等量扩容

扩容过程是渐进的,主要是防止一次扩容需要搬迁的 key 数量过多,引发性能问题。触发扩容的时机是增加了新元素,搬迁的时机则发生在赋值/删除期间,每次最多搬迁两个 bucket

问题

删除掉map中的元素是否会释放内存?

不会,删除操作仅仅将对应的 tophash[i] 设置为 empty,并非释放内存。若要释放内存只能等待指针无引用后被系统 GC

map 的 iterator 是否安全?

for k, v := range map 会复制 mapkeyvalue,迭代期间修改 map 不会影响 range 里的 value,但会影响 map[k] 得到的数据。

map如何缩容?

go 里的 map 不会缩容,最多等量扩容。因此如果大量插入、再大量删除 key 的话,会浪费内存。

参考

Stefno - 深度解密Go语言之 map

健 の 随笔 - 你不知道的Golang map

tptppp - Redis和Go中map的异同

darveness - 3.3 哈希表

Last updated