Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > Go1.24 map重构

一文带你彻底读懂Go 1.24 map重构了哪些

作者:墨贤|码录

Go中的map是一种高效的内置哈希表实现,用于存储键值对,了解map的底层实现原理对于编写高性能的Go程序非常重要,这篇文章主要介绍了Go1.24 map重构了哪些的相关资料,需要的朋友可以参考下

声明: 本文数据源于官方原文档

很多人聊 Go map,还停在那套老答案上:

hmapbucket、每个桶 8 个槽位、满了挂 overflow bucket、扩容时搬桶。

这套说法在很长一段时间里都没问题。可如果你现在还只会这么讲,那面对 Go 1.24 的 map,就已经不够了。

因为这次改的不是 API,而是底层组织方式。表面上你还是在写:

m := map[string]int{}
m["go"] = 124

可 runtime 里那张“哈希表”已经不是很多人熟悉的那张表了。

所以这篇文章,我只想把一条主线讲清楚:

如果你读完之后,能把“Go 1.24 map = Swiss Table + extendible hashing 风格增长 + 语义兼容处理”这句话真正理解明白,这篇就值了。

注:Swiss Table 起源于 Google/Abseil 的高性能哈希表工程实现,因 C++ 版本出名

1. 为什么 Go 要重写 map

先说结论:旧版 map 不是不能用,而是继续往上做,越来越难。

旧版设计的核心问题,不在于它“查得不快”,而在于它在冲突、高负载、扩容和遍历语义这几件事上,包袱越来越重。

最典型的一个包袱,就是 overflow bucket

它的存在当然有价值。桶满了,总得先有地方放。但问题也正出在这里:一旦冲突集中,查找路径就会从“在一个桶里看几眼”慢慢变成“顺着链往后跳”。

经常写算法的都知道:这就相当于把O(1)的查找速度,硬生生的干成了O(n)。
这时候 CPU 不喜欢,cache 也不喜欢,延迟更不会喜欢。

对后端来说,这不是教科书里的小瑕疵,而是线上会遇到的真问题:

所以 Go 1.24 的这次重构,本质上不是“换个更新潮的名词”,而是一次针对旧瓶颈的结构性调整。

2. 旧版 map 到底卡在哪

要理解新版,先得知道旧版到底卡哪。

经典 Go map 可以粗略理解成两层:

每个 bucket 最多放 8 个槽位。冲突多了,就往后挂 overflow bucket

这套设计的问题,不是功能不完整,而是几个成本会一起冒头。

第一,指针跳转多。

主 bucket 还算连续,overflow 一挂上去,访问路径就不再那么“平”。现代 CPU 很吃缓存局部性,这种链式跳转会让命中率变差。

第二,miss 成本变高。

查找一个不存在的 key,本来应该尽快停下。可一旦 overflow 多,你得继续探、继续跳、继续比,空查也会被拖慢。

第三,扩容很难做得既快又稳。

旧版要靠 oldbuckets + nevacuate 渐进迁移,把一次性重分布拆散到后续操作里。这套办法很聪明,但也说明了另一件事:增长这件事本身已经很重了。

第四,删除和整理会留下历史包袱。

只要结构里有冲突链、删除标记、迁移状态,时间一长,表就会越来越不像一张“干净的表”。

一句话概括旧版痛点:

旧版 map 的主问题,不是不能冲突,而是冲突后的代价越来越依赖 overflow 链,结构会越跑越重。

3. 新版整体结构:map -> directory -> tables -> groups -> slots

Go 1.24 的新实现,第一眼最容易看错的地方是:它不是“把 bucket 改名成了 Swiss Table”。

更准确地说,它是分层结构:

Map
  -> Directory
    -> Table
      -> Group
        -> Slot

这几个词分别可以这样理解:

这就是新版 map 跟很多人印象中最大的不一样:

它不是“一张大表加一堆 overflow 桶”,而是“顶层 directory + 底层多个 table”的分层结构。

