一、整体设计思想

Go map(Swiss Table 版本)是基于 Google Abseil 的 "Swiss Table" 设计的现代化哈希表实现,采用以下核心设计:

  1. 控制字并行匹配:使用 8 字节控制字一次性检查 8 个槽位
  2. 开放寻址 + 二次探测:无需链表,所有数据存储在连续数组中
  3. 可扩展哈希:使用目录机制实现增量扩容,单表大小受限
  4. SIMD 加速:AMD64 架构使用 SIMD 指令并行比较控制字

设计灵感来源Abseil Swiss Tables

二、核心数据结构

本篇以 Go1.25 版本的源码为基准,完整源码参考: - Runtime 封装:map_swiss.go - 核心实现:internal/runtime/maps/

1. Map(顶层结构)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type Map struct {
// 元素总数(必须在第一位,供 len() 使用)
used uint64

// 哈希种子(每个 map 独立随机)
seed uintptr

// 目录指针:指向 table 数组或单个 group
// 小 map 优化:≤8 个元素时,dirPtr 直接指向单个 group
// 大 map:dirPtr 指向 *[dirLen]*table
dirPtr unsafe.Pointer
dirLen int // 目录长度 = 1 << globalDepth

// 可扩展哈希的全局深度(使用哈希高位的 bit 数)
globalDepth uint8
globalShift uint8 // 64 - globalDepth(用于快速计算索引)

// 并发写检测标志
writing uint8

// 墓碑存在标记
tombstonePossible bool

// 清空序列号(用于检测迭代中的 clear)
clearSeq uint64
}

关键字段说明

  • dirPtr + dirLen:实现可扩展哈希的目录结构
  • globalDepth:当前使用哈希高位的 bit 数
  • 小 map 优化:≤8 个元素时无需分配 table

2. Table(哈希表)

1
2
3
4
5
6
7
8
9
10
type table struct {
used uint16 // 已使用槽位数
capacity uint16 // 总槽位数(= groups数 × 8)
growthLeft uint16 // 剩余可填充槽位

localDepth uint8 // 表的局部深度
index int // 在目录中的索引

groups groupsReference // group 数组
}

单表最大容量:1024 个槽位(限制单次扩容开销)

3. Group(组结构)

每个 group 包含: - 1 个控制字(8 字节):每字节对应一个槽位的状态 - 8 个槽位:每个槽位存储一个 key-value 对

1
2
3
4
5
6
7
8
9
10
11
12
type groupReference struct {
data unsafe.Pointer // 指向实际的 group 内存
}

// 内存布局
type group struct {
ctrls ctrlGroup // 8 字节控制字
slots [8]struct {
key K
elem V
}
}

控制字编码

1
2
3
  empty: 1000 0000  (0x80)
deleted: 1111 1110 (0xFE) - 墓碑标记
full: 0xxx xxxx (0x00-0x7F) - 低 7 位存储 H2 哈希值

4. 总结图

三、核心算法

1. 哈希值分割

1
2
3
4
5
64 位哈希值分为两部分:
┌─────────────────────────────────────────────┬───────────┐
│ H1 (高 57 位) │ H2 (低7位) │
└─────────────────────────────────────────────┴───────────┘
用于定位 group 存储在控制字中

H1:用于定位 group(二次探测) H2:存储在控制字中,用于快速过滤

2. 并行匹配算法

传统方式(non-Swiss):

1
2
3
4
5
for i := 0; i < 8; i++ {
if tophash[i] == target {
// 逐个串行比较
}
}

Swiss 方式:

1
2
3
// 一次操作检查所有 8 个槽位!
matches := ctrlGroup.matchH2(target)
// matches 是 bitset,每位表示一个槽位是否匹配

实现原理:

1
2
3
4
5
6
7
8
9
10
11
func ctrlGroupMatchH2(g ctrlGroup, h uintptr) bitset {
// 1. XOR:将匹配的字节变为 0x00
v := uint64(g) ^ (0x0101010101010101 * uint64(h))

// 2. 减法技巧:0x00 - 0x01 会借位变成 0xFF
// 其他值减 0x01 不会设置最高位
result := (v - 0x0101010101010101) &^ v

// 3. 提取每字节的最高位
return bitset(result & 0x8080808080808080)
}

AMD64 优化:使用 SIMD 指令(SSE2 PCMPEQB)进一步加速

3. 二次探测序列

1
2
3
4
5
6
7
8
9
10
11
12
type probeSeq struct {
mask uint64 // 组数 - 1
offset uint64 // 当前偏移
index uint64 // 探测索引
}

