Go map:从 hmap 到 Swiss Table

Go map:从 hmap 到 Swiss Table

•约 4.4 千字

Go 的 map 太常用了,以至于很多人不再把它当作数据结构。写一行 m[key],编译器和 runtime 就替我们完成了哈希、探测、比较、扩容和内存管理。代码很短,代价却不短。

更麻烦的是,网上关于 Go map 的文章经常混着两个时代:Go 1.23 及以前的 hmap、bucket、overflow bucket;Go 1.24 起默认启用的 Swiss Table、group、control word、table 和目录。两套实现都服务于同一个语言语义,但底层路线已经不同。继续拿旧图解释新 runtime,像拿老城区地图导航新机场:方向可能差不多,出口一定会错。

本文先讲 map 作为语言类型的承诺,再分两条实现线解释底层结构,最后把哈希冲突、扩容、缓存、GC、并发和高并发选型放在同一张图里。先给结论:map 是高效的单进程索引结构,不是并发容器,也不是数据库;性能优化的关键通常是减少共享、控制容量和选择合适的数据布局,而不是背出某个桶里有几个槽位。

一、语言层的 map:方便,但有边界

最简单的 map 是:

go
scores := make(map[string]int)
scores["alice"] = 100
value, ok := scores["bob"]
delete(scores, "alice")

map 的 key 必须是可比较类型。布尔、数字、字符串、指针、channel、interface,以及只包含可比较字段的数组和结构体都可以作为 key;slice、map 和 function 不能直接作为 key。value 可以是任意类型,包括另一个 map、函数或大结构体。

零值和初始化要分清:

go
var a map[string]int
_ = a["missing"] // 可以读取,得到零值
delete(a, "missing") // 可以删除
a["x"] = 1 // panic:写入 nil map

nil map 的读取和删除是安全的,写入则会 panic。这个设计让只读配置可以自然地使用零值,但也让「字段没初始化」在第一次写入时才暴露。对需要写入的字段,通常在构造函数中 make;对可选 map,写入前明确初始化。

map 的读取有两种结果:value,或者 value 加一个布尔值。后者用于区分「不存在」和「存在但值恰好是零值」:

go
count, exists := counters[key]
if !exists {
    count = 0
}

迭代顺序不保证稳定。每次 range 可能从不同位置开始,随机化是为了避免程序偷偷依赖内部布局,也降低哈希攻击者通过观察顺序推断结构的机会。迭代期间删除、更新和新增有语言规定的特殊语义,但不要把它当作并发迭代协议:同一 map 被另一个 goroutine 同时写,仍然是错误。

二、旧实现:hmap、bucket 与渐进式扩容

在 Go 1.23 及以前的经典实现里,一个 map 值可以理解成指向 runtime.hmap 的指针。hmap 保存计数、桶数量的对数、哈希种子、旧桶和扩容进度等元数据;真正的 key/value 存放在 bucket 数组中。

概念上的结构大致是:

go
type hmap struct {
    count     int
    B         uint8
    hash0     uint32
    buckets   unsafe.Pointer
    oldbuckets unsafe.Pointer
    nevacuate uintptr
}

这不是稳定的公开 API,而是帮助理解旧 runtime 的示意。B 表示桶数量为 2^B,hash0 是每个 map 独有的随机种子,oldbuckets 和 nevacuate 用来支持渐进式扩容。

一个 bucket 里装什么

旧实现的 bucket 通常包含三部分:

  1. 8 个 tophash 字节,保存每个 key 哈希值的高位摘要;
  2. 连续排列的 8 个 key;
  3. 连续排列的 8 个 value,以及指向 overflow bucket 的指针。

为什么 key 和 value 不交错?因为把相同类型的数据放在一起,有利于对齐、减少填充,也让 runtime 能根据类型信息计算地址。查找时先算完整哈希,用低位选择桶,再用 tophash 快速排除大部分不可能的 key,最后才做完整 key 比较。

一个 bucket 放满之后,会链接 overflow bucket。冲突多时,查找会沿着 overflow 链继续。链越长,指针追踪越多,缓存局部性越差。旧实现的性能因此不仅取决于 O(1) 的平均复杂度,还取决于负载因子、哈希分布、key/value 大小和 overflow 数量。

旧实现如何扩容