我这里做一个对比:

旧版本:

主表
[桶0] [桶1] [桶2] [桶3] ...
桶2满了
[桶2] -> [overflow1] -> [overflow2]

Go 1.24 之后

directory
  ├── 指向 table A
  ├── 指向 table B
  ├── 指向 table C
  └── 指向 table D
table A: 存一部分 key
table B: 存一部分 key
table C: 存一部分 key
table D: 存一部分 key

把原来“一张总表”拆成了“directory + 多个底层 table”的两层结构。

这个拆分带来的最大收益是:

以前

容量不够时,只能想:

我把整个房子推倒重建

现在

容量不够时,可以想:

只是某个房间太挤了,我把这个房间隔成两个房间

所以 “局部 split” 本质是:

顺带一提,小 map 还有专门优化。元素很少时,路径会更短,不需要一开始就把目录层玩得很重。这一点很工程化,因为真实业务里小 map 并不少。

这件事在官方源码里说得很直白:如果一个 map 始终不超过 8 个元素,它可以直接塞进单个 group,dirLen 会保持为 0,连真正的 directory 都不用展开。并且这种 small map 不会留下 deleted slot,因为它本身就没有需要维护的 probe 链。

换句话说就是:由于这种小 map 的查找不依赖复杂的探测链,所以删除元素时也不需要保留“已删除但不能当成空位”的墓碑标记。

4.group / control word / H1 / H2各自负责什么

如果说旧版 map 的标志性结构是 bucket + tophash,那新版的标志性结构就是 group + control word

先看 group。

一个 group 里有 8 个 slot。这个“8”很关键,因为它既是存储单位,也是探测单位。查找时不是一个槽一个槽慢慢问,而是先看这一组里谁有可能命中。

再看 control word

它本质上就是 8 字节,对应这 8 个 slot。你可以把它理解成一块“组内导航板”:

这就引出 H1 / H2 的分工。

一个 key 做完哈希之后,不是整个哈希值一起乱用,而是拆成两部分:

注意,H2 不是最终判等。

它只是“先帮你排除大部分不可能命中的槽位”。真正命中了候选 slot,最后仍然要做完整 key compare。

这一步非常重要。因为真正贵的,不是看一个字节状态,而是去比完整 key。尤其是字符串 key、长结构体 key、或者需要间接访问的 key。

所以新版 map 的一个核心收益,就是把“重比较”尽量往后推,只在必要的时候做。

5. 一次查找到底怎么走

把上面的概念连起来,一次查找路径就清楚了。

先给一个压缩版流程:

hash(key)
  -> 拆成 H1 / H2
  -> 用 H1 选 directory 和 table
  -> 按 probe sequence 找到目标 group
  -> 读取 control word,批量过滤候选 slot
  -> 对候选做完整 key compare
  -> 命中则返回;遇到 empty 可停止

这里最值得记住的,不是步骤顺序,而是新版 map 的查找哲学变了:

先批量过滤,再做少量精确比较。

这跟旧版 overflow bucket 的思路很不一样。

旧版冲突多了,本质上是在“往后接更多位置”;而新版更强调“在连续空间里更快地判断哪些位置值得看”。一个是链式补救,一个是探测优化。

为什么这会更适合现代 CPU?

因为 group 是连续的,control word 也是连续的,查找路径更容易保持在局部内存里完成。对 cache 更友好,对 miss 的处理也更利落。

还有一个细节值得在这里强调一下:

遇到 empty 就可以停,是开放寻址类结构里很关键的终止信号。

这句话短,但很能体现你不是只记了几个术语。

6. 为什么扩容方案不是旧版搬桶,而是 split

旧版 map 扩容,难点在“搬旧桶”。

新版 map 还是要增长,但增长方式变了。它不再默认把一整张大表一起重分布,而是更接近 extendible hashing 的思路。