func (s probeSeq) next() probeSeq {
s.index++
// 三角数序列:offset[i+1] = offset[i] + i + 1
s.offset = (s.offset + s.index) & s.mask
return s
}

探测公式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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
func runtime_mapaccess1(typ *abi.SwissMapType, m *Map, key unsafe.Pointer) unsafe.Pointer {
// 空map检查
if m == nil || m.Used() == 0 {
if err := mapKeyError(typ, key); err != nil {
panic(err)
}
return unsafe.Pointer(&zeroVal[0])
}

// 并发写检测
if m.writing != 0 {
fatal("concurrent map read and map write")
}

// ============================================
// 步骤 1:计算哈希值
// hash 将被分为 H1(高57位)和 H2(低7位)
// ============================================
hash := typ.Hasher(key, m.seed)

// ============================================
// 步骤 2:小 map 快速路径(≤8个元素)
// ============================================
if m.dirLen <= 0 {
// 小map:数据直接存储在单个group中,无需探测
_, elem, ok := m.getWithKeySmall(typ, hash, key)
if !ok {
return unsafe.Pointer(&zeroVal[0])
}
return elem
}

// ============================================
// 步骤 3:大 map 路径 - 选择 table
// 使用哈希值的高位索引目录,选择对应的 table
// ============================================
idx := m.directoryIndex(hash) // idx = hash >> globalShift
t := m.directoryAt(idx) // 获取 table 指针

// ============================================
// 步骤 4:二次探测循环
// 初始化探测序列:使用 H1(高57位)定位初始 group
// ============================================
seq := makeProbeSeq(h1(hash), t.groups.lengthMask)

for ; ; seq = seq.next() { // 无限循环,直到找到或遇到空槽
// ----------------------------------------
// 步骤 4.1:获取当前探测的 group
// ----------------------------------------
g := t.groups.group(typ, seq.offset)

// ----------------------------------------
// 步骤 4.2:并行匹配控制字(核心优化!)
// 使用 H2(低7位)与控制字进行并行匹配
// 一次操作检查 8 个槽位的控制字
// ----------------------------------------
match := g.ctrls().matchH2(h2(hash)) // match 是 bitset,每位代表一个槽位是否匹配


// ----------------------------------------
// 步骤 4.3:遍历所有匹配的槽位
// ----------------------------------------
for match != 0 {
// 获取第一个匹配槽位的索引
i := match.first()

// 获取槽位中的 key 指针
slotKey := g.key(typ, i)
slotKeyOrig := slotKey // 保存原始指针(计算 elem 偏移用)

// 如果是间接key(key太大,存的是指针)
if typ.IndirectKey() {
slotKey = *((*unsafe.Pointer)(slotKey)) // 解引用
}

// ----------------------------------------
// 步骤 4.4:完整 key 比较
// ----------------------------------------
if typ.Key.Equal(key, slotKey) {
// 找到了!计算 elem 的地址
// elem 紧跟在 key 后面,偏移量为 typ.ElemOff
slotElem := unsafe.Pointer(uintptr(slotKeyOrig) + typ.ElemOff)

// 如果是间接elem(elem太大,存的是指针)
if typ.IndirectElem() {
slotElem = *((*unsafe.Pointer)(slotElem)) // 解引用
}

return slotElem // 返回元素指针
}

// key不匹配(控制字碰撞),移除当前匹配位,检查下一个
match = match.removeFirst()
}

// ----------------------------------------
// 步骤 4.5:检查空槽(探测终止条件)
// ----------------------------------------
match = g.ctrls().matchEmpty()
if match != 0 {
// 找到空槽 → 探测序列结束 → key不存在 → 返回对应类型的零值
return unsafe.Pointer(&zeroVal[0])
}

// 当前group没有匹配且无空槽,继续探测下一个group
// seq.next() 会应用二次探测公式:offset += index+1
}
}

查找步骤

  1. 计算哈希并分割为 H1/H2
  2. 使用 H1 定位初始 group
  3. 并行匹配 H2(一次检查 8 个槽位)
  4. 对匹配的槽位进行完整 key 比较
  5. 未找到则二次探测下一个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
