Go 底层原理丨map(Swiss Table 版本) 永久链接

Go 底层原理 · 第 8

Go 底层原理丨map(Swiss Table 版本)

本文系统解析 Go map(Swiss Table 版本)的底层实现,涵盖控制字并行匹配、开放寻址探测、可扩展哈希、细粒度并发控制,并对比 sync.Map 的 HashTrieMap 实现。

特此声明,本篇是笔者与 Google Gemini 3Pro 共创所作,非常庆幸在当今 AI 时代下获取知识已是如此便利,且也为学习者从第一性原理理解所学知识大大降低了门槛。不过本篇的篇章安排和叙述逻辑,均由笔者把控和审阅,欢迎放心阅读。

1. 整体设计思想

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

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

设计灵感来源:Abseil Swiss Tables

2. 核心数据结构

本篇以 Go1.25 版本的源码为基准,完整源码参考:

2.1 Map(顶层结构)

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.2 Table(哈希表)

type table struct {
    used     uint16  // 已使用槽位数
    capacity uint16  // 总槽位数(= groups数 × 8)
    growthLeft uint16  // 剩余可填充槽位

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

    groups groupsReference  // group 数组
}

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

2.3 Group(组结构)

每个 group 包含:

  • 1 个控制字(8 字节):每字节对应一个槽位的状态
  • 8 个槽位:每个槽位存储一个 key-value 对
type groupReference struct {
    data unsafe.Pointer  // 指向实际的 group 内存
}

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

控制字编码

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

2.4 总结图

3. 核心算法

3.1 哈希值分割

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

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

3.2 并行匹配算法

传统方式(non-Swiss):

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

Swiss 方式:

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

实现原理:

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.3 二次探测序列

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)
  • 缓存友好性优于线性探测

3.4 查找流程

底层调用了 runtime/maps/runtime_swiss.go 中的 mapaccess1() 或者 mapaccess2 函数:

  • v := m[k] 调用 runtime_mapaccess1()
  • v,k := m[k] 调用 runtime_mapaccess2()

我们重点来看 runtime_mapaccess1()

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
Mermaid
正在绘制图表…

3.5 插入流程

插入底层调用了 runtime/maps/runtime_swiss.go 中的 runtime_mapassign() 函数:

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 不存在
  • 自动触发表分裂(如果负载过高)
Mermaid
正在绘制图表…

3.6 删除操作(墓碑机制)

删除操作底层调用了 runtime/map_swiss.go 中的 mapdelete() 函数:

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)

// 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
	}
}
Mermaid
正在绘制图表…

墓碑的核心作用是:保持探测链的完整性,防止后续元素"失联"

不用墓碑会发生什么?

假设有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!)

解决方案:使用墓碑

删除 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 ✓

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

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

实际代码中的体现:

// 探测循环中
match = g.ctrls().matchEmpty()
if match != 0 {
    // 遇到 Empty → 终止
    return false  // key不存在
}
// 遇到 Deleted → 循环继续
// 遇到 H2 → 检查key

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

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

如果删除后 group 有空槽:

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

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

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

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

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

4. 可扩展哈希与增量扩容

4.1 可扩展哈希原理

传统单表扩容的问题:

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

Swiss map 的解决方案:

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

目录结构

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

4.2 表分裂过程

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:分裂成两个表

分裂示例

初始状态 (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)

分裂规则

旧 table 的元素根据哈希的新 bit 分流:
hash & newbit == 0 → Table1a (低位)
hash & newbit != 0 → Table1b (高位)

4.3 负载因子

const maxAvgGroupLoad = 7  // 7/8 = 87.5%
  • Non-Swiss:6.5(约 81%)
  • Swiss:7/8(87.5%)

更高的原因:开放寻址 + 墓碑重用提高空间利用率

5. 与 Non-Swiss 对比

维度Non-SwissSwiss
核心结构桶 + 链式溢出组 + 开放寻址
每桶/组大小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 需要额外目录

6. 从源头理解 Swiss Table 版本

swiss table 版本的 map 实现对笔者来说还是比较新奇的,我一开始一直无法彻底这种这种实现思路。因为它的核心思想确实与我们教科书上常见的拉链法(Chaining)或线性探测法(Linear Probing)有很大不同。好在现在有 LLM,所以笔者跟 Gemini 进行了一番探讨,才对 swiss table 的底层原理有了更深的理解。