官方源码里对这件事给得非常明确:
一个 map 会先从单个 table 起步;在单 table 容量还没到上限前,增长就是把这个 table 扩成更大的 table;超过上限之后,才不再继续把同一张表做大,而是把它 split 成两张 table。当前这个单 table 上限是 1024 个条目。

这里有两个关键字:

注:(一个map对应一个directory,一个directory可以对应多个目录项,也就是全局索引)

可以先这样理解:

这意味着什么?
意味着多个 directory index 可以共享同一个 table。只有当某个 table 压力真的上来了,才需要把这个 table 拆成两个,也就是 split

如果局部拆分之后,directory 现有位数还够,就只更新部分映射关系。
如果不够,再扩 directory。

这跟旧版“整张表一起翻倍,然后慢慢 evacuate”是两种味道。

新版增长更像:

顶层 directory 负责分流,局部 table 负责独立增长,必要时再做 split。

这么做的好处很现实:

这也是为什么很多材料会说:Go 1.24 的 map,不只是用了 Swiss Table,还把增长方案一起重做了。

7. 为什么 iteration 最难,以及 Go 1.24 如何维持语义

如果只做查找和插入,新版 map 其实已经很好理解了。真正麻烦的,是 iteration。

因为 Go 对 range map 的语义不是“随便遍历一下就行”,而是有实际约束的。典型地说:

问题在于,map 底层这时候可能正在变化:

所以 iteration 难,不是因为“遍历很慢”,而是因为:

你要在底层结构持续变化的前提下,尽量维持 Go 语言层面对遍历结果的承诺。

原则上:Go 1.24 的处理思路可以概括成两步:

如果元素已经删除,就跳过。
如果元素还在,但内容更新了,就返回新值。

这就是为什么很多人说:新 map 最难的不是查找,不是扩容,而是 iteration 语义。

当然这里也有一个容易被误解的点:

并发语义没有变化。

底层重构了,不代表原生 map 就变成并发安全了。它依旧不支持并发写,读写并发依旧不安全,照样可能触发运行时问题。

新版改变的是组织方式,不是并发模型。

8. 工程结论:哪些收益是真的,哪些点最容易讲错

先说收益。

如果只看 map 内核本身,这次重构带来的方向很明确:

所以说,Go 1.24 之后的map,相较于老map:

以下是官方给的数据:

官方原话:

其中Go 1.24发布的时候也提到了:

9. 自测

第一,它不是一张单独的 Swiss Table。

Go 1.24 的 map 是分层结构,顶层有 directory,下面是多个 table

第二,H2 不是最终判等。

它只是组内快速过滤,最后仍然要做完整 key compare。

第三,删除不等于直接变成 empty。

需要 tombstone 这一类状态来维持 probe 链的完整性。

第四,新版不是旧版 oldbuckets 搬迁模型的换皮。

它的增长思路更接近 extendible hashing 风格的 split。

第五,并发安全边界没有变。

不要把“底层重构”误讲成“原生 map 更适合并发写了”。

一分钟复盘:

Go 1.24 之前,经典 map 的核心是 hmap + bucket + overflow bucket。它能工作,但冲突多时会出现 overflow 链,缓存局部性和查找路径都会变差。Go 1.24 把 map 底层重构成了分层结构:顶层是 directory,下面挂多个 table,table 内部采用 Swiss Table 风格的 group + control word + open addressing。查找时先把 hash 拆成 H1 / H2H1 负责选 table 和 probe,H2 负责组内快速过滤,最后再做完整 key compare。增长方式也不再是旧版整表搬迁那套思路,而是更接近 extendible hashing 的局部 split。最难的是 iteration,因为底层结构在变,但 Go 还得维持既有遍历语义。要注意的是,这次重构提升的是性能和组织方式,不是并发语义,原生 map 依旧不支持并发写。

深度思考:

参考资料

到此这篇关于读懂Go1.24 map重构了哪些的文章就介绍到这了,更多相关Go1.24 map重构内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文