func runtime_mapassign(typ *abi.SwissMapType, m *Map, key unsafe.Pointer) unsafe.Pointer {
// 并发写检测
if m.writing != 0 {
fatal("concurrent map writes")
}

// ============================================
// 步骤 1:计算哈希并设置写标志
// ============================================
hash := typ.Hasher(key, m.seed)

// 在调用Hasher之后设置writing标志(Hasher可能panic)
m.writing ^= 1 // toggle写标志

// ============================================
// 步骤 2:确保map已初始化
// ============================================
if m.dirPtr == nil {
m.growToSmall(typ) // 初始化为小map
}

// ============================================
// 步骤 3:小 map 快速路径(≤8个元素)
// ============================================
if m.dirLen == 0 {
if m.used < abi.SwissMapGroupSlots {
// 小map还有空间,直接插入
elem := m.putSlotSmall(typ, hash, key)

if m.writing == 0 {
fatal("concurrent map writes")
}
m.writing ^= 1 // 清除写标志

return elem
}

// 小map满了,扩展为大map
m.growToTable(typ)
}

// ============================================
// 步骤 4:大 map 路径 - 外层循环(处理rehash)
// ============================================
var slotElem unsafe.Pointer
outer:
for {
// ----------------------------------------
// 步骤 4.1:选择 table
// ----------------------------------------
idx := m.directoryIndex(hash)
t := m.directoryAt(idx)

// ----------------------------------------
// 步骤 4.2:初始化探测序列
// ----------------------------------------
seq := makeProbeSeq(h1(hash), t.groups.lengthMask)

// 记录第一个删除槽位(墓碑复用优化)
var firstDeletedGroup groupReference
var firstDeletedSlot uintptr

// ----------------------------------------
// 步骤 4.3:二次探测内层循环
// ----------------------------------------
for ; ; seq = seq.next() {
g := t.groups.group(typ, seq.offset)

// 并行匹配控制字
match := g.ctrls().matchH2(h2(hash))

// ====================================
// 场景 1:查找已存在的 key(更新操作)
// ====================================
for match != 0 {
i := match.first()

slotKey := g.key(typ, i)
slotKeyOrig := slotKey
if typ.IndirectKey() {
slotKey = *((*unsafe.Pointer)(slotKey))
}

// 找到相同的key → 更新操作
if typ.Key.Equal(key, slotKey) {
// 某些类型需要更新key(如float NaN)
if typ.NeedKeyUpdate() {
typedmemmove(typ.Key, slotKey, key)
}

// 计算elem地址并返回
slotElem = unsafe.Pointer(uintptr(slotKeyOrig) + typ.ElemOff)
if typ.IndirectElem() {
slotElem = *((*unsafe.Pointer)(slotElem))
}

t.checkInvariants(typ, m)
break outer // 完成更新,退出所有循环
}
match = match.removeFirst()
}

// ====================================
// 场景 2:遇到空槽 → 插入新 key
// ====================================
match = g.ctrls().matchEmpty()
if match != 0 {
// 找到空槽,探测序列结束

var i uintptr

// 优先复用删除槽位(避免消耗growthLeft)
if firstDeletedGroup.data != nil {
g = firstDeletedGroup
i = firstDeletedSlot
t.growthLeft++ // 补偿后面的--操作
} else {
// 使用空槽
i = match.first()
}

// --------------------------------
// 检查是否有增长空间
// --------------------------------
if t.growthLeft > 0 {
// 有空间,直接插入新entry

// 准备key存储
slotKey := g.key(typ, i)
slotKeyOrig := slotKey
if typ.IndirectKey() {
// 大key:分配堆内存,存指针
kmem := newobject(typ.Key)
*(*unsafe.Pointer)(slotKey) = kmem
slotKey = kmem
}
typedmemmove(typ.Key, slotKey, key)

// 准备elem存储
slotElem = unsafe.Pointer(uintptr(slotKeyOrig) + typ.ElemOff)
if typ.IndirectElem() {
// 大elem:分配堆内存,存指针
emem := newobject(typ.Elem)
*(*unsafe.Pointer)(slotElem) = emem
slotElem = emem
}

// 设置控制字为H2
g.ctrls().set(i, ctrl(h2(hash)))

// 更新计数器
t.growthLeft--
t.used++
m.used++

t.checkInvariants(typ, m)
break outer // 完成插入,退出所有循环
}

// --------------------------------
// 没有增长空间,触发rehash
// --------------------------------
t.rehash(typ, m)
continue outer // 重新开始(table结构已变化)
}

// ====================================
// 场景 3:记录第一个删除槽位(延迟选择)
// ====================================
// 当前group没有空槽,继续探测,但记录删除槽位
if firstDeletedGroup.data == nil {
match = g.ctrls().matchEmptyOrDeleted()
if match != 0 {
// 找到删除槽位,记录下来
// (可能后续找到existing key,也可能用于插入)
firstDeletedGroup = g
firstDeletedSlot = match.first()
}
}

// 继续探测下一个group
}
}

// ============================================
// 步骤 5:清除写标志并返回
// ============================================
if m.writing == 0 {
fatal("concurrent map writes")
}
m.writing ^= 1 // 清除写标志

return slotElem // 返回elem指针供调用者写入值
}