当桶太满或 overflow 过多时,map 会扩容。扩容不把所有元素一次搬完,而是在后续写入、删除或查找过程中逐步 evacuation:旧 bucket 被搬到新 bucket,nevacuate 记录下一个需要迁移的位置。

渐进扩容避免了某一次插入突然复制整个大 map,但代价是扩容期间同时存在 oldbuckets 和 buckets,读写路径要判断当前 bucket 是否已经迁移。一个看似普通的写入,可能顺便承担搬运工作。高尾延迟敏感服务中,预估容量依然有价值:

go
users := make(map[int64]*User, 1_000_000)

容量提示不是硬性预留,也不是保证最终只占这么多内存;它只是让 runtime 更早知道规模,减少反复增长。

三、新实现:Go 1.24 起的 Swiss Table

Go 1.24 引入了基于 Swiss Table 的全新内置 map 实现,Swiss Table 仍然是哈希表,却从「链式 bucket」转向「开放寻址 + 分组控制字」。Go runtime 的 internal/runtime/maps 源码把术语分得很清楚:

  • slot:一个 key/value 存储位置;
  • group:8 个 slot 加一个控制字;
  • control word:8 个控制字节,表示 slot 为空、删除或已使用,并保存哈希的低 7 位;
  • table:一组 group 以及扩容元数据;
  • map:一个或多个 table;
  • directory:根据哈希高位选择 table 的目录。

H1、H2 与一次并行筛选

新实现把哈希拆成两部分。高 57 位称为 H1,用来选择初始 group 或 table;低 7 位称为 H2,写进 control word。查找一个 key 时,runtime 先定位 group,再把目标 H2 复制成 8 个字节,与 control word 一次做并行比较,找出可能匹配的 slot,最后才比较完整 key。

这一步的关键不是「一次比较 8 个 key」。控制字只保存摘要,H2 相同不代表 key 相同,完整比较仍不可省略。它做的是把大量不可能命中的 slot 在进入昂贵的 key 比较前筛掉。amd64 上可以利用 64 位算术和 SIMD 相关指令,缺少特定 SIMD 时也有可移植的位运算实现。

开放寻址让 key/value 和控制信息更紧凑,减少 overflow 指针追踪,也提高缓存局部性。但表越满,探测序列越长;删除后的 tombstone 也会占据控制状态。Swiss Table 不是「永不冲突」,只是用更适合现代 CPU 的方式处理冲突。

为什么 Go 没有直接照搬 Abseil

传统 Swiss Table 常在整张表上一次性扩容,Go 不愿意让一个写入突然复制 1 GB 数据。Go map 需要增量控制单次操作对尾延迟的影响,因此一个 map 由多个 table 组成,每张 table 服务一部分哈希空间。

目录使用 extendible hashing:哈希高位选择 table,随着 table 数量增加,目录使用的位数增加。小 map 可以把单个 group 直接放在目录指针指向的位置;更大的 map 才逐渐建立目录和多个 table。某个 table 需要增长时,只影响这一小部分哈希空间;超过单 table 的上限后,table 会 split 成两张。

这条设计把「开放寻址必须整表重排」和「Go 需要渐进扩容」拼在了一起。代价是多了一层 directory/table 间接访问,也让迭代语义、table split 和旧表保留变得复杂。

迭代为什么仍然棘手

Go 语言允许在 range 期间删除、更新,也允许新增元素可能出现或不出现。新 Swiss Table 在扩容或 split 时不能简单地只走新数组,否则可能读到过时值或漏掉语义上应该看到的更新。

runtime 的做法是让迭代器保留正在遍历的旧 table 来维持遍历顺序,同时在真正返回 key/value 前去新 table 检查它是否仍存在、是否已更新。新增元素可以被漏掉,删除和更新则必须尽量符合语言规范。迭代是 map 实现最容易被低估的复杂部分。

四、两代实现的共同核心:哈希、比较和内存

底层结构变化,不代表 map 变成魔法。两代实现都要完成相同的工作:

  1. 根据 key 类型计算哈希;
  2. 用哈希定位候选桶、group 或 table;
  3. 快速过滤不匹配的候选;
  4. 做完整 key 比较;
  5. 读取、更新或插入 value;
  6. 在容量、删除标记或负载达到阈值时增长。

