Go map 底层原理:从「桶 + 溢出链」到 Swiss Table

Go map 底层原理封面
封面:Go map 从「桶 + 溢出链」到「Swiss Table」

Go 的 map 在 Go 1.24 换了一次底层实现:从「桶 + 溢出链」的链式哈希,换成 Google 的 Swiss Table(开放寻址哈希表)。官方基准:插入、查找、删除快约 20%–50%,迭代快约 10%,内存最多省 25%。这篇先讲老实现,再讲新实现,最后给对比。

老实现(Go 1.23 及更早):桶 + 溢出链

结构

map 的主体是 hmap,核心字段:

type hmap struct {
    count       int                 // 元素个数
    B           uint8               // 桶数量 = 2^B
    hash0       uint32              // 哈希种子,创建 map 时随机生成
    buckets     unsafe.Pointer      // 桶数组
    oldbuckets  unsafe.Pointer      // 扩容前的旧桶数组,迁移完为 nil
    nevacuate   uintptr             // 增量迁移进度:下一个要迁移的旧桶下标
    noverflow   uint16              // 溢出桶数量
    extra       *mapextra
}

每个桶是 bmap,固定装 8 个键值对,布局是编译期拼出来的:

type bmap struct {
    tophash [8]uint8 // 每个 key 哈希值的高 8 位
    // 接下来是 8 个 key 连续存放
    // 再是 8 个 value 连续存放
    // 最后是一个 overflow 指针,指向溢出桶
}

key 和 value 分开连续存放(不是 key1,val1 交替),各自按类型对齐,省内存。key 或 value 体积大、或含指针时,存的是指针。

查找流程

一次查找走四步:

  1. 用 hash0 种子和哈希函数算出 64 位哈希;
  2. 取哈希的低 B 位当桶下标,定位到桶;
  3. 用高 8 位(tophash)预筛:先比 1 字节的 tophash,命中再比完整 key;
  4. 8 个槽位都不中,顺着 overflow 指针去溢出桶继续找。

tophash 预筛是核心优化:大多数 key 不匹配,比 1 字节就淘汰,不用碰完整 key。

扩容

装载因子超过 6.5(count / 2^B > 6.5)触发扩容,分两种:

  • 翻倍扩容:桶数翻倍,多取一位哈希分散数据;
  • 等量扩容:桶数不变,把散在溢出桶的数据搬回前面排布,顺带清理溢出桶。

扩容是增量的:每次读写顺带迁移 nevacuate 指向的旧桶,全部搬完 oldbuckets 置空。扩容期间查找要先查新桶、不中再查旧桶。这样避免扩容瞬间卡顿。

删除只是把槽位标记为空,不立即释放内存,等扩容或 GC 回收。反复增删的大 map 可能内存居高不下。

老实现 hmap bmap 桶加溢出链结构图
图 1:老实现结构——hmap 指向桶数组,每个桶 8 个槽位,满了挂溢出桶

新实现(Go 1.24+):Swiss Table

提案是 golang/go #54766,字节跳动工程师 2022 年提出——当时实测 map 操作占服务 CPU 约 4%。实现基于 Peter Mattis 的 cockroachdb/swiss,即 Google Abseil 的 Swiss Table 同款设计,代码在 internal/runtime/maps 包。

结构

没有溢出桶。数据是一块连续的 group 数组,每个 group = 8 个槽位 + 8 字节控制字(control word):

group:
┌──────────────────────────────┐
│ 控制字 8 字节:每字节管一个槽位 │
│ 状态:空 / 已删除 / 已占用      │
│ 占用时低 7 位存哈希的 H2        │
├──────────────────────────────┤
│ 槽位 0 ~ 7:8 个 key/value     │
└──────────────────────────────┘

哈希分成两段:H1 是高 57 位,用于定位 group;H2 是低 7 位,存进控制字节。

查找

  1. 按 H1 定位到 group;
  2. 在 group 内开放寻址探测,用 SWAR/SIMD 一次并行比较 8 个控制字节里的 H2;
  3. 命中 H2 的槽位再比完整 key。

大多数查找只碰控制字节,完全不读 key。这是性能提升的主要来源。

删除:tombstone

删除不直接清空,而是标记成 tombstone(墓碑)。开放寻址的探测序列依赖「连续的已占用槽位」,直接清空会截断别人的探测路径。墓碑攒到超过容量约 10% 时统一清理(pruneTombstones)。

装载因子与扩容

最大装载因子 7/8:每 group 最多 7 个元素,必须留 1 个空位保证探测能终止。比老版「每桶 6.5 个 + 溢出桶」更紧凑,这是内存减少的原因。

增量扩容的思路和老版完全不同。开放寻址的探测序列依赖 group 总数,一张表要重排就得整体重建,没法像老版那样按桶搬。Swiss Table 用可扩展哈希(extendible hashing)解决:

  • 一个 map 是多张表,目录(directory)按哈希的高位索引指向各表;
  • 每张表独立管理自己的装载因子;
  • 表容量到 1024 个 group 后不翻倍,而是拆成两张表,目录加一个索引位。

扩容变成局部操作:只有被拆的那张表要重排,其余表不动。

Swiss Table 目录与 group 结构图
图 2:Swiss Table——目录 + 多张表(extendible hashing),group 由控制字节 + 8 个槽位组成

对比

维度老实现(≤1.23)Swiss Table(≥1.24)
冲突处理链式:溢出桶链表开放寻址 + 探测序列
单元容量8 槽 + 溢出链group 8 槽,无溢出
预筛方式tophash 高 8 位控制字节 H2,SWAR 并行比 8 个
装载因子6.5 / 桶7 / 8
删除标记空位tombstone + 定期清理
扩容翻倍/等量 + 双数组迁移目录 + 表拆分(extendible hashing)
内存溢出桶碎片连续紧凑,省 0–25%
性能基线增删查快约 20–50%

行为特性一条没变:遍历依然无序且随机、并发写依然 panic、map 依然不能取地址、键依然要求可比较。sync.Map 用自己的独立实现,不受这次替换影响。

参考资料

  • golang/go #54766:Swiss Table 提案,https://github.com/golang/go/issues/54766
  • golang/go #70849:小 map 性能回退修复
  • golang/go #80428:ixmap 实验,评估可扩展哈希是否再换实现
  • 源码:go.dev/src/internal/runtime/maps/
滚动至顶部