关键策略: - 优先重用墓碑槽位(避免浪费空间) - 探测到空槽表示 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func mapdelete(t *abi.SwissMapType, m *maps.Map, key unsafe.Pointer) {
// ...

m.Delete(t, key)
}

func (m *Map) Delete(typ *abi.SwissMapType, key unsafe.Pointer) {
// ...

if m.dirLen == 0 {
// 小 map 删除
m.deleteSmall(typ, hash, key)
} else {
// 大 map 删除
idx := m.directoryIndex(hash)
if m.directoryAt(idx).Delete(typ, m, hash, key) {
m.tombstonePossible = true // 如果返回 true,则表明可能设置了墓碑
}
}

// ...
}

根据 map 的大小具体分为 m.deleteSmall(typ, hash, key)m.directoryAt(idx).Delete(typ, m, hash, key) 两种删除逻辑。我们重点来看一下 m.directoryAt(idx).Delete(typ, m, hash, key)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// Delete 删除 key,返回是否放置了墓碑
// 返回 true:放置了墓碑(group已满,需要保持探测链完整)
// 返回 false:未找到key 或 直接清空(group有空槽)
func (t *table) Delete(typ *abi.SwissMapType, m *Map, hash uintptr, key unsafe.Pointer) bool {
// 1:初始化二次探测序列
seq := makeProbeSeq(h1(hash), t.groups.lengthMask)

// 2:探测循环 - 查找 key
for ; ; seq = seq.next() {
// 获取当前 group
g := t.groups.group(typ, seq.offset)

// 并行匹配控制字
match := g.ctrls().matchH2(h2(hash))

// 遍历所有匹配的槽位
for match != 0 {
i := match.first()

// 获取槽位中的 key
slotKey := g.key(typ, i)
origSlotKey := slotKey // 保存原始指针
if typ.IndirectKey() {
slotKey = *((*unsafe.Pointer)(slotKey))
}

// 3:找到匹配的 key,执行删除
if typ.Key.Equal(key, slotKey) {
t.used--
m.used--

// 清除 key 的内存
if typ.IndirectKey() {
*(*unsafe.Pointer)(origSlotKey) = nil
} else if typ.Key.Pointers() {
typedmemclr(typ.Key, slotKey)
}

// 清除 elem 的内存
slotElem := g.elem(typ, i)
if typ.IndirectElem() {
*(*unsafe.Pointer)(slotElem) = nil
} else {
typedmemclr(typ.Elem, slotElem)
}

// 4:决定控制字策略(关键设计!)
/*
* 核心逻辑:
* - 探测链遇到空槽会终止
* - 只有"满group"才会出现在探测链中间
* - group一旦满了,在rehash前会一直保持满
*
* 因此:
* - group有空槽 → 删除不会破坏探测链 → 直接清空
* - group无空槽 → 删除可能破坏探测链 → 放置墓碑
*/
var tombstone bool
if g.ctrls().matchEmpty() != 0 {
// --------------------------------
// 场景 A:group 有空槽 → 直接清空
// --------------------------------
g.ctrls().set(i, ctrlEmpty)
t.growthLeft++ // 恢复增长空间
tombstone = false
} else {
// --------------------------------
// 场景 B:group 已满 → 放置墓碑
// --------------------------------
g.ctrls().set(i, ctrlDeleted)
tombstone = true
// 注意:不增加 growthLeft
// 墓碑仍占用空间,但可被后续插入复用
}

t.checkInvariants(typ, m)
return tombstone // 返回是否放置了墓碑
}

// key不匹配,检查下一个匹配位
match = match.removeFirst()
}

// 4:检查空槽(探测终止条件)
match = g.ctrls().matchEmpty()
if match != 0 {
// 遇到空槽 → 探测链结束 → key不存在
return false
}

// 继续探测下一个 group
}
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
假设有3个连续的满group(hash碰撞导致):

初始状态(插入顺序:A → B → C):
┌─────────────────────────┐
│ Group 5: [A][X][X][X][X][X][X][X] │ ← A的首选位置,但已满
└─────────────────────────┘
↓ 继续探测
┌─────────────────────────┐
│ Group 6: [B][X][X][X][X][X][X][X] │ ← B的首选也是Group 5,被挤到这里
└─────────────────────────┘
↓ 继续探测
┌─────────────────────────┐
│ Group 7: [C][X][X][X][X][X][X][X] │ ← C也是,被挤到这里
└─────────────────────────┘

┌─────────────────────────┐
│ Group 8: [ ][...未使用...] │ ← 空槽,探测终止
└─────────────────────────┘


现在删除 B(如果直接设置为 Empty):
┌─────────────────────────┐
│ Group 5: [A][X][X][X][X][X][X][X] │
└─────────────────────────┘

┌─────────────────────────┐
│ Group 6: [Empty][X][X][X][X][X][X][X] │ ← 变成空槽!
└─────────────────────────┘
↓ ⚠️ 探测在这里终止!
┌─────────────────────────┐
│ Group 7: [C][X][X][X][X][X][X][X] │ ← C"失联"了!
└─────────────────────────┘


查找 C 时:
1. 计算 hash → 首选 Group 5
2. Group 5 没有 → 继续探测到 Group 6
3. Group 6 发现 Empty → 终止探测
4. 返回"不存在" ❌ (但 C 实际在 Group 7!)

解决方案:使用墓碑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
删除 B(使用墓碑):
┌─────────────────────────┐
│ Group 5: [A][X][X][X][X][X][X][X] │
└─────────────────────────┘

┌─────────────────────────┐
│ Group 6: [Deleted][X][X][X][X][X][X][X] │ ← 墓碑,探测继续!
└─────────────────────────┘
↓ ✓ 探测继续
┌─────────────────────────┐
│ Group 7: [C][X][X][X][X][X][X][X] │ ← 能找到 C!
└─────────────────────────┘

┌─────────────────────────┐
│ Group 8: [ ][...未使用...] │ ← Empty 才终止
└─────────────────────────┘


查找 C 时:
1. 计算 hash → 首选 Group 5
2. Group 5 没有 → 继续
3. Group 6 发现 Deleted → 跳过,继续探测!
4. Group 7 找到 C ✓

三种控制字代表不同的探测行为:

ctrlEmpty ctrlDeleted H2 (0-127)
空槽 墓碑 有效entry
停止探测,key 不存在 跳过继续找 检查 key,可能找到

实际代码中的体现:

1
2
3
4
5
6
7
8
9
10
// 探测循环中
match = g.ctrls().matchEmpty()
if match != 0 {
// 遇到 Empty → 终止
return false // key不存在
}
// 遇到 Deleted → 循环继续
// 遇到 H2 → 检查key

// 这就是为什么墓碑必须 != ctrlEmpty

为什么有些情况不需要墓碑?

1
2
3
4
5
6
7
8
9
10
11
如果删除后 group 有空槽:

删除前:
Group 6: [B][X][X][ ][ ][ ][ ][ ] ← 有空槽

删除 B:
Group 6: [ ][X][X][ ][ ][ ][ ][ ] ← 直接清空

为什么安全?
因为探测链本来就在这个 group 终止!
(有空槽意味着后续没有碰撞元素)

总结来说,墓碑 = 探测链的"虚拟占位符",如果没有墓碑,删除操作会破坏开放寻址哈希表的探测链,导致某些元素永远找不到!

作用 说明
🔗 保持链接 防止探测链被"截断",后续元素失联
♻️ 可复用 插入时优先使用墓碑位置(不消耗 growthLeft)
🧹 延迟清理 rehash 时一次性清除所有墓碑
⚖️ 权衡 占用空间 vs 探测链完整性

四、可扩展哈希与增量扩容

1. 可扩展哈希原理

传统单表扩容的问题:

1
2
Table (100万元素) → Table (200万元素)
↑ 延迟峰值!

Swiss map 的解决方案:

1
2
将大 map 拆分成多个小 table
每个 table 独立扩容

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
Hash = 0x1A2B3C4D

globalDepth = 2 (使用高 2 位)
┌────────────────────┐
│ Directory (size=4) │
├────────────────────┤
│ [00] → Table0 │ ← localDepth=1
│ [01] → Table0 │ ← 同一个 table
│ [10] → Table1 │ ← localDepth=2
│ [11] → Table2 │ ← localDepth=2
└────────────────────┘

hash >> (64-2) = 00 → 索引 Table0

2. 表分裂过程

1
2
3
4
5
6
7
8
9
10
11
const maxTableCapacity = 1024

func (t *table) rehash(typ *abi.SwissMapType, m *Map) {
newCapacity := 2 * t.capacity
if newCapacity <= maxTableCapacity {
t.grow(typ, m, newCapacity) // 表内扩容
return
}

t.split(typ, m) // 分裂
}

触发条件

  • 负载因子 > 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)的一种,但又做出了革命性的优化。它同时解决了两个层面的核心问题:

  1. 微观问题 (Group 内): 如何在 8 个槽位 (slot) 中一次性找到目标?(解决 CPU 运算和 L1 缓存效率)
  2. 宏观问题 (Map 级): 如何在 map 变得巨大时,实现低延迟的扩容?(解决内存访问和延迟抖动)

