上周,Go 发布了 1.24.0 版本,其中一项最重要的更新,便是将内置 map 的实现改为了 Swiss Tables。官方发布说明中是这样描述的:
Several performance improvements to the runtime have decreased CPU overheads by 2–3% on average across a suite of representative benchmarks. Results may vary by application. These improvements include a new builtin map implementation based on Swiss Tables, more efficient memory allocation of small objects, and a new runtime-internal mutex implementation.
简单来说,新的 map 实现平均带来了 2-3% 的 CPU 开销降低。Swiss Tables 这个名词听起来很陌生,它究竟是什么?如果你和我一样好奇,不妨跟着本文,借助大模型的帮助来快速学习一下。
什么是 Swiss Tables
Swiss Tables 是一种高效的哈希表实现,最初由 Google 的 Abseil 库引入。它采用了一系列先进的设计,主要特点包括:
- 开放寻址:使用开放寻址法处理哈希冲突,所有键值对都直接存储在哈希表数组内。
- SIMD 优化:利用现代 CPU 的 SIMD 指令加速查找操作。
- 元数据分离:将哈希值的部分信息(元数据)与实际的键值对分开存储,提升查找和插入效率。
开放寻址的原理是什么
开放寻址是处理哈希冲突的一种方法。它与我们熟悉的链地址法不同,不会在冲突位置拉出一条链表。相反,它会将所有键值对都存放在哈希表本身。当发生冲突时,它会按照一个预定的“探测序列”去查找下一个可用的空槽位。
开放寻址的步骤
- 计算哈希值:对键(key)计算哈希值,得到一个初始的槽位索引:
index = hash(key) mod table_size
- 检查槽位:
- 如果该槽位为空,则直接插入键值对。
- 如果已被占用,则启动探测序列。
- 探测序列:这是一个固定的规则,决定如何寻找下一个槽位。常见的方法有:
- 线性探测:逐个检查下一个槽位。公式为
index = (index + i) mod table_size,i 是探测次数。
- 二次探测:使用二次函数跳过一些槽位以减少聚集。公式为
index = (index + c1 * i + c2 * i^2) mod table_size。
- 双重哈希:使用第二个哈希函数计算步长。公式为
index = (index + i * hash2(key)) mod table_size。
- 插入或查找:按照探测序列找到空槽位后插入;查找时则按相同序列检查,直到找到目标键或遇到空槽位(表示键不存在)。
开放寻址的优缺点
- 优点:内存效率高(无需额外链表结构),数据连续存储,缓存友好。
- 缺点:容易发生“聚集”现象,导致探测路径变长;删除操作复杂,不能简单置空槽位。
怎么解决开放寻址的聚集现象
聚集现象分为两种:线性探测导致的初级聚集,以及不同键探测路径重合导致的次级聚集。
解决方法主要有:
- 改进探测序列:使用二次探测或双重哈希,让探测路径更分散。
- 动态调整表大小:当填充因子超过阈值(如0.7)时,对哈希表进行扩容和重哈希。
- 使用更好的哈希函数:让键值分布更均匀。
怎么解决删除操作复杂问题
删除的难点在于,如果直接清空槽位,后续查找时探测序列会在此中断,误以为键不存在。
标记删除(Tombstone) 是常用的解决方案:
- 删除时,不物理移除数据,而是将槽位标记为“已删除”。
- 查找时:遇到标记删除的槽位,继续探测;遇到真正的空槽位,才判定键不存在。
- 插入时:遇到标记删除的槽位,可以复用。
为了不让表内充满“墓碑”,需要定期清理:当标记删除的槽位达到一定数量时,触发一次重哈希,将有效数据整理到新表中。
SIMD 优化是什么
SIMD(单指令多数据流)是一种并行计算技术,一条指令可以同时处理多个数据(如4个整数)。现代 CPU 的 SSE、AVX、NEON 等指令集都支持 SIMD。
Swiss Tables 利用 SIMD 加速核心操作:
- 同时检查多个槽位:将多个槽位的元数据加载到 SIMD 寄存器,用一条指令同时比较。
- 减少分支预测:批量比较避免了逐一遍历和大量条件判断,减少了分支预测失败的开销。
- 提高缓存利用率:连续访问内存区域,更符合 CPU 缓存的工作方式。
优化示例:普通线性探测需要循环逐个比较,而 SIMD 优化可以一次比较多个(如4个):
__m128i target = _mm_set1_epi32(target_key);
for (int i = 0; i < table_size; i += 4) {
__m128i data = _mm_loadu_si128((__m128i*)&table[i]);
__m128i cmp = _mm_cmpeq_epi32(data, target);
int mask = _mm_movemask_epi8(cmp);
if (mask != 0) {
return i + __builtin_ctz(mask) / 4;
}
}
元数据分离是怎样提高性能的
元数据分离是 Swiss Tables 的关键设计。它把每个槽位的状态信息(通常是哈希值的高位字节)与实际的键值对分开存储。
具体实现:
- 元数据数组:一个紧凑的字节数组,存储哈希值高位和状态(空/有效/已删除)。
- 键值对数组:另一个独立数组,存储实际的键和值。两者通过索引一一对应。
为何能提高性能:
- 减少内存访问开销:元数据体积小且连续,遍历时缓存命中率高,能快速过滤掉大量不匹配的槽位。
- 便于 SIMD 优化:紧凑的元数据数组非常适合用 SIMD 指令进行批量比较。
- 快速过滤:查找时先扫描元数据,只有元数据匹配的槽位才需要访问键值对数组进行精确比对,减少了昂贵的内存访问。
- 高效维护:删除只需标记元数据;插入也能通过元数据快速定位空位。
具体示例:

查找键“key1”时:
- 计算其哈希值高位(例如
0x12)。
- 用 SIMD 指令快速扫描元数据数组,发现槽位0的元数据
0x12 匹配。
- 仅访问键值对数组中索引0的位置,进行最终确认。
Go 1.24.0 之前 map 用的是什么数据结构
在 Go 1.24.0 之前,Go 的 map 也基于哈希表,但使用传统的链地址法处理冲突。
其核心是一个 桶(bucket) 数组,每个桶结构大致如下:
type bmap struct {
tophash [8]uint8 // 哈希值高位,用于快速匹配
keys [8]Key // 键数组
values [8]Value // 值数组
overflow *bmap // 指向溢出桶
}
- 每个桶可存储最多8个键值对。
- 发生哈希冲突时,键值对放入同一个桶。
- 如果桶满了,会创建溢出桶并通过链表链接。
- 查找时,先在桶内根据
tophash 快速筛选,再精确比较键值,最后可能还需遍历溢出桶链表。
这种设计的缺点在于:链表结构导致内存访问不连续,缓存不友好;冲突严重时,链表变长,查找性能下降。
为什么不采用 Java 的方案
Java 的 HashMap 也采用链地址法,但在链表过长时会转换为红黑树(O(log n)复杂度)。Go 为何不借鉴?
这源于Go 语言的设计哲学和目标:
- 追求简单与可预测性:红黑树实现和维护复杂,不符合 Go 的“简单”哲学。
- 系统编程需求:Go 常用于高性能、高并发场景,需要极致的缓存友好性和内存访问效率。连续存储的开放寻址比链表或树节点分散存储更有优势。
- 性能权衡:虽然链表/树在极端冲突下有兜底,但 Swiss Tables 通过优秀的哈希函数、SIMD 和元数据设计,在绝大部分场景下都能保持 O(1) 的高效访问,同时避免了复杂数据结构的开销。
方案对比
| 特性 |
Java 的 HashMap |
Go 的 map (Go 1.24 之前) |
Go 的 map (Go 1.24 及以后) |
| 哈希冲突处理 |
链地址法(链表或红黑树) |
链地址法(链表,8 个键值对 + 溢出桶) |
开放寻址(Swiss Tables) |
| 内存访问效率 |
链表/树节点分散,缓存命中率较低 |
链表节点分散,缓存命中率较低 |
数据连续存储,缓存友好 |
| 实现复杂性 |
链表或红黑树,维护成本较高 |
链表实现,相对简单 |
开放寻址 + SIMD,实现复杂但性能高 |
| 查找性能 |
O(1)(链表短时),O(log n)(链表长时) |
O(n)(溢出链表) |
O(1) |
综上所述,Go 1.24 转向 Swiss Tables 并非偶然,而是为了在保持语言简洁性的同时,追求更极致的运行时性能,这体现了 Go 团队在底层计算机系统优化上的持续投入。对于开发者而言,这次升级意味着我们常用的 map 在不知不觉中变得更快了。