一、整体设计思想
Go map(Swiss Table 版本)是基于 Google Abseil 的 "Swiss Table" 设计的现代化哈希表实现,采用以下核心设计:
- 控制字并行匹配:使用 8 字节控制字一次性检查 8 个槽位
- 开放寻址 + 二次探测:无需链表,所有数据存储在连续数组中
- 可扩展哈希:使用目录机制实现增量扩容,单表大小受限
- SIMD 加速:AMD64 架构使用 SIMD 指令并行比较控制字
设计灵感来源:Abseil Swiss Tables
二、核心数据结构
本篇以 Go1.25 版本的源码为基准,完整源码参考: - Runtime 封装:map_swiss.go - 核心实现:internal/runtime/maps/
1. Map(顶层结构)
1 | type Map struct { |
关键字段说明:
dirPtr+dirLen:实现可扩展哈希的目录结构globalDepth:当前使用哈希高位的 bit 数- 小 map 优化:≤8 个元素时无需分配 table
2. Table(哈希表)
1 | type table struct { |
单表最大容量:1024 个槽位(限制单次扩容开销)
3. Group(组结构)
每个 group 包含: - 1 个控制字(8 字节):每字节对应一个槽位的状态 - 8 个槽位:每个槽位存储一个 key-value 对
1 | type groupReference struct { |
控制字编码: 1
2
3 empty: 1000 0000 (0x80)
deleted: 1111 1110 (0xFE) - 墓碑标记
full: 0xxx xxxx (0x00-0x7F) - 低 7 位存储 H2 哈希值
4. 总结图

三、核心算法
1. 哈希值分割
1 | 64 位哈希值分为两部分: |
H1:用于定位 group(二次探测) H2:存储在控制字中,用于快速过滤
2. 并行匹配算法
传统方式(non-Swiss): 1
2
3
4
5for i := 0; i < 8; i++ {
if tophash[i] == target {
// 逐个串行比较
}
}
Swiss 方式: 1
2
3// 一次操作检查所有 8 个槽位!
matches := ctrlGroup.matchH2(target)
// matches 是 bitset,每位表示一个槽位是否匹配
实现原理:
1 | func ctrlGroupMatchH2(g ctrlGroup, h uintptr) bitset { |
AMD64 优化:使用 SIMD 指令(SSE2 PCMPEQB)进一步加速
3. 二次探测序列
1 | type probeSeq struct { |
探测公式:p(i) = (i² + i)/2 + hash (mod 组数)
优势: - 当组数为 2 的幂时,保证遍历所有组 - 跳跃式分布,减少聚集(clustering) - 缓存友好性优于线性探测
4. 查找流程
底层调用了 runtime/maps/runtime_swiss.go 中的
mapaccess1() 或者 mapaccess2 函数:
- v := m[k] 调用
runtime_mapaccess1() - v,k := m[k] 调用
runtime_mapaccess2()
我们重点来看 runtime_mapaccess1():
1 | func runtime_mapaccess1(typ *abi.SwissMapType, m *Map, key unsafe.Pointer) unsafe.Pointer { |
查找步骤:
- 计算哈希并分割为 H1/H2
- 使用 H1 定位初始 group
- 并行匹配 H2(一次检查 8 个槽位)
- 对匹配的槽位进行完整 key 比较
- 未找到则二次探测下一个 group
flowchart TD
Start([mapaccess1]) --> Hash[计算哈希
hash = Hasher key, seed]
Hash --> Size{小map?
dirLen <= 0}
Size -->|是| SmallMap[单group查找
getWithKeySmall]
SmallMap --> Result1{找到?}
Result1 -->|是| Return1[返回 elem]
Result1 -->|否| Return2[返回 zeroVal]
Size -->|否| SelectTable[选择table
idx = directoryIndex hash]
SelectTable --> ProbeLoop[二次探测循环]
ProbeLoop --> GetGroup[获取group
g = groups seq.offset]
GetGroup --> ParallelMatch[⭐ 并行匹配控制字
matches = g.ctrls.matchH2 H2]
ParallelMatch --> HasMatch{有匹配?}
HasMatch -->|是| KeyCompare[完整key比较
key == slotKey?]
KeyCompare -->|是| Return3[返回 slotElem]
KeyCompare -->|否| NextMatch{下一个匹配?}
NextMatch -->|有| KeyCompare
NextMatch -->|无| CheckEmpty
HasMatch -->|否| CheckEmpty[检查空槽
matchEmpty]
CheckEmpty --> IsEmpty{有空槽?}
IsEmpty -->|是| Return4[返回 zeroVal
key不存在]
IsEmpty -->|否| NextGroup[下一个group
seq = seq.next]
NextGroup --> GetGroup
Return1 --> End([结束])
Return2 --> End
Return3 --> End
Return4 --> End
style ParallelMatch fill:#ffeb3b,stroke:#f57f17,stroke-width:3px
style KeyCompare fill:#4caf50,stroke:#2e7d32,stroke-width:2px
style Return3 fill:#2196f3,stroke:#1565c0,stroke-width:2px
style Return4 fill:#f44336,stroke:#c62828,stroke-width:2px
5. 插入流程
插入底层调用了 runtime/maps/runtime_swiss.go 中的 runtime_mapassign()
函数:
1 | func runtime_mapassign(typ *abi.SwissMapType, m *Map, key unsafe.Pointer) unsafe.Pointer { |
关键策略: - 优先重用墓碑槽位(避免浪费空间) - 探测到空槽表示 key 不存在 - 自动触发表分裂(如果负载过高)
flowchart TD
Start([mapassign]) --> Hash[计算哈希
设置写标志]
Hash --> Init{已初始化?}
Init -->|否| GrowSmall[growToSmall]
Init -->|是| CheckSize
GrowSmall --> CheckSize
CheckSize{小map?
dirLen == 0}
CheckSize -->|是| HasSpace{used < 8?}
HasSpace -->|是| PutSmall[直接插入
putSlotSmall]
HasSpace -->|否| GrowTable[扩展为大map
growToTable]
PutSmall --> Return1[返回 elem]
GrowTable --> BigMap
CheckSize -->|否| BigMap[大map路径]
BigMap --> SelectTable[选择table
idx = directoryIndex hash]
SelectTable --> ProbeLoop[二次探测循环]
ProbeLoop --> GetGroup[获取group]
GetGroup --> Match[⭐ 并行匹配H2
matchH2]
Match --> CheckExist{找到existing
key?}
CheckExist -->|是| UpdateKey[更新操作
NeedKeyUpdate?]
UpdateKey --> Return2[返回 elem]
CheckExist -->|否| CheckEmpty{遇到空槽?}
CheckEmpty -->|是| HasDeleted{有记录的
删除槽位?}
HasDeleted -->|是| UseDeleted[复用删除槽位]
HasDeleted -->|否| UseEmpty[使用空槽]
UseDeleted --> CheckGrowth
UseEmpty --> CheckGrowth{growthLeft > 0?}
CheckGrowth -->|是| InsertNew[插入新entry
设置控制字
更新计数]
InsertNew --> Return3[返回 elem]
CheckGrowth -->|否| Rehash[触发rehash
t.rehash]
Rehash --> SelectTable
CheckEmpty -->|否| RecordDeleted[记录删除槽位
matchEmptyOrDeleted]
RecordDeleted --> NextGroup[下一个group
seq.next]
NextGroup --> GetGroup
Return1 --> ClearFlag[清除写标志]
Return2 --> ClearFlag
Return3 --> ClearFlag
ClearFlag --> End([结束])
style Match fill:#ffeb3b,stroke:#f57f17,stroke-width:3px
style InsertNew fill:#4caf50,stroke:#2e7d32,stroke-width:2px
style UpdateKey fill:#2196f3,stroke:#1565c0,stroke-width:2px
style Rehash fill:#ff9800,stroke:#e65100,stroke-width:2px
6. 删除操作(墓碑机制)
删除操作底层调用了 runtime/map_swiss.go 中的 mapdelete()
函数:
1 | func mapdelete(t *abi.SwissMapType, m *maps.Map, key unsafe.Pointer) { |
根据 map 的大小具体分为 m.deleteSmall(typ, hash, key) 和
m.directoryAt(idx).Delete(typ, m, hash, key)
两种删除逻辑。我们重点来看一下 m.directoryAt(idx).Delete(typ,
m, hash, key):
1 | // Delete 删除 key,返回是否放置了墓碑 |
flowchart TD
Start([table.Delete]) --> InitProbe[初始化探测
seq = makeProbeSeq H1]
InitProbe --> ProbeLoop[探测循环]
ProbeLoop --> GetGroup[获取group]
GetGroup --> Match[⭐ 并行匹配H2
matchH2]
Match --> HasMatch{有匹配?}
HasMatch -->|是| KeyCompare[完整key比较
key == slotKey?]
KeyCompare -->|是| UpdateCount[更新计数
t.used--
m.used--]
UpdateCount --> ClearKey[清除key内存
IndirectKey?
Pointers?]
ClearKey --> ClearElem[清除elem内存
总是清除]
ClearElem --> CheckFull{⭐ group有
空槽?}
CheckFull -->|是| SetEmpty[设置 ctrlEmpty
growthLeft++]
SetEmpty --> ReturnFalse[返回 false
未放置墓碑]
CheckFull -->|否| SetDeleted[设置 ctrlDeleted
保持 growthLeft]
SetDeleted --> ReturnTrue[返回 true
已放置墓碑]
KeyCompare -->|否| NextMatch{下一个匹配?}
NextMatch -->|有| KeyCompare
NextMatch -->|无| CheckEmpty
HasMatch -->|否| CheckEmpty[检查空槽
matchEmpty]
CheckEmpty --> IsEmpty{有空槽?}
IsEmpty -->|是| ReturnFalse2[返回 false
key不存在]
IsEmpty -->|否| NextGroup[下一个group
seq.next]
NextGroup --> GetGroup
ReturnTrue --> End([结束])
ReturnFalse --> End
ReturnFalse2 --> End
style Match fill:#ffeb3b,stroke:#f57f17,stroke-width:3px
style CheckFull fill:#ff9800,stroke:#e65100,stroke-width:3px
style SetEmpty fill:#4caf50,stroke:#2e7d32,stroke-width:2px
style SetDeleted fill:#f44336,stroke:#c62828,stroke-width:2px
墓碑的核心作用是:保持探测链的完整性,防止后续元素"失联"。
不用墓碑会发生什么?
1 | 假设有3个连续的满group(hash碰撞导致): |
解决方案:使用墓碑
1 | 删除 B(使用墓碑): |
三种控制字代表不同的探测行为:
| ctrlEmpty | ctrlDeleted | H2 (0-127) |
|---|---|---|
| 空槽 | 墓碑 | 有效entry |
| 停止探测,key 不存在 | 跳过继续找 | 检查 key,可能找到 |
实际代码中的体现:
1 | // 探测循环中 |
为什么有些情况不需要墓碑?
1 | 如果删除后 group 有空槽: |
总结来说,墓碑 = 探测链的"虚拟占位符",如果没有墓碑,删除操作会破坏开放寻址哈希表的探测链,导致某些元素永远找不到!
| 作用 | 说明 |
|---|---|
| 🔗 保持链接 | 防止探测链被"截断",后续元素失联 |
| ♻️ 可复用 | 插入时优先使用墓碑位置(不消耗 growthLeft) |
| 🧹 延迟清理 | rehash 时一次性清除所有墓碑 |
| ⚖️ 权衡 | 占用空间 vs 探测链完整性 |
四、可扩展哈希与增量扩容
1. 可扩展哈希原理
传统单表扩容的问题: 1
2Table (100万元素) → Table (200万元素)
↑ 延迟峰值!
Swiss map 的解决方案: 1
2将大 map 拆分成多个小 table
每个 table 独立扩容
目录结构:
1 | Hash = 0x1A2B3C4D |
2. 表分裂过程
1 | const maxTableCapacity = 1024 |
触发条件:
- 负载因子 > 7/8(容量 × 0.875)
- 单表容量 ≤ 1024:表内扩容(容量翻倍)
- 单表容量 > 1024:分裂成两个表
分裂示例: 1
2
3
4
5
6
7
8
9
10
11
12初始状态 (globalDepth=1):
Directory: [0] → Table0 (localDepth=1, 容量=1024)
[1] → Table1 (localDepth=1, 容量=1024)
Table1 满了,需要分裂:
1. Table1 分裂为 Table1a 和 Table1b
2. globalDepth 增加到 2
3. 目录扩展:
Directory: [00] → Table0 (localDepth=1)
[01] → Table0 (同一个)
[10] → Table1a (localDepth=2)
[11] → Table1b (localDepth=2)
分裂规则: 1
2
3旧 table 的元素根据哈希的新 bit 分流:
hash & newbit == 0 → Table1a (低位)
hash & newbit != 0 → Table1b (高位)
3. 负载因子
1 | const maxAvgGroupLoad = 7 // 7/8 = 87.5% |
- Non-Swiss:6.5(约 81%)
- Swiss:7/8(87.5%)
更高的原因:开放寻址 + 墓碑重用提高空间利用率
五、与 Non-Swiss 对比
| 维度 | Non-Swiss | Swiss |
|---|---|---|
| 核心结构 | 桶 + 链式溢出 | 组 + 开放寻址 |
| 每桶/组大小 | 8 个键值对 | 8 个槽位 |
| 查找方式 | 顺序比较 tophash | 并行匹配 H2 ⭐ |
| 冲突处理 | 溢出桶链表 | 二次探测 |
| 扩容方式 | 渐进式迁移(全局锁) | 表分裂(细粒度) ⭐ |
| 负载因子 | 6.5 (81%) | 7/8 (87.5%) |
| 删除标记 | 直接删除 | 墓碑机制 |
| SIMD 支持 | 无 | AMD64 优化 ⭐ |
| 内存布局 | 分离 keys/values | 交错 key-value |
Swiss 的优势: 1. ✅ 并行匹配:一次检查 8 个槽位 2. ✅ SIMD 加速:硬件级优化 3. ✅ 细粒度扩容:单表限制控制延迟 4. ✅ 更高负载因子:空间利用率提升
Swiss 的劣势: 1. ❌ 墓碑管理:需要定期清理 2. ❌ 实现复杂:位运算 + SIMD 内联 3. ❌ 目录开销:大 map 需要额外目录
六、从源头理解 Swiss Table 版本
swiss table 版本的 map 实现对笔者来说还是比较新奇的,我一开始一直无法彻底这种这种实现思路。因为它的核心思想确实与我们教科书上常见的拉链法(Chaining)或线性探测法(Linear Probing)有很大不同。好在现在有 LLM,所以笔者跟 Gemini 进行了一番探讨,才对 swiss table 的底层原理有了更深的理解。
1. 传统 HashMap 的问题
要从根本上理解 swiss table 版本的
HashMap,我们必须回归到第一性原理:HashMap 的目标是在
O(1) 的时间复杂度内完成插入、查找和删除。而这个 1 在现代
CPU 面前,其含金量是完全不同的。
[!IMPORTANT]
现代 CPU 的第一性原理:缓存为王 (Cache is King)
我们常说的 O(1) 理论上假设内存访问是等价的。但在现实中:
- CPU 访问 L1 缓存:~1-2 纳秒
- CPU 访问 L2 缓存:~5-10 纳秒
- CPU 访问 L3 缓存:~30-50 纳秒
- CPU 访问主内存 (RAM):~100-200 纳秒
一个 Cache Miss 并从主内存读取数据的代价,可能是访问 L1 缓存的 100 倍以上。
传统的 HashMap 是一个"数组 + 链表"的结构。hash(key)
算出一个数组下标(桶 B[i])。如果冲突了,就把这个 (key, value) 挂在
B[i] 后面的链表上。这存在 2 个问题:
- 缓存极不友好: 链表的节点在内存中是离散分布的。当你遍历一个长链表来查找 key 时,每访问一个节点,都极有可能导致一次 Cache Miss(这被称为指针追逐 pointer chasing)。
- 元数据开销:
每个节点都需要一个额外的指针来指向下一个节点,这在 64 位系统上就是 8
个字节。如果你的 (key, value) 本身很小(比如
map[int]int),这个指针的开销就非常巨大。
所以:传统拉链法的主要瓶颈不在于 CPU 计算,而在于内存访问延迟。
2. Swiss Table 的创新
Swiss Table 的核心思想是用密集的计算换取稀疏的内存访问。它属于开放寻址法(Open Addressing)的一种,但又做出了革命性的优化。它同时解决了两个层面的核心问题:
- 微观问题 (Group 内): 如何在 8 个槽位 (slot) 中一次性找到目标?(解决 CPU 运算和 L1 缓存效率)
- 宏观问题 (Map 级): 如何在 map 变得巨大时,实现低延迟的扩容?(解决内存访问和延迟抖动)
在我看来,Swiss Table 有 3 点最重要的核心创新:
2.1 核心创新一:控制字 (ctrls) 与并行匹配
这是 Swiss Map 对缓存为王的极致应用。
第一性原理: CPU 访问 8 个完整的
key来比较,即使它们都在 L1 缓存中,也是昂贵的(因为key可能很大)。最快的方式是,先用元数据过滤掉 99% 的无效比较。理论:
- 数据结构:
group结构包含一个 8 字节的ctrls控制字 和 8 个slots(key/elem 对)。 - 哈希分割: 64 位哈希被分为 H1(高 57 位,用于定位)和 H2(低 7 位,用于匹配)。
- 控制字编码: 这 8 字节的
ctrls中,每个字节代表一个槽位 (slot) 的状态。0x80(1000 0000) =empty(空)0xFE(1111 1110) =deleted(墓碑)0x00-0x7F(0xxx xxxx) =full(已占用)
- 关键设计: 当槽位为
full时,它的 7 个x位存储的正是 H2 的 7 位哈希值。
- 数据结构:
实践(并行匹配的魔力):当我们要查找一个 key(其 H2 值为 target_h2)时,我们不再需要 for 循环 8 次。Go (Abseil) 使用了一种位运算的魔法:
ctrlGroup.matchH2(target_h2)。1
2
3v := uint64(g) ^ (0x0101010101010101 * uint64(h))
result := (v - 0x0101010101010101) &^ v
matches := bitset(result & 0x8080808080808080)这一系列操作(在 AMD64 上甚至可以用 SIMD 指令
PCMPEQB加速)的唯一目的是:用 1-2 个 CPU 指令,同时将 8 个槽位的 H2 值与
target_h2进行比较,并返回一个bitset。这个
bitset(例如0b00100100) 会瞬间告诉我们:"第 2 号和第 5 号槽位的 H2 匹配了,请只检查它们俩的完整key"。这使得查找 8 个槽位的开销,从 O(8) 次比较 降低到了 O(1) 次并行比较。
2.2 核心创新二:开放寻址与二次探测
这是 Swiss Map 解决哈希冲突的方式,也是它优于传统拉链法的第二个关键点。
- 第一性原理: 拉链法的
overflow链表节点在内存中是离散的,遍历链表会导致大量的指针追逐 (pointer chasing),引发灾难性的 Cache Miss。 - 理论(开放寻址):Swiss Map
规定,所有数据都必须存储在连续的 group 数组中。如果
hash(key)算出的主 group 满了,它不会挂一个链表。 - 实践(二次探测):
- 如果主
group满了(或者 H2 没匹配上,且没有空槽),我们怎么办? - 我们通过一个二次探测 (Quadratic Probing) 公式:
p(i) = (i² + i)/2 + hash (mod 组数),计算下一个要探测的group的索引。 - 这个公式的重点在于它可预测、无指针、计算快,并且能保证(在
2 的幂容量下)遍历所有
group。 - 根本收益: 我们用 CPU
计算(下一个索引) 代替了
内存访问(指针跳转)。由于
group数组是连续内存,下一个group极有可能也已在 CPU 缓存中,访问极快。
- 如果主
- 带来的问题(墓碑机制):这也解释了为什么需要
deleted (0xFE)状态。如文档所说,如果你删除了探测链中间的一个元素并将其标记为empty (0x80),那么查找它后面的元素时,探测链会在这里错误地终止。因此,删除时必须留下墓碑 (tombstone),告诉查找操作:"这里曾经有过人,请继续往后找"。
2.3 核心创新三:可扩展哈希与表分裂
这是 Swiss Map 解决宏观扩容问题的精妙之举。
- 第一性原理: 传统 map 扩容时,需要分配一个 2 倍大的新数组,并把所有元素 rehash 过去。如果 map 有 1 亿个元素,这个延迟是不可接受的。
- 理论(可扩展哈希):Go 的 Swiss Map
并没有一个无限大的 group 数组。它引入了目录 (Directory) 结构。
- 顶层
Map结构有一个dirPtr和dirLen。 dirPtr指向一个[dirLen]*table指针数组。- 每个
table才是真正存储group的地方,但每个table的容量是受限的(例如 1024 个槽位)。
- 顶层
- 实践(增量扩容与表分裂):
- 查找:
hash(key)的高位(由globalDepth决定)用于在directory中选择使用哪个table。hash(key)的低位(H1)用于在该table内进行“二次探测”。 - 扩容(关键): 当一个
table负载过高(例如 > 7/8) 且已达到 1024 槽位的上限时,我们不再扩容整个 map。 - 我们只分裂这一个
table。如Table1(localDepth=1) 分裂为Table1a和Table1b(localDepth=2)。 - 然后,我们去更新
directory中的指针。如果directory不够大(globalDepth < new_localDepth),我们就只扩容directory(这很快,因为它只存指针)。 - 根本收益: 扩容的开销被均摊了。延迟是可控的,因为我们一次最多只迁移 1024 个元素,而不是 1 亿个。
- 查找:
2.4 总结
总的来说,Go Swiss Table 是三种先进技术的完美结合:
- 微观 (Group 内): 并行匹配(基于 SIMD/位运算),实现 O(1) 的 8 槽位匹配。
- 中观 (Table 内): 二次探测(开放寻址),实现无指针、缓存友好的冲突解决。
- 宏观 (Map 级): 可扩展哈希(目录+表分裂),实现低延迟、增量式的扩容。
这套组合拳,从 L1 缓存、CPU 指令集,一直优化到全局内存布局和延迟控制,是现代高性能 Hash Map 的典范之作。
3. Swiss Table 的演进猜想
理解了是什么(What)之后,追问为什么(Why)和如何演进(How)是掌握一个复杂系统最根本的方法。本小节我们尝试像一个系统设计师一样,从零开始推演 Go Swiss Table 的实现过程。
3.1 第 0 步:明确目标与核心矛盾
- 初始状态: 拉链法。
- 要解决的核心矛盾(第一性原理):
- 性能瓶颈: 传统 map 的性能瓶颈不在 CPU 计算,而在内存访问。
- 缓存失效 (Cache Miss): 拉链法的溢出桶链表在内存中是离散的,遍历它会导致大量的指针追逐,每一次跳转都可能是一次昂贵的 Cache Miss(访问主内存)。
- 延迟抖动: 传统 map 扩容时,需要一次性迁移所有数据,导致服务(STW)的延迟毛刺。
- 新 map 的目标:
- 缓存友好: 必须用连续内存布局,消除指针追逐。
- 低延迟: 必须实现增量式扩容,平滑延迟。
- 高吞吐: 查找、插入、删除操作要尽可能快。
3.2 第 1 步:从拉链到开放寻址
为了实现目标 1(缓存友好):
- 设计师的决策: 抛弃拉链法,全面转向开放寻址法 (Open Addressing)。
- 为什么? 开放寻址法将所有 (key, value) 存储在一个连续的数组中。
- 带来的新问题:
- 冲突解决: 如何处理哈希冲突?(拉链法用链表,现在没链表了)
- 查找效率: 如何在连续数组中快速找到目标?
- 删除: 拉链法删除一个节点很简单,开放寻址法删除了一个元素,会不会中断探测链?
3.3 第 2 步:解决冲突与查找效率
为了解决第 1 步的新问题(冲突与查找):
- 决策 1 - 冲突解决: 采用二次探测
(Quadratic Probing)。
- 为什么不用线性探测?
线性探测(
hash + i)会导致严重的聚集。 - 为什么用二次探测?
p(i) = (i² + i)/2 + hash公式能跳跃式分布,减少聚集,且计算开销小。
- 为什么不用线性探测?
线性探测(
- 决策 2 - 查找效率(Swiss Table 的核心!):
- 开放寻址的痛点: 探测
i位置时,必须比较key == target_key。如果key是个很长的字符串,这个比较本身就非常昂贵。 - 思路: 我们能不能先不过早地比较完整的 Key?
- 解决方案: 引入元数据!将哈希值一分为二:H1(用于探测)和 H2(用于过滤)。
- 演进: 我们设计一个控制字 (Control
Word) 数组,它与
slots数组平行。这个ctrls数组只存放 H2(低 7 位)。 - 查找流程优化:
- (昂贵)
for i... { if array[i].key == my_key } - (优化后)
for i... { if ctrls[i] == H2 { if array[i].key == my_key } }
- (昂贵)
- 收益: 99% 的比较,都从昂贵的 Key 比较变成了廉价的 1 字节 H2 比较。
- 开放寻址的痛点: 探测
3.4 第 3 步:将查找效率推向极致
为了解决第 2 步的遗留问题:
- 新痛点: 查找
H2(
ctrls[i] == H2)虽然廉价,但我们仍然在for循环!如果一个group有 8 个槽位,最坏情况还是要循环 8 次。 - 设计师的决策: 一次性比较 8 个槽位!
- 为什么?
- 现代 CPU 拥有 SIMD(单指令多数据流)指令集(如 x86 上的 SSE2,ARM 上的 NEON)。
- CPU 的 L1 缓存一次加载 64 字节(一个 Cache Line)。我们一个
group里的 8 字节ctrls必定在同一个 Cache Line 里。
- 解决方案:
- 将 8 个
ctrl字节视为一个uint64。 - 使用 SIMD 指令(如
PCMPEQB)或等效的位运算,并行地将这个uint64中的 8 个字节与我们目标的 H2 进行比较。 - 收益: 查找
group内 8 个槽位的复杂度,从 O(8)(串行) 降低到 O(1)(并行)。
- 将 8 个
3.5 第 4 步:解决删除的遗留问题
为了解决第 1 步留下的删除问题:
- 痛点: 在一个探测链
A -> B -> C中,如果删除了 B 并标记为empty。 - 后果: 查找 C 时,探测到 A,下一个是
empty,查找会提前终止,导致 C 永远找不到。 - 解决方案: 引入墓碑 (Tombstone) 状态。
- 演进:
ctrls字节现在必须有 3 种状态:empty(0x80):空的,探测终止。deleted(0xFE):墓碑,插入时可复用,查找时请继续。full(0x00-0x7F):有数据(H2)。
3.6 第 5 步:解决扩容的延迟目标
为了实现目标 2(低延迟):
- 痛点: 我们的开放寻址表(
group数组)如果满了(例如负载 > 87.5%),传统的做法是分配一个 2 倍大的新表,然后 rehash 所有元素。 - 后果: 导致巨大的延迟毛刺,违背了目标 2。
- 解决方案: 可扩展哈希 (Extensible Hashing)。
- 演进:
- 我们不使用一个无限大的表。我们将数据拆分到多个、固定大小(如
1024 槽位)的
table中。 - 我们引入一个目录 (Directory)
结构,它是一个指针数组,指向这些
table。 - 使用哈希的高位 (
globalDepth) 来决定去哪个directory槽位,找到对应的table。 - 使用哈希的低位 (H1) 在
table内部进行二次探测。
- 我们不使用一个无限大的表。我们将数据拆分到多个、固定大小(如
1024 槽位)的
- 收益:
- 当一个
table满了,我们只需要分裂这一个table! - 扩容的开销被均摊了。一次迁移的元素上限是 1024,而不是 N(N 可能是 1 亿)。这完美解决了延迟抖动问题。
- 当一个
3.7 第 6 步:锦上添花的优化
- 痛点: 如果用户只
make(map[int]int)并存了 3 个元素,我们也需要分配directory和table吗?太浪费了。 - 解决方案: 小 Map 优化。
- 演进:
- 当
dirLen == 0时,dirPtr不指向directory,而是直接指向单个group(8 个槽位)。 - 收益: 对于绝大多数(< 8 个元素)的
map,内存开销极小,且无需任何
table间接寻址。
- 当
3.8 总结:从 0 到 1 的演进路径
这个 0 到 1 的实现路径,清晰地展现了从第一性原理出发的思考:
缓存失效(问题) → 1. 开放寻址(方案) → 2. 元数据(H2)过滤(解决比较效率) → 3. 并行匹配(极致优化比较) → 4. 墓碑(解决删除遗留问题) → 5. 可扩展哈希(解决扩容延迟) → 6. 小 Map 优化(解决小内存开销)
七、并发:sync.Map 的 HashTrieMap 实现
1. 为什么需要新实现?
传统 sync.Map 的问题: - 全局锁:写操作竞争激烈 - 双 map 开销:read + dirty 内存翻倍 - 提升机制:周期性全量拷贝
HashTrieMap 的解决方案: - Hash-Trie 结构:树形结构,天然支持分区 - 细粒度锁:每个节点独立锁,并发度高 - 无需提升:没有 read/dirty 切换
2. HashTrieMap 数据结构
1 | type HashTrieMap[K comparable, V any] struct { |
Trie 结构(每层使用 6 位哈希): 1
2
3
4
5
6
7 root
/ \
[0-63] [64-127]
/ \ |
[0-15] [16-31] [...]
| |
entries entries
3. 查找流程
1 | func (ht *HashTrieMap[K, V]) Load(key K) (V, bool) { |
无锁读取:读操作不需要加锁!
4. 插入流程
1 | func (ht *HashTrieMap[K, V]) LoadOrStore(key K, value V) (V, bool) { |
细粒度锁: 1
2
3
4
5
6
7 root (lock1)
/ \
indirect1 indirect2
(lock2) (lock3)
↓ ↓
goroutine1 goroutine2
可以同时修改不同子树!
5. 与传统 sync.Map 对比
| 维度 | Read-Write sync.Map | HashTrieMap |
|---|---|---|
| 数据结构 | 双 map(read + dirty) | Hash-Trie 树 |
| 并发策略 | 全局锁 + 读写分离 | 细粒度锁(per-node) |
| 读性能 | 命中 read:O(1) 无锁 未命中:加锁 |
始终无锁,O(log₆₄ n) |
| 写性能 | 快速路径:原子操作 慢速路径:全局锁 |
锁粒度细,O(log₆₄ n) |
| 内存开销 | 两份 map | Trie 节点开销 |
| 提升机制 | 需要周期性提升 | 无需提升 |
| 类型安全 | map[any]any |
泛型 HashTrieMap[K,V] |
HashTrieMap 的优势: 1. ✅ 细粒度并发:锁竞争大幅降低 2. ✅ 无需提升:没有全量拷贝开销 3. ✅ 泛型支持:类型安全 4. ✅ 读无锁:始终无需加锁
适用场景: - 高并发写入 - 键空间大(Trie 的空间优势) - 需要泛型支持
八、总结
Swiss Map 核心创新
并行匹配算法:
1
2传统:逐个比较 8 次
Swiss:并行比较 1 次(8 倍提升)可扩展哈希:
1
2传统:单表扩容,延迟峰值
Swiss:多表分裂,延迟可控SIMD 加速:
1
2AMD64:使用 SSE2 指令
性能提升:2-3 倍
设计权衡
优势: - ✅ 查找性能:并行匹配 + SIMD - ✅ 空间效率:87.5% 负载因子 - ✅ 增量扩容:表分裂控制延迟
劣势: - ❌ 墓碑管理:需要定期清理 - ❌ 实现复杂:位运算 + SIMD 内联 - ❌ 目录开销:大 map 需要额外结构
选择建议
1 | ┌─────────────────────────────────────┐ |
最佳实践:
- 默认使用 Swiss map(已启用实验特性)
- 高并发场景使用 sync.Map
- 简单场景用
RWMutex + map
参考资料: - Abseil Swiss Tables - Go Swiss Map PR - Go HashTrieMap PR