在我看来,Swiss Table 有 3 点最重要的核心创新:

2.1 核心创新一:控制字 (ctrls) 与并行匹配

这是 Swiss Map 对缓存为王的极致应用。

  • 第一性原理: CPU 访问 8 个完整的 key 来比较,即使它们都在 L1 缓存中,也是昂贵的(因为 key 可能很大)。最快的方式是,先用元数据过滤掉 99% 的无效比较。

  • 理论:

    1. 数据结构: group 结构包含一个 8 字节的 ctrls 控制字 和 8 个 slots (key/elem 对)。
    2. 哈希分割: 64 位哈希被分为 H1(高 57 位,用于定位)和 H2(低 7 位,用于匹配)
    3. 控制字编码: 这 8 字节的 ctrls 中,每个字节代表一个槽位 (slot) 的状态。
      • 0x80 (1000 0000) = empty(空)
      • 0xFE (1111 1110) = deleted(墓碑)
      • 0x00-0x7F (0xxx xxxx) = full(已占用)
    4. 关键设计: 当槽位为 full 时,它的 7 个 x 位存储的正是 H2 的 7 位哈希值
  • 实践(并行匹配的魔力):当我们要查找一个 key(其 H2 值为 target_h2)时,我们不再需要 for 循环 8 次。Go (Abseil) 使用了一种位运算的魔法:ctrlGroup.matchH2(target_h2)

    1
    2
    3
    v := 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 满了,它不会挂一个链表。
  • 实践(二次探测):
    1. 如果主 group 满了(或者 H2 没匹配上,且没有空槽),我们怎么办?
    2. 我们通过一个二次探测 (Quadratic Probing) 公式: p(i) = (i² + i)/2 + hash (mod 组数),计算下一个要探测的 group 的索引。
    3. 这个公式的重点在于它可预测、无指针、计算快,并且能保证(在 2 的幂容量下)遍历所有 group
    4. 根本收益: 我们用 CPU 计算(下一个索引) 代替了 内存访问(指针跳转)。由于 group 数组是连续内存,下一个 group 极有可能也已在 CPU 缓存中,访问极快。
  • 带来的问题(墓碑机制):这也解释了为什么需要 deleted (0xFE) 状态。如文档所说,如果你删除了探测链中间的一个元素并将其标记为 empty (0x80),那么查找它后面的元素时,探测链会在这里错误地终止。因此,删除时必须留下墓碑 (tombstone),告诉查找操作:"这里曾经有过人,请继续往后找"。

