本文来自公众号 猩猩程序员 欢迎关注
哈希表是计算机科学的核心数据结构,也是包括 Go 在内的许多语言中 map 类型的底层实现。
1953 年,Hans Peter Luhn 在一份 IBM 内部备忘录中首次提出哈希表概念:通过把元素放进"桶"并用链表处理溢出,来加速查找。今天我们把这种实现称为"链地址法"哈希表。
1954 年,Gene M. Amdahl、Elaine M. McGraw 与 Arthur L. Samuel 在为 IBM 701 编程时首次采用"开放寻址"策略:当一个桶已被占用时,就把新元素放进下一个空桶。1957 年,W. Wesley Peterson 在《Addressing for Random-Access Storage》中正式发表这一思想。今天我们把这种实现称为"线性探测开放寻址"哈希表。
历经七十多年,我们很容易以为这种"老"数据结构已经"定型"。事实并非如此!计算机科学仍在持续改进基础算法,既优化算法复杂度,也更好地利用现代 CPU 硬件。例如,Go 1.19 就把 sort
包从传统快排换成了 2015 年才提出的 Pattern-Defeating Quicksort。
与排序算法类似,哈希表也在演进。2017 年,Google 的 Sam Benzaquen、Alkis Evlogimenos、Matt Kulukundis 与 Roman Perepelitsa 提出了一种新的 C++ 哈希表设计------"Swiss Tables";2018 年,其开源实现随 Abseil C++ 库发布。
Go 1.24 基于 Swiss Table 设计,对内置 map 进行了彻底重写。本文将介绍 Swiss Table 如何优于传统哈希表,以及将其引入 Go map 时遇到的独特挑战。
开放寻址哈希表
Swiss Tables 属于开放寻址哈希表,我们先回顾一下基本实现方式。
在开放寻址哈希表中,所有元素都存放在同一块底层数组里,数组中的每个位置称为一个 slot。每个键所属 slot 由哈希函数 hash(key)
决定。若目标 slot 已被占用(冲突),就按"探测序列"继续查找,直到找到一个空 slot。
示例
假设底层数组有 16 个 slot:
Slot | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Key | 56 | 32 | 21 | 78 |
插入新键时,先计算 hash(key) % 16
得到目标 slot。若 slot 为空,直接插入;否则按线性探测依次尝试后继 slot。
查找过程同理,遇到空 slot 即可停止。
当元素数量超过数组大小时,哈希表会扩容(通常翻倍),并把旧元素全部重新插入到新数组中。为避免平均探测长度过长,哈希表并不会等到数组全满才扩容,而是设定一个最大负载因子(通常 70--90%)。
Swiss Table
Swiss Table 仍是开放寻址,但做了关键改进:
- 把底层数组按 8 个 slot 一组划分为"group"(也可更大)。
- 每组配一个 64 位"控制字"(control word)。8 个字节分别对应组内 8 个 slot,记录该 slot 是空、已删除还是占用;若占用,则保存该键哈希低 7 位(h2)。
示意图:
Group 0 | Group 1 |
---|---|
slot 0--7 | slot 0--7 |
Key: 56,32,21,... | Key: 78,... |
Ctrl: 23,89,50,... | Ctrl: 47,... |
插入流程:
- 计算
hash(key)
,拆分为高 57 位 h1 与低 7 位 h2。 - 用 h1 选起始 group:
h1 % group_cnt
。 - 在组内并行比较控制字,筛选 h2 匹配的候选 slot;若命中则更新,否则找空 slot;若无空 slot,则继续探测下一组。
- 查找同理,可用 SIMD 一次并行比较 8 个 slot。
借助控制字,我们一次就能完成 8 次探测,大幅减少比较次数。Abseil 与 Go 的实现都因此提高了最大负载因子,降低了平均内存占用。
Go 独有的挑战
Go 的内置 map 有两个特殊需求,给 Swiss Table 落地带来额外复杂性:
-
增量扩容
传统实现触发扩容时,需要一次性复制全部元素,最坏情况下延迟极高。Go map 用于高并发服务器,必须限制单次插入的最坏延迟。
因此 Go map 采用"增量扩容":把一个大 map 拆成多个最多 1024 条目的独立 Swiss Table。哈希的高位决定落入哪个表。扩容时只需复制 1024 条元素,延迟可控。
-
边遍历边修改
Go 语言规范允许在遍历 map 时增删键值,并给出明确定义:
- 删除尚未遍历到的键不会被产出;
- 更新尚未遍历到的键会产出新值;
- 新增的键可能或可能不被产出。
传统"顺序扫描数组"方式在扩容时会打乱内存布局,无法满足语义。
Go 的解决方法是:迭代器持有旧表的引用;若表在遍历期间扩容,仍按旧表顺序产出键值,但在真正返回前查询新表,确保返回最新值或跳过已删除项。这使得遍历成为 Go map 实现中最复杂的部分。
未来工作
在微基准测试中,Go 1.24 的 map 操作相比 1.23 最高提速 60%。
整体应用级基准平均 CPU 时间下降约 1.5%。
后续计划:
- 提升未命中 CPU cache 时的局部性;
- 在更多架构上利用 SIMD 指令,把 group 扩大到 16 slot,进一步减少探测次数。
致谢
Swiss Table 版 Go map 历经多年、多人贡献。感谢 YunHao Zhang (@zhangyunhao116)、PJ Malloy (@thepudds)、@andy-wm-arthur 早期实现;Peter Mattis (@petermattis) 结合 Go 需求完成 github.com/cockroachdb/swiss
库;Go 1.24 官方实现大量基于其工作。感谢社区每一位贡献者!
本文来自公众号 猩猩程序员 欢迎关注