哈希种子通常按 map 独立生成。两个 map 即使有相同 key,内部布局也不应假设相同。字符串、整数、指针和结构体的哈希路径不同;interface key 还要先处理动态类型和可比较性。使用巨大的结构体作为 key,会把每次比较的成本带进查找热路径。

key 的选择往往比 map 本身更重要:

  • 用短字符串做 key,通常比拼接长字符串再哈希更省;
  • 多维查询可以考虑结构体 key,避免嵌套 map 和多次指针跳转;
  • 可用整数 ID 时,不要为了可读性把热路径全部换成长字符串;
  • value 很大时,考虑存指针,但要计算额外分配、间接访问和 GC 扫描成本;
  • value 很小时,连续存储和减少指针往往更友好。

map 的 O(1) 是平均意义上的复杂度,不是每次访问都固定耗时。哈希函数、冲突、内存层次和 GC 都可能改变实际成本。

五、内存与 GC:map 不是只有 key/value

map 的内存账单包括控制信息、桶或 group、目录、扩容中的旧结构、tombstone、key/value 本体,以及由指针触发的 GC 扫描。删除一个元素不等于立刻把底层容量还给操作系统。长期增长又删除的缓存,可能保留很大的地址空间和内部表。

如果 map 的 value 是指针,GC 需要沿着这些指针扫描对象图;如果 key 和 value 是不含指针的紧凑类型,runtime 在某些路径上可以减少扫描负担。用 map[string]*Object 还是 map[string]Object,不应只按复制成本决定,也要看对象生命周期、逃逸和 GC 压力。

清空策略也有取舍:

go
clear(cache) // 保留 map 的容量,适合后续继续复用
cache = make(map[string]Item) // 让旧 map 变得不可达,等待 GC 回收

如果缓存周期性清空后会重新填到相近规模,复用容量可能减少分配;如果峰值很大、之后长期空闲,重新建 map 才可能释放长期占用。不要仅凭 len(cache) 判断内存已经释放,应该结合 heap profile 和 RSS 观察。

Go 1.24 的 map 改善了局部性和某些内存占用,但不会改变大 value、大量指针和无限缓存带来的基本账单。数据结构优化不能代替容量上限和淘汰策略。

六、并发:读多也不等于随便读

普通 map 不是并发容器。多个 goroutine 并发读,如果没有写入,通常可以安全运行;一旦存在并发写,或者一个 goroutine 读、另一个 goroutine 写,就必须同步。runtime 可能直接报告 fatal error: concurrent map read and map write 或 concurrent map writes;即使某次没有报错,也不代表程序正确。

最小保护方式是 Mutex:

go
type Store struct {
    mu sync.RWMutex
    data map[string]Value
}

func (s *Store) Get(key string) (Value, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.data[key]
    return v, ok
}

RWMutex 不是读多就一定更快。读临界区很短、写入存在、读者数量高时,读写锁的计数和写者排队也有成本。单把大锁会把所有 key 串行化,分片锁则按哈希把访问拆到多个 shard:

go
type Shard struct {
    mu sync.RWMutex
    m  map[string]Value
}

分片不能解决跨 key 事务,也不能解决热点 key。一个被请求打爆的 key 无论落在哪个 shard,仍然只有一条通道。

sync.Map 适合特定场景:键集合相对稳定且读远多于写,或不同 goroutine 操作的 key 集合彼此分离。它不是普通 map 加锁后的通用升级版。Go 1.24 还改进了 sync.Map 的实现,减少大 map 上不相交 key 修改之间的争用;这不意味着可以跳过基准测试,更不意味着它适合所有缓存。

七、高并发的五层限制

1. 共享状态的串行上限

一把锁保护一个全局 map,CPU 再多也只能让一条临界区代码同时运行。把锁换成更快的锁,不能突破共享状态的串行上限。第一选择通常是分片、局部缓存、不可变快照或单所有者模型。

2. hash 热点和 key 倾斜

均匀哈希只说明平均分布,不保证业务流量均匀。热门租户、热点商品或固定配置 key 会把访问集中到同一 shard、同一 table 或同一 cache line。解决办法包括热点复制、只读快照、分层缓存和请求合并,而不是盲目增加 shard 数。

3. 扩容和内存峰值