2.3 核心创新三:可扩展哈希与表分裂

这是 Swiss Map 解决宏观扩容问题的精妙之举。

  • 第一性原理: 传统 map 扩容时,需要分配一个 2 倍大的新数组,并把所有元素 rehash 过去。如果 map 有 1 亿个元素,这个延迟是不可接受的。
  • 理论(可扩展哈希):Go 的 Swiss Map 并没有一个无限大的 group 数组。它引入了目录 (Directory) 结构。
    1. 顶层 Map 结构有一个 dirPtrdirLen
    2. dirPtr 指向一个 [dirLen]*table 指针数组。
    3. 每个 table 才是真正存储 group 的地方,但每个 table 的容量是受限的(例如 1024 个槽位)。
  • 实践(增量扩容与表分裂):
    1. 查找: hash(key) 的高位(由 globalDepth 决定)用于在 directory选择使用哪个 tablehash(key) 的低位(H1)用于在该 table 内进行“二次探测”。
    2. 扩容(关键): 当一个 table 负载过高(例如 > 7/8) 且已达到 1024 槽位的上限时,我们不再扩容整个 map
    3. 我们只分裂这一个 table。如 Table1 (localDepth=1) 分裂为 Table1aTable1b (localDepth=2)。
    4. 然后,我们去更新 directory 中的指针。如果 directory 不够大(globalDepth < new_localDepth),我们就只扩容 directory(这很快,因为它只存指针)。
    5. 根本收益: 扩容的开销被均摊了。延迟是可控的,因为我们一次最多只迁移 1024 个元素,而不是 1 亿个。