6.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 计算,而在于内存访问延迟

6.2 Swiss Table 的创新

Swiss Table 的核心思想是用密集的计算换取稀疏的内存访问。它属于开放寻址法(Open Addressing)的一种,但又做出了革命性的优化。它同时解决了两个层面的核心问题:

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

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

6.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)

    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) 次并行比较

6.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),告诉查找操作:"这里曾经有过人,请继续往后找"。

6.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 亿个。

6.2.4 总结

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

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

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

6.3 Swiss Table 的演进猜想

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

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

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

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

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

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

6.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 比较。

6.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)(并行)。

6.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)。

6.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 亿)。这完美解决了延迟抖动问题。

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

  • 痛点: 如果用户只 make(map[int]int) 并存了 3 个元素,我们也需要分配 directorytable 吗?太浪费了。
  • 解决方案: 小 Map 优化
  • 演进:
    • dirLen == 0 时,dirPtr 不指向 directory,而是直接指向单个 group(8 个槽位)。
    • 收益: 对于绝大多数(< 8 个元素)的 map,内存开销极小,且无需任何 table 间接寻址。

6.3.8 总结:从 0 到 1 的演进路径

这个 0 到 1 的实现路径,清晰地展现了从第一性原理出发的思考:

缓存失效(问题)1. 开放寻址(方案)2. 元数据(H2)过滤(解决比较效率)3. 并行匹配(极致优化比较)4. 墓碑(解决删除遗留问题)5. 可扩展哈希(解决扩容延迟)6. 小 Map 优化(解决小内存开销)

7. 并发:sync.Map 的 HashTrieMap 实现

7.1 为什么需要新实现?

传统 sync.Map 的问题

  • 全局锁:写操作竞争激烈
  • 双 map 开销:read + dirty 内存翻倍
  • 提升机制:周期性全量拷贝

HashTrieMap 的解决方案

  • Hash-Trie 结构:树形结构,天然支持分区
  • 细粒度锁:每个节点独立锁,并发度高
  • 无需提升:没有 read/dirty 切换

7.2 HashTrieMap 数据结构

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 位哈希):

               root
              /    \
      [0-63]        [64-127]
      /    \            |
 [0-15]  [16-31]      [...]
   |       |
entries entries

7.3 查找流程

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()
    }
}

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

7.4 插入流程

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
}

细粒度锁

        root (lock1)
       /           \
   indirect1    indirect2
   (lock2)       (lock3)
      ↓             ↓
  goroutine1  goroutine2
  可以同时修改不同子树!

7.5 与传统 sync.Map 对比

维度Read-Write sync.MapHashTrieMap
数据结构双 map(read + dirty)Hash-Trie 树
并发策略全局锁 + 读写分离细粒度锁(per-node)
读性能命中 read:O(1) 无锁
未命中:加锁
始终无锁,O(log₆₄ n)
写性能快速路径:原子操作
慢速路径:全局锁
锁粒度细,O(log₆₄ n)
内存开销两份 mapTrie 节点开销
提升机制需要周期性提升无需提升
类型安全map[any]any泛型 HashTrieMap[K,V]

HashTrieMap 的优势

  1. ✅ 细粒度并发:锁竞争大幅降低
  2. ✅ 无需提升:没有全量拷贝开销
  3. ✅ 泛型支持:类型安全
  4. ✅ 读无锁:始终无需加锁

适用场景

  • 高并发写入
  • 键空间大(Trie 的空间优势)
  • 需要泛型支持

8. 总结

8.1 Swiss Map 核心创新

  1. 并行匹配算法

    传统:逐个比较 8 次
    Swiss:并行比较 1 次(8 倍提升)
    
  2. 可扩展哈希

    传统:单表扩容,延迟峰值
    Swiss:多表分裂,延迟可控
    
  3. SIMD 加速

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

8.2 设计权衡

优势

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

劣势

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

8.3 选择建议

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

最佳实践

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

参考资料

下一篇Go 底层原理丨map(非 swiss 版本)

来自开放网络的回应

引用、喜欢、转发和站外回复会通过 Webmention 回到这里。

接收与展示代码已经就绪,注册正式域名后即可开始收集回应。

评论

评论由 GitHub Discussions 保存和管理。