旧 hmap 的渐进 evacuation 和新 Swiss Table 的 table growth 都在控制单次操作成本,但扩容仍需要额外内存、复制或重排。一个接近容量上限的 map 可能在高峰期同时承担业务写入与增长工作。容量提示、预热、限流和后台迁移可以降低风险,但没有免费扩容。

4. 调度与惊群

大量 goroutine 争同一 map 的锁,等待队列会比 map 本身更大。请求超时后统一重试,会产生惊群:大家一起醒来、一起读写、一起竞争。队列长度、并发上限、连接池和退避策略应在 map 外面设计。

5. 进程边界和数据一致性

map 只保护本进程内存。多实例服务各自持有一份 map 时,缓存失效、写入顺序和一致性就变成分布式问题。需要全局唯一约束或跨实例协调时,应使用数据库约束、消息系统或专门的分布式协调机制。把多个本地 map 拼起来,不会产生一个全局 map。

八、不要用 unsafe 猜底层布局

旧文章常通过 unsafe 把 map 转成 hmap,读取 B、buckets 或 nevacuate。这个办法可以帮助学习旧 runtime,却不应进入业务代码。Go 1.24 起默认实现已经换成 internal/runtime/maps,hmap 时代的偏移、字段和桶布局不再适用;即使当前版本能跑,升级后也可能静默读错内存。

反射和 unsafe 也不能绕过 map 的并发规则。运行时内部用 writing 标志检测并发写,检测到问题会直接终止进程;这不是给业务提供的可恢复异常。想知道 map 的真实成本,应使用 benchmark、heap profile、mutex/block profile 和 runtime trace,而不是依赖未导出的结构体。

九、怎样做有意义的 benchmark

至少准备四类对照:

  • 小 map 与大 map;
  • 预分配容量与逐步增长;
  • 整数 key、短字符串 key、长字符串 key;
  • 指针 value、无指针 value;
  • Go 1.23 旧实现与 Go 1.24 Swiss Table;
  • 单 goroutine、读并发、读写并发和热点 key。

测试时固定 Go 版本、GOARCH、CPU 频率策略和数据分布。不要只看 ns/op,还要看 allocations/op、堆大小、GC CPU、P99 延迟和吞吐。Go 1.24 官方 Swiss Table 文章报告,在其微基准集合中部分操作最高可比 Go 1.23 快 60%,但完整应用的几何平均 CPU 改善约为 1.5%,且不同场景可能回退。这个数字适合说明趋势,不适合承诺你的服务一定快 60%。

一个好的 benchmark 会回答「我的 key 和访问分布下,哪一层是瓶颈」,而不是证明新版本在所有 map 上都赢。

十、我的选择原则

普通业务里,我会优先使用内置 map,因为它的语义稳定、工具链成熟,Go 1.24 之后底层实现还更贴近现代 CPU。写入量可预估就提供容量提示;结构体 key 能减少嵌套查找就使用结构体 key;value 是否存指针则结合复制成本和 GC 画像决定。

需要并发时,我不会从 sync.Map 开始,而会先定义数据所有权:能否让一个 goroutine 独占?能否按 key 分片?能否用原子指针发布不可变快照?只有这些方案不合适时,才用一把清晰的 Mutex 或 RWMutex。sync.Map 适合它描述的访问模式,不适合拿来遮住所有权混乱。

版本迁移时,我会把 hmap 文章和 Swiss Table 文章分开读,并在 benchmark 中注明 Go 版本。不要因为旧文章里出现 bucket、overflow、nevacuate,就把这些名字当作永恒 API;也不要因为新实现使用 control word,就认为哈希表的冲突、扩容和内存成本消失了。

说到底,map 的底层实现可以换代,工程问题不会消失:key 是否合理,数据是否共享,容量是否受控,热点是否分散,失效是否一致,观测是否充分。理解 hmap 是为了看懂过去,理解 Swiss Table 是为了看懂现在,而真正决定系统上限的,仍然是你把多少工作塞进了同一份共享状态。

Go map:从 hmap 到 Swiss Tablehttps://aoang.x2ox.com/2025/go-map/
作者Aoang
发布于2025-02-16
更新于2025-02-16
许可协议CC BY-NC-SA 4.0
转载或引用本文时请遵守许可协议,并注明出处。