2.4 总结

总的来说,Go Swiss Table 是三种先进技术的完美结合:

  1. 微观 (Group 内): 并行匹配(基于 SIMD/位运算),实现 O(1) 的 8 槽位匹配。
  2. 中观 (Table 内): 二次探测(开放寻址),实现无指针、缓存友好的冲突解决。
  3. 宏观 (Map 级): 可扩展哈希(目录+表分裂),实现低延迟、增量式的扩容。

这套组合拳,从 L1 缓存、CPU 指令集,一直优化到全局内存布局和延迟控制,是现代高性能 Hash Map 的典范之作。

3. Swiss Table 的演进猜想

理解了是什么(What)之后,追问为什么(Why)和如何演进(How)是掌握一个复杂系统最根本的方法。本小节我们尝试像一个系统设计师一样,从零开始推演 Go Swiss Table 的实现过程。

3.1 第 0 步:明确目标与核心矛盾

  • 初始状态: 拉链法。
  • 要解决的核心矛盾(第一性原理):
    1. 性能瓶颈: 传统 map 的性能瓶颈不在 CPU 计算,而在内存访问
    2. 缓存失效 (Cache Miss): 拉链法的溢出桶链表在内存中是离散的,遍历它会导致大量的指针追逐,每一次跳转都可能是一次昂贵的 Cache Miss(访问主内存)。
    3. 延迟抖动: 传统 map 扩容时,需要一次性迁移所有数据,导致服务(STW)的延迟毛刺。
  • 新 map 的目标:
    1. 缓存友好: 必须用连续内存布局,消除指针追逐。
    2. 低延迟: 必须实现增量式扩容,平滑延迟。
    3. 高吞吐: 查找、插入、删除操作要尽可能快。

3.2 第 1 步:从拉链到开放寻址

为了实现目标 1(缓存友好):

  • 设计师的决策: 抛弃拉链法,全面转向开放寻址法 (Open Addressing)。
  • 为什么? 开放寻址法将所有 (key, value) 存储在一个连续的数组中。
  • 带来的新问题:
    1. 冲突解决: 如何处理哈希冲突?(拉链法用链表,现在没链表了)
    2. 查找效率: 如何在连续数组中快速找到目标?
    3. 删除: 拉链法删除一个节点很简单,开放寻址法删除了一个元素,会不会中断探测链?

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 位)。
    • 查找流程优化:
      1. (昂贵)for i... { if array[i].key == my_key }
      2. (优化后)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 个槽位!
  • 为什么?
    1. 现代 CPU 拥有 SIMD(单指令多数据流)指令集(如 x86 上的 SSE2,ARM 上的 NEON)。
    2. CPU 的 L1 缓存一次加载 64 字节(一个 Cache Line)。我们一个 group 里的 8 字节 ctrls 必定在同一个 Cache Line 里。
  • 解决方案:
    • 将 8 个 ctrl 字节视为一个 uint64
    • 使用 SIMD 指令(如 PCMPEQB)或等效的位运算,并行地将这个 uint64 中的 8 个字节与我们目标的 H2 进行比较。
    • 收益: 查找 group 内 8 个槽位的复杂度,从 O(8)(串行) 降低到 O(1)(并行)。

3.5 第 4 步:解决删除的遗留问题

为了解决第 1 步留下的删除问题:

  • 痛点: 在一个探测链 A -> B -> C 中,如果删除了 B 并标记为 empty
  • 后果: 查找 C 时,探测到 A,下一个是 empty查找会提前终止,导致 C 永远找不到。
  • 解决方案: 引入墓碑 (Tombstone) 状态。
  • 演进: ctrls 字节现在必须有 3 种状态:
    1. empty (0x80):空的,探测终止。
    2. deleted (0xFE):墓碑,插入时可复用查找时请继续
    3. full (0x00-0x7F):有数据(H2)。

3.6 第 5 步:解决扩容的延迟目标

为了实现目标 2(低延迟):

  • 痛点: 我们的开放寻址表(group 数组)如果满了(例如负载 > 87.5%),传统的做法是分配一个 2 倍大的新表,然后 rehash 所有元素。
  • 后果: 导致巨大的延迟毛刺,违背了目标 2。
  • 解决方案: 可扩展哈希 (Extensible Hashing)。
  • 演进:
    1. 我们不使用一个无限大的表。我们将数据拆分到多个固定大小(如 1024 槽位)的 table 中。
    2. 我们引入一个目录 (Directory) 结构,它是一个指针数组,指向这些 table
    3. 使用哈希的高位 (globalDepth) 来决定去哪个 directory 槽位,找到对应的 table
    4. 使用哈希的低位 (H1) 在 table 内部进行二次探测。
  • 收益:
    • 当一个 table 满了,我们只需要分裂这一个 table
    • 扩容的开销被均摊了。一次迁移的元素上限是 1024,而不是 N(N 可能是 1 亿)。这完美解决了延迟抖动问题。

3.7 第 6 步:锦上添花的优化

  • 痛点: 如果用户只 make(map[int]int) 并存了 3 个元素,我们也需要分配 directorytable 吗?太浪费了。
  • 解决方案: 小 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type HashTrieMap[K comparable, V any] struct {
root atomic.Pointer[indirect[K, V]]
keyHash hashFunc
valEqual equalFunc
seed uintptr
}

// 中间节点(indirect node)
type indirect[K, V] struct {
mu Mutex // 节点锁
dead atomic.Bool // 节点是否已死
children [64]atomic.Pointer[node] // 64 个子节点
}

// 叶子节点(entry node)
type entry[K, V] struct {
overflow *entry[K, V] // 哈希冲突链表
key K
value V
}

Trie 结构(每层使用 6 位哈希):

1
2
3
4
5
6
7
               root
/ \
[0-63] [64-127]
/ \ |
[0-15] [16-31] [...]
| |
entries entries

3. 查找流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (ht *HashTrieMap[K, V]) Load(key K) (V, bool) {
hash := Hash(key)
i := ht.root.Load()

// 从根向下遍历,每次取 6 位哈希
for shift := 58; shift >= 0; shift -= 6 {
idx := (hash >> shift) & 0x3F // 取 6 位
n := i.children[idx].Load()

if n == nil {
return zero, false
}
if n.isEntry {
return n.entry().lookup(key)
}
i = n.indirect()
}
}

无锁读取:读操作不需要加锁!

4. 插入流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (ht *HashTrieMap[K, V]) LoadOrStore(key K, value V) (V, bool) {
hash := Hash(key)

retry:
// 无锁查找插入点
i, slot := ht.findInsertPoint(hash)

// 加锁(只锁一个节点)
i.mu.Lock()
defer i.mu.Unlock()

// 双重检查
n := slot.Load()
if n != nil && n changed {
goto retry // 节点变化,重试
}

// 插入新 entry
newEntry := &entry{key: key, value: value}
slot.Store(newEntry)
return value, false
}

细粒度锁

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. 并行匹配算法

    1
    2
    传统:逐个比较 8 次
    Swiss:并行比较 1 次(8 倍提升)

  2. 可扩展哈希

    1
    2
    传统:单表扩容,延迟峰值
    Swiss:多表分裂,延迟可控

  3. SIMD 加速

    1
    2
    AMD64:使用 SSE2 指令
    性能提升:2-3 倍

设计权衡

优势: - ✅ 查找性能:并行匹配 + SIMD - ✅ 空间效率:87.5% 负载因子 - ✅ 增量扩容:表分裂控制延迟

劣势: - ❌ 墓碑管理:需要定期清理 - ❌ 实现复杂:位运算 + SIMD 内联 - ❌ 目录开销:大 map 需要额外结构

选择建议

1
2
3
4
5
6
7
8
9
┌─────────────────────────────────────┐
│ 需要并发? │
│ ├─ 否 → 使用 map (Swiss 或 non-Swiss)|
│ └─ 是 ↓ │
├─────────────────────────────────────┤
│ 写多还是读多? │
│ ├─ 读多 → sync.Map (Read-Write) │
│ └─ 写多 → sync.Map (HashTrieMap) │
└─────────────────────────────────────┘

最佳实践

  1. 默认使用 Swiss map(已启用实验特性)
  2. 高并发场景使用 sync.Map
  3. 简单场景用 RWMutex + map

参考资料: - Abseil Swiss Tables - Go Swiss Map PR - Go HashTrieMap PR