Go 的 map[key]value 用起来如此简单,一行代码就能完成增删改查。但在这简洁的语法背后,是一个精心设计的哈希表实现,它需要处理哈希冲突、动态扩容、迭代顺序随机化、并发检测等多个复杂问题。
深入 runtime/map.go 和 internal/runtime/maps/ 的源码,揭示 map 的每一个底层细节。Go 1.25 默认使用基于 Swiss Tables 的新实现,两种实现都会介绍。
一、hmap:map 的运行时头部
每个 Go map 在运行时都是一个 hmap 结构体,定义在 runtime/map.go。make(map[K]V, hint) 在编译期转换为 runtime.makemap 调用:
// src/runtime/map.go(简化版)func makemap(t *maptype, hint int, h *hmap) *hmap { // 检查 hint 是否导致内存溢出 mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size) if overflow || mem > maxAlloc { hint = 0 } if h == nil { h = new(hmap) } h.hash0 = fastrand() // 随机哈希种子
// 根据 hint 计算最小的 B,使得 loadFactor(2^B) >= hint B := uint8(0) for overLoadFactor(hint, B) { B++ } h.B = B
if h.B != 0 { var nextOverflow *bmap h.buckets, nextOverflow = makeBucketArray(t, h.B, nil) if nextOverflow != nil { h.extra = new(mapextra) h.extra.nextOverflow = nextOverflow } } return h}编译器对小 map 有额外优化:当 map 分配在栈上且元素不超过 8 个时,编译器直接在栈上分配 hmap 和单个 bmap,避免堆分配和 GC 开销。
// src/runtime/map.go (简化版)type hmap struct { count int // 元素个数(len(map) 返回此值) flags uint8 // 状态标志(是否正在写入、是否正在迭代等) B uint8 // 桶数量的对数:桶数 = 2^B noverflow uint16 // 溢出桶的近似数量 hash0 uint32 // 哈希种子(随机化,防止 HashDoS 攻击) buckets unsafe.Pointer // 桶数组的指针 oldbuckets unsafe.Pointer // 扩容时指向旧桶数组 nevacuate uintptr // 扩容进度:下一个要搬迁的旧桶编号 extra *mapextra // 可选的溢出桶信息}关键字段
| 字段 | 作用 | 说明 |
|---|---|---|
count | len(m) 的返回值 | 直接读取,O(1) |
B | 桶数的对数 | 桶数 = 2^B,初始 B=0(1 个桶) |
hash0 | 哈希种子 | 创建时随机生成,防止 HashDoS |
buckets | 当前桶数组 | 指向连续的 2^B 个桶 |
oldbuckets | 旧桶数组 | 扩容期间非 nil,指向扩容前的桶 |
flags | 状态标志位 | 4 个标志位,控制并发检测和迭代器安全 |
flags 标志位
flags 是一个 uint8,目前使用了 4 个位:
const ( iterator = 1 // 可能有迭代器正在遍历 buckets oldIterator = 2 // 可能有迭代器正在遍历 oldbuckets hashWriting = 4 // 有协程正在向 map 写入 key sameSizeGrow = 8 // 等量扩容(溢出桶太多,桶数不翻倍))这 4 个标志位在扩容和迭代中扮演关键角色:
hashWriting:每次写操作(mapassign/mapdelete)开始时设置,结束时清除。如果发现已经设置,说明有并发写入,直接throw(不可 recover 的 fatal)。iterator/oldIterator:迭代器启动时设置,用于告知 evacuate 函数”有人在遍历旧桶,搬迁时需要保留旧桶数据”。扩容时hashGrow会把iterator转接到oldIterator,因为原来的 buckets 变成了 oldbuckets。sameSizeGrow:标记当前扩容是等量扩容还是增量扩容,evacuate 函数据此决定是否初始化 Y 部分。
mapextra:溢出桶的辅助管理
extra 字段是可选的,只有当 map 存在溢出桶时才会分配:
type mapextra struct { overflow *[]*bmap // 当前桶数组的溢出桶指针切片 oldoverflow *[]*bmap // 旧桶数组的溢出桶指针切片(扩容期间使用) nextOverflow *bmap // 下一个可用的预分配溢出桶}mapextra 的存在是为了 GC:溢出桶通过 bmap.overflow 指针链成链表,但 bmap 的 overflow 字段在源码定义中并不存在(它是编译器生成的),GC 无法直接追踪。通过 overflow 和 oldoverflow 切片保存所有溢出桶的指针,GC 就能正确扫描它们。nextOverflow 则指向预分配的溢出桶链中下一个空闲桶,避免频繁向运行时申请内存。
二、bmap:桶的结构
每个桶(bmap)可以存储 8 个键值对:
// 编译器会根据 key 和 value 的类型动态生成 bmaptype bmap struct { tophash [8]uint8 // 每个槽的哈希高 8 位 // 后面跟着(编译器生成): // keys [8]keyType // 8 个键 // values [8]valueType // 8 个值 // overflow *bmap // 溢出桶指针}为什么是 8 个?
8 是精心选择的平衡值:
- 太小:溢出桶太多,查找效率下降
- 太大:桶太大,缓存局部性差
- 8 个键值对 + tophash 刚好适合 CPU 缓存行(64 字节)
编译器如何生成 bmap
源码中 bmap 只定义了 tophash 字段,因为 Go 的 map 需要支持任意类型的 key/value,而泛型在早期版本不存在。编译器在类型检查阶段根据 key 和 value 的具体类型,动态构建完整的 bmap 布局:
// 编译器生成的实际 bmap 布局(以 map[string]int 为例)type bmap struct { tophash [8]uint8 // 哈希高 8 位 keys [8]string // 8 个 string key values [8]int // 8 个 int value overflow uintptr // 溢出桶指针(用 uintptr 而非 *bmap,避免 GC 扫描)}key 和 value 分开存储(keys 在前,values 在后),而不是 key-value-key-value 交替排列。这样做是为了在 key 和 value 类型不同时减少因对齐产生的内存填充。例如 map[int8]int64,如果交替排列,每个 int8 后面需要 7 字节填充;分开排列则 8 个 int8 紧凑存储,后面再放 8 个 int64。
溢出桶的预分配策略
创建 map 时,makeBucketArray 会预分配溢出桶,与正常桶在内存中连续存放:
func makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) { base := bucketShift(b) // 正常桶数量 = 2^B nbuckets := base // B >= 4 时,额外预分配 2^(B-4) 个溢出桶 if b >= 4 { nbuckets += bucketShift(b - 4) // ... 内存对齐调整 } buckets = newarray(t.bucket, int(nbuckets)) // 预分配的溢出桶从 base 位置开始 if base != nbuckets { nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize))) // 最后一个预分配溢出桶的 overflow 指向桶数组头部 // 作为"已用完"的哨兵 last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize))) last.setoverflow(t, (*bmap)(buckets)) } return buckets, nextOverflow}当 B >= 4 时,预分配 2^(B-4) 个溢出桶。这些溢出桶与正常桶在同一块连续内存中,通过 h.extra.nextOverflow 逐个领取。最后一个预分配溢出桶的 overflow 指针指向桶数组头部(而非 nil),作为”预分配已用完”的标记。当预分配用完后,后续的溢出桶通过 newobject 动态分配,不再与正常桶连续。
tophash 的作用
tophash 存储每个键哈希值的高 8 位,用于快速过滤不匹配的槽位:
tophash 的特殊值:
| 值 | 含义 |
|---|---|
| 0-5 | 空槽的特殊标记 |
emptyRest (0) | 此槽及后续所有槽都空 |
emptyOne (1) | 此槽空,但后续可能有数据 |
evacuatedX (2) | 扩容时:数据已搬到新桶 X 侧 |
evacuatedY (3) | 扩容时:数据已搬到新桶 Y 侧 |
evacuatedEmpty (4) | 扩容时:此槽空,已标记搬迁 |
| ≥ 5 | 正常的 tophash 值 |
三、哈希冲突处理:溢出桶
当一个桶的 8 个槽都满了,新键值对会放入溢出桶(overflow bucket):
溢出桶的查找需要遍历链表,性能从 O(1) 退化为 O(k),其中 k 是溢出桶的数量。Go 通过扩容来控制溢出桶的数量。
四、核心操作的源码实现
查找:mapaccess1 与 mapaccess2
m[key] 在编译期间被转换为 OINDEXMAP 操作,中间代码生成阶段根据赋值左侧接收参数的个数,路由到不同的运行时函数:
v := m[key] // => v := *mapaccess1(maptype, &m, &key)v, ok := m[key] // => v, ok := mapaccess2(maptype, &m, &key)mapaccess1 返回指向目标值的指针,mapaccess2 额外返回一个 bool 表示 key 是否存在。整个查找过程分三步:定位桶、遍历 tophash 快速过滤、比较完整 key。
// src/runtime/map.go(简化版)func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { hash := t.hasher(key, h.hash0) bucket := hash & bucketMask(h.B)
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize)))
// 扩容期间:如果旧桶未搬迁,在旧桶中查找 if h.growing() { oldBucket := hash & bucketMask(h.B - 1) oldB := (*bmap)(add(h.oldbuckets, oldBucket*uintptr(t.bucketsize))) if !evacuated(oldB) { b = oldB } }
top := tophash(hash) for ; b != nil; b = b.overflow(t) { for i := uintptr(0); i < bucketCnt; i++ { if b.tophash[i] != top { // emptyRest:此槽及后续所有槽都空,直接结束查找 if b.tophash[i] == emptyRest { goto done } continue } // tophash 匹配,比较完整 key k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) if t.key.equal(key, k) { // 找到!返回对应 value 的地址 return add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize)) } } }done: return zeroVal[0] // 未找到,返回零值}查找的优化点在于 tophash 快速过滤:8 个槽的 tophash 逐个比较,只有高 8 位匹配时才进行完整的 key 比较。遇到 emptyRest 标记时可以提前终止,因为后续槽一定都是空的。
mapaccess2 的逻辑与 mapaccess1 几乎完全相同,区别只在返回值:
func mapaccess2(t *maptype, h *hmap, key unsafe.Pointer) (unsafe.Pointer, bool) { // ... 同 mapaccess1 的定位和遍历逻辑 for ; b != nil; b = b.overflow(t) { for i := uintptr(0); i < bucketCnt; i++ { if b.tophash[i] != top { if b.tophash[i] == emptyRest { break bucketloop } continue } k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) if t.key.equal(key, k) { v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize)) return v, true // 找到,返回值和 true } } } return unsafe.Pointer(&zeroVal[0]), false // 未找到,返回零值和 false}推荐使用 v, ok := m[key] 的形式判断 key 是否存在。当 value 类型的零值是合法值时(如 map[string]int 中 0 是合法值),仅靠返回值无法区分”key 不存在”和”key 对应零值”。
赋值:mapassign
m[key] = value 在编译期间被转换为 runtime.mapassign 函数调用。但 mapassign 只返回 value 的地址,真正的赋值由编译器插入的写操作完成:
# m[key] = "88" 的汇编片段CALL runtime.mapassign_fast64(SB) # 返回 value 地址到 DILEAQ go.string."88"(SB), AX # AX = &"88"MOVQ AX, (DI) # *DI = AX,完成赋值mapassign 是 map 最复杂的操作,因为它需要处理插入新 key、更新已有 key、触发扩容等多种情况。
// src/runtime/map.go(简化版,核心逻辑)func mapassign(t *maptype, h *hmap, key unsafe.Pointer) { // 并发检测 if h.flags&hashWriting != 0 { throw("concurrent map writes") } h.flags ^= hashWriting // 用 XOR 设置写入标志
hash := t.hasher(key, h.hash0)
// 触发扩容(如果需要) if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { hashGrow(t, h) }
// 如果正在扩容,先搬迁当前桶 if h.growing() { growWork(t, h, bucket) }
bucket := hash & bucketMask(h.B) b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize))) top := tophash(hash)
var inserti *uint8 // 记录第一个空槽的 tophash 地址 var insertk unsafe.Pointer // 记录第一个空槽的 key 地址
for ; b != nil; b = b.overflow(t) { for i := uintptr(0); i < bucketCnt; i++ { if b.tophash[i] != top { // 记录第一个空槽,用于后续插入 if isEmpty(b.tophash[i]) && inserti == nil { inserti = &b.tophash[i] insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) } if b.tophash[i] == emptyRest { goto done // 后续都空,不用继续找 } continue } // tophash 匹配,比较完整 key k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) if t.key.equal(key, k) { // key 已存在,更新 value val := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize)) typedmemmove(t.elem, val, val) goto done } } }
done: if inserti == nil { // 所有桶都满了,创建新的溢出桶 newb := h.newoverflow(t, b) inserti = &newb.tophash[0] insertk = add(unsafe.Pointer(newb), dataOffset) }
// 插入新 key *inserti = top typedmemmove(t.key, insertk, key) h.count++
// 清除写入标志(XOR toggle off) h.flags ^= hashWriting}mapassign 的一个巧妙设计是:遍历桶时同时记录第一个空槽的位置。如果 key 不存在,直接用记录的空槽插入,不需要再遍历一次。这把”查找 + 插入”合并成一次遍历。
另一个值得注意的设计是扩容后的 goto again 模式。完整的 mapassign 中,触发 hashGrow 后会跳回 again 标签重新计算桶号:
// src/runtime/map.go(关键控制流)func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { hash := t.hasher(key, h.hash0) h.flags ^= hashWriting
again: bucket := hash & bucketMask(h.B) // 扩容期间,先搬迁当前桶 if h.growing() { growWork(t, h, bucket) } // ... 查找和插入逻辑
// 触发扩容后跳回 again,重新定位桶 if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { hashGrow(t, h) goto again } // ...}扩容可能改变 B 的值,桶掩码随之变化,因此需要重新计算 bucket。goto again 保证了扩容后 key 一定能定位到正确的新桶。
删除:mapdelete
delete(m, key) 在运行时调用 mapdelete,删除操作不会缩小桶数组,只是将槽标记为空:
// src/runtime/map.go(简化版)func mapdelete(t *maptype, h *hmap, key unsafe.Pointer) { if h.flags&hashWriting != 0 { throw("concurrent map writes") } h.flags ^= hashWriting
hash := t.hasher(key, h.hash0)
// 扩容期间先搬迁当前桶 if h.growing() { growWork(t, h, bucket) }
b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize))) top := tophash(hash)
for ; b != nil; b = b.overflow(t) { for i := uintptr(0); i < bucketCnt; i++ { if b.tophash[i] != top { if b.tophash[i] == emptyRest { goto done // 后续都空 } continue } k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) if t.key.equal(key, k) { // 找到 key,清除 key 和 value b.tophash[i] = emptyOne // 清除 key if t.key.ptrdata != 0 { memclrHasPointers(k, t.key.size) } // 清除 value v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize)) if t.elem.ptrdata != 0 { memclrHasPointers(v, t.elem.size) } h.count--
// 检查后续槽是否都空,如果是则标记为 emptyRest // 这样下次查找可以提前终止 // ... goto done } } }done: h.flags ^= hashWriting}删除后,emptyOne 标记可能被提升为 emptyRest:如果当前槽后面的所有槽也都是空的,就把当前槽标记为 emptyRest,这样查找时遇到 emptyRest 就能提前终止,不用继续遍历溢出桶链。这个优化在大量删除后特别有效。
具体的提升逻辑如下:
// src/runtime/map.go(emptyOne → emptyRest 提升)// 删除 key 后,检查是否可以将 emptyOne 提升为 emptyRestif i == 0 { // 当前是桶的第一个槽,检查溢出桶是否为空 // 如果溢出桶链都为空,可以提升 if b.overflow(t) != nil { goto done // 还有溢出桶,不能提升 }} else if b.tophash[i-1] != emptyRest { goto done // 前一个槽不是 emptyRest,不能提升}// 当前槽及后续都空,标记为 emptyRestb.tophash[i] = emptyRest
// 向前回溯:如果前一个槽也是 emptyOne,继续提升为 emptyRest// 这确保了整个空槽区间末尾的 emptyRest 传播提升过程会向前回溯:如果当前槽变为 emptyRest,而前一个槽是 emptyOne,那么前一个槽也变为 emptyRest。这个传播确保了一连串空槽的最后一个一定是 emptyRest,查找时可以尽早终止。
五、扩容策略
Go map 有两种扩容触发条件和两种扩容类型:
触发条件
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) { // ... 赋值逻辑
// 触发条件 1:负载因子超过 6.5 if !h.growing() && overLoadFactor(h.count+1, h.B) { hashGrow(t, h) // 增量扩容(翻倍) }
// 触发条件 2:溢出桶太多 else if tooManyOverflowBuckets(h.noverflow, h.B) { hashGrow(t, h) // 等量扩容(整理) }}| 条件 | 阈值 | 扩容类型 | 目的 |
|---|---|---|---|
| 负载因子 > 6.5 | count / 2^B > 6.5 | 增量扩容 | 增加桶数,减少溢出 |
| 溢出桶太多 | B < 15 时 noverflow >= 2^B;B >= 15 时 noverflow >= 2^15 | 等量扩容 | 整理桶,消除空槽 |
溢出桶阈值的分段设计是为了防止极端情况:当 B 很大时(桶数已经很多),2^15 个溢出桶已经足够多,不需要按 2^B 继续增长。
增量扩容(翻倍)
等量扩容(整理)
当溢出桶太多但负载因子不高时(大量删除后重新插入),等量扩容不增加桶数,只是重新排列数据,消除空槽和溢出桶:
- 扩容前(B=1, 2 个桶,空槽和溢出桶很多)
- 桶 0:
[A, _, C, _, E, _, G, _]→ 溢出桶:[I] - 桶 1:
[B, _, D, _, F, _, H, _]→ 溢出桶:[J]
- 桶 0:
- 等量扩容后(B=1, 2 个桶,紧凑排列)
- 桶 0:
[A, C, E, G, I, _, _, _] - 桶 1:
[B, D, F, H, J, _, _, _]
- 桶 0:
等量扩容的本质是把分散在溢出桶中的数据压缩回主桶,减少指针追踪,提升缓存命中率。
等量扩容解决的是一个”空城”问题:反复插入和删除 key,导致元素总数不多(负载因子不高),但溢出桶大量积累。这些溢出桶不会被自动回收,因为 Go map 的桶数组只扩不缩。等量扩容通过重新整理,把有效数据紧凑排列,让 GC 回收空出来的溢出桶。
但等量扩容有一个无法解决的极端情况:如果所有 key 的哈希值都相同(哈希函数退化),它们必然落在同一个桶中,溢出桶会持续增长。等量扩容只是把数据搬到新桶,但哈希值相同的 key 仍然会落在同一个新桶,溢出桶问题不会改善。这时整个哈希表已经退化为链表,操作效率 O(n),唯一的解决办法是换一个更好的哈希函数。
六、渐进式扩容
Go map 的扩容是渐进式的,在每次 map 操作时搬迁少量桶。
扩容分两步:hashGrow 分配新桶,growWork/evacuate 逐步搬迁数据。
// src/runtime/map.go(简化版)func hashGrow(t *maptype, h *hmap) { bigger := uint8(1) // 等量扩容:B 不变 if !overLoadFactor(int64(h.count), h.B) { bigger = 0 h.flags |= sameSizeGrow }
oldbuckets := h.buckets newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger)
// 提交扩容:旧桶挂到 oldbuckets,新桶挂到 buckets h.B += bigger h.flags = h.flags &^ (iterator | oldIterator) if h.flags&iterator != 0 { h.flags |= oldIterator } h.oldbuckets = oldbuckets h.buckets = newbuckets h.nevacuate = 0 h.noverflow = 0}hashGrow 只做分配,不做搬迁。它把旧桶挂到 oldbuckets,分配新的空桶挂到 buckets,然后把 nevacuate 置 0 表示搬迁进度为零。
其中对 flags 的操作值得展开说明:
// 先清除 iterator 和 oldIterator 位flags := h.flags &^ (iterator | oldIterator)// 如果原来有迭代器在使用 buckets,转接到 oldIteratorif h.flags&iterator != 0 { flags |= oldIterator}h.flags = flags&^ 是 Go 的”按位置零”运算符:x &^ y 将 y 中为 1 的位在 x 中清零。这段代码的含义是:扩容后原来的 buckets 变成了 oldbuckets,所以原来标记”有迭代器在遍历 buckets”的 iterator 标志需要转接到 oldIterator,表示”有迭代器在遍历 oldbuckets”。evacuate 函数会检查 oldIterator,如果为 0 就可以在搬迁后清除旧桶数据,帮助 GC。
func growWork(t *maptype, h *hmap, bucket uintptr) { // 每次操作搬迁 1 个当前桶 evacuate(t, h, bucket&h.oldbucketmask())
// 再搬迁 1 个未搬迁的旧桶(推进扩容进度) if h.growing() { evacuate(t, h, h.nevacuate) }}growWork 每次搬迁两个桶:第一个是当前操作涉及的旧桶(bucket & h.oldbucketmask() 将新桶号映射回旧桶号),第二个是 nevacuate 指向的未搬迁旧桶。搬迁当前桶保证了本次操作的数据一致性,搬迁 nevacuate 桶则推进整体进度。h.growing() 的实现很简单,就是检查 oldbuckets != nil。
扩容期间的查找
扩容期间,查找需要同时检查新旧桶。具体逻辑取决于扩容类型:
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { hash := t.hasher(key, h.hash0) m := bucketMask(h.B) b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
if c := h.oldbuckets; c != nil { // 增量扩容:旧桶数是新桶数的一半,掩码右移一位 if !h.sameSizeGrow() { m >>= 1 } oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize))) // 如果旧桶还没搬迁,在旧桶中查找 if !evacuated(oldb) { b = oldb } } // ... 在 b 中查找}增量扩容时,旧桶掩码是 bucketMask(h.B-1),比新桶掩码少一位。等量扩容时新旧桶数相同,掩码不变。如果旧桶尚未搬迁(evacuated 返回 false,即 tophash[0] 不是 evacuatedX/evacuatedY/evacuatedEmpty),就在旧桶中查找,因为数据还在那里。
搬迁源码:evacuate 函数
evacuate 是扩容的核心搬迁函数,每次调用搬迁一个旧桶(包括其溢出桶链)的所有数据。理解它的逻辑,就理解了增量扩容和等量扩容在源码层面的区别。
evacDst:搬迁目标上下文
evacuate 使用 evacDst 结构体记录搬迁目标的位置信息,增量扩容时有两个 evacDst(X 和 Y),等量扩容时只用一个:
type evacDst struct { b *bmap // 目标桶 i int // 目标桶内当前槽索引(0-7) k unsafe.Pointer // 目标 key 的写入地址 v unsafe.Pointer // 目标 value 的写入地址}完整搬迁流程
// src/runtime/map.go(详细版)func evacuate(t *maptype, h *hmap, oldbucket uintptr) { // 定位旧桶 b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))) newbit := h.noldbuckets() // 旧桶数 = 2^oldB
if !evacuated(b) { // 初始化 X 部分的 evacDst var xy [2]evacDst x := &xy[0] x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize))) x.k = add(unsafe.Pointer(x.b), dataOffset) x.v = add(x.k, bucketCnt*uintptr(t.keysize))
// 增量扩容时,初始化 Y 部分 // Y 部分的桶号 = oldbucket + 2^oldB y := &xy[1] if !h.sameSizeGrow() { y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize))) y.k = add(unsafe.Pointer(y.b), dataOffset) y.v = add(y.k, bucketCnt*uintptr(t.keysize)) }
// 遍历旧桶及其溢出桶链 for ; b != nil; b = b.overflow(t) { k := add(unsafe.Pointer(b), dataOffset) v := add(k, bucketCnt*uintptr(t.keysize))
// 遍历桶内 8 个槽 for i := 0; i < bucketCnt; i, k, v = i+1, add(k, uintptr(t.keysize)), add(v, uintptr(t.valuesize)) { top := b.tophash[i] if top == emptyRest { b.tophash[i] = evacuatedEmpty continue } if top < minTopHash { throw("bad map state") }
k2 := k if t.indirectkey { k2 = *((*unsafe.Pointer)(k2)) }
// 决定搬迁到 X 还是 Y var useY uint8 if !h.sameSizeGrow() { hash := t.hasher(k2, uintptr(h.hash0)) // hash 的第 B 位决定去 X 还是 Y if hash&newbit != 0 { useY = 1 } // NaN key 的特殊处理(见下文) // ... }
b.tophash[i] = evacuatedX + useY dst := &xy[useY]
// 目标桶满了,创建溢出桶 if dst.i == bucketCnt { dst.b = h.newoverflow(t, dst.b) dst.i = 0 dst.k = add(unsafe.Pointer(dst.b), dataOffset) dst.v = add(dst.k, bucketCnt*uintptr(t.keysize)) }
// 写入 tophash dst.b.tophash[dst.i&(bucketCnt-1)] = top // 复制 key if t.indirectkey { *(*unsafe.Pointer)(dst.k) = k2 } else { typedmemmove(t.key, dst.k, k) } // 复制 value if t.indirectelem { *(*unsafe.Pointer)(dst.v) = *(*unsafe.Pointer)(v) } else { typedmemmove(t.elem, dst.v, v) } // 推进目标位置 dst.i++ dst.k = add(dst.k, uintptr(t.keysize)) dst.v = add(dst.v, uintptr(t.valuesize)) } }
// 如果没有迭代器在使用旧桶,清除旧桶的 key/value 部分 // 保留 tophash(指示搬迁状态),帮助 GC 回收内存 if h.flags&oldIterator == 0 && t.bucket.ptrdata != 0 { b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))) memclrHasPointers(add(unsafe.Pointer(b), dataOffset), uintptr(t.bucketsize)-dataOffset) } }
// 更新搬迁进度 if oldbucket == h.nevacuate { advanceEvacuationMark(h, t, newbit) }}搬迁逻辑的关键点:
-
X/Y 部分拆分:增量扩容时,旧桶
i中的数据可能分裂到新桶i(X 部分)和新桶i + 2^oldB(Y 部分,oldB 为扩容前的 B 值)。判断依据是hash & newbit,即哈希值第 B 位是 0 还是 1。等量扩容没有 Y 部分,所有数据按序号搬到对应的新桶。 -
tophash 标记搬迁状态:搬迁完成后,旧桶的 tophash 被设为
evacuatedX或evacuatedY,表示数据已搬到新桶的 X 或 Y 部分。查找时如果发现 tophash 是这些特殊值,就知道要去新桶找。 -
indirectkey/indirectelem:当 key 或 value 超过 128 字节时,bmap 中存储的是指针而非值。搬迁时需要区分这两种情况:指针类型直接复制指针,值类型用
typedmemmove复制。 -
旧桶清理:如果确认没有迭代器在使用旧桶(
oldIterator标志为 0),搬迁完一个桶后立即清除其 key/value 部分,帮助 GC 尽早回收内存。tophash 部分保留,因为后续查找需要通过 tophash 判断搬迁状态。
X/Y 拆分的位运算原理
增量扩容时,桶数从 2^oldB 变为 2^(oldB+1)。桶掩码多了一位,原来只用低 B 位定位桶,现在用低 B+1 位。这意味着原来落在同一个旧桶的 key,可能因为第 B 位不同而分裂到两个新桶。
以 B=2 扩容到 B=3 为例:
旧桶掩码: 0b011 (3) 新桶掩码: 0b111 (7)
旧桶 2 中的两个 key: key A: hash = ...010 → hash & 0b011 = 2 → 旧桶 2 key B: hash = ...110 → hash & 0b011 = 2 → 旧桶 2
扩容后: key A: hash & 0b111 = 2 → 新桶 2 (X 部分,第 B 位 = 0) key B: hash & 0b111 = 6 → 新桶 6 (Y 部分,第 B 位 = 1)
newbit = 2^2 = 4key A: hash & newbit = ...010 & 100 = 0 → useX = truekey B: hash & newbit = ...110 & 100 = 4 → useX = falsehash & newbit 就是检查哈希值第 B 位的值:0 去 X 部分(桶号不变),1 去 Y 部分(桶号加上 2^oldB)。
搬迁进度推进:advanceEvacuationMark
func advanceEvacuationMark(h *hmap, t *maptype, newbit uintptr) { h.nevacuate++ // 向前扫描最多 1024 个桶,跳过已搬迁的 stop := h.nevacuate + 1024 if stop > newbit { stop = newbit } for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) { h.nevacuate++ } // 全部搬迁完毕 if h.nevacuate == newbit { h.oldbuckets = nil if h.extra != nil { h.extra.oldoverflow = nil } h.flags &^= sameSizeGrow }}nevacuate 记录下一个要搬迁的旧桶编号。每次搬迁后向前扫描最多 1024 个桶,跳过已搬迁的,加速进度。这个 1024 的值是保守的安全上限,源码注释提到实验表明它”至少高了一个数量级”,但确保了搬迁进度的 O(1) 行为。
全部搬迁完毕后,oldbuckets 和 oldoverflow 被置 nil,释放旧桶内存,sameSizeGrow 标志被清除。此时 map 完全切换到新桶,扩容结束。
NaN key 的搬迁处理
math.NaN() 作为 map 的 key 有一个特殊问题:每次计算 hash(NaN()) 的结果都不同。这意味着 m[math.NaN()] 永远查不到之前插入的值,因为查找时算出的哈希与插入时不同。
搬迁时,NaN key 不能重新计算哈希来决定去 X 还是 Y 部分(因为结果不可复现)。Go 的处理方式是用 tophash 的最低位来决定:
// src/runtime/map.go evacuate 内部if !h.sameSizeGrow() { hash := t.hasher(k2, uintptr(h.hash0)) // NaN key 的特殊处理:k2 != k2 说明是 NaN if h.flags&iterator != 0 && !t.reflexivekey && !t.key.equal(k2, k2) { if top&1 != 0 { hash |= newbit // 强制第 B 位置 1 → 去 Y 部分 } else { hash &^= newbit // 强制第 B 位置 0 → 去 X 部分 } // 重新计算 tophash,因为 hash 被修改了 top = uint8(hash >> (sys.PtrSize*8 - 8)) if top < minTopHash { top += minTopHash } } useY = hash & newbit}这段代码有几个值得注意的细节:
-
只在有迭代器时才特殊处理:
h.flags&iterator != 0是前提条件。如果没有迭代器在遍历 map,NaN key 随便搬到哪个桶都无所谓,因为反正查不到。但有迭代器时,必须保证搬迁前后遍历结果的一致性,所以用 tophash 最低位做确定性决定。 -
tophash 最低位做决定:同一个 NaN key 的 tophash 值是固定的(插入时计算一次,存储在 tophash 中),所以
top&1的结果是确定的。这保证了同一个 NaN key 在多次搬迁中始终去同一个部分。 -
修改 hash 后重算 tophash:强制修改 hash 的第 B 位后,高 8 位可能变化,需要重新计算 tophash 写入新桶。
但 m[math.NaN()] 仍然查不到值,这是 NaN 语义的固有行为,不是 bug。NaN key 只有在 range 遍历整个 map 时才能被访问到。
七、迭代器与随机化
Go map 的迭代顺序是故意随机化的。for k, v := range m 每次跑出的顺序都不同,这不是实现上的疏忽,而是运行时在每次迭代开始时主动掷了一次骰子。
随机化发生在 mapiterinit 里:调用 fastrand() 生成一个随机数,从中取出起始桶编号和桶内起始槽位,迭代器就从这个随机位置开始遍历。
type hiter struct { key unsafe.Pointer // 当前 key elem unsafe.Pointer // 当前 value t *maptype h *hmap buckets unsafe.Pointer // 当前桶数组指针 bptr *bmap // 当前桶 bucket uintptr // 当前桶编号 checkBucket uintptr // 扩容时的校验桶 startBucket uintptr // 随机起始桶 offset uint8 // 桶内随机起始槽位 // ... 其他字段}
func mapiterinit(t *maptype, h *hmap, it *hiter) { it.t = t it.h = h // 随机选择起始桶和桶内起始槽位 r := uintptr(fastrand()) it.startBucket = r & bucketMask(h.B) it.offset = uint8(r >> h.B & (bucketCnt - 1)) // 初始化后由 mapiternext 推进到第一个有效元素 mapiternext(it)}随机数被拆成两部分使用:低位决定从哪个桶开始(r & bucketMask(h.B)),高位决定桶内从第几个槽开始(r >> h.B & (bucketCnt-1),bucketCnt 是 8)。两个维度都随机,遍历起点就分布在 2^B × 8 个位置中的任意一个。
确定起点后,mapiternext 负责逐个推进。遍历逻辑其实是个环形扫描:从 startBucket 的 offset 槽位开始,依次检查每个槽,到桶末尾后跳到溢出桶,溢出桶走完就回到主桶数组下一个桶,绕一圈回到 startBucket 时迭代结束。
func mapiternext(it *hiter) { h := it.h b := it.bptr i := it.offsetnext: if b == nil { // 当前桶没数据,跳到下一个桶 if it.bucket == it.startBucket && it.wrapped { return // 已经绕回起点,遍历结束 } b = (*bmap)(bucketAt(h.buckets, it.bucket, h.B)) i = 0 it.bucket++ if it.bucket == bucketMask(h.B)+1 { it.bucket = 0 // 绕回桶数组头部 it.wrapped = true } } for ; i < bucketCnt; i++ { // 跳过空槽,emptyRest 表示从这里往后整个桶和溢出桶都空 if isEmpty(b.tophash[i]) { if b.tophash[i] == emptyRest { continue // 跳到下一个桶 } } // 命中一个有效槽,记录到 it.key/it.elem,推进 offset 后返回 // ...(省略取 key/value 的逻辑) it.offset = i + 1 return } // 当前桶走完,跳到溢出桶继续 b = b.overflow(t) i = 0 goto next}如果 map 正在渐进式扩容,迭代还要处理新旧两套桶数组:访问到一个尚未搬迁的桶时,用 checkBucket 记录,按”旧桶 + 搬迁方向”推导出元素应该落在哪。这意味着扩容期间的迭代顺序还和搬迁进度有关,进一步加剧了不确定性。
把随机化放进设计史看就更好理解了。早期 Go 的 map 遍历顺序是固定的(按桶序遍历),结果开发者开始依赖这个顺序写代码,换了 Go 版本后程序就坏了。Go 团队 1.0 之前干脆把随机化内置进 mapiterinit,用”每次都不同”强制开发者不要假设顺序。这是一个典型的以不变量换健壮性的设计:既然无法阻止开发者依赖顺序,就让顺序本身不成为可依赖的不变量。所以如果面试问”map 为什么无序”,标准答案有两层:机制上是 mapiterinit 用 fastrand 随机化起始桶和起始槽位,设计意图是防止开发者依赖固定顺序、避免跨版本不可移植。
八、并发检测
Go map 不是并发安全的。运行时在每次写操作(mapassign、mapdelete)中检测并发写入并抛出 fatal panic:
// src/runtime/map.go(mapassign / mapdelete 的并发检测模式)// 进入写操作时:if h.flags&hashWriting != 0 { throw("concurrent map writes") // fatal,不可 recover}h.flags ^= hashWriting // 设置写入标志
// ... 写入逻辑 ...
h.flags ^= hashWriting // 清除写入标志写操作通过 hashWriting 标志位做并发检测:进入时检查是否已有写入在进行,如果有就 throw(不可 recover 的 fatal),然后设置标志位;操作完成后用 XOR 清除。throw 是 runtime 发现状态损坏时的终止手段,不走 defer 链表,所以并发写 map 时你看到的 panic 无法被 recover 拦截。
需要注意的是,hashWriting 的检查和设置都是非原子操作,这个检测只能”尽量发现”并发冲突,不能保证捕获所有竞态。在极端时序下,两个 goroutine 可能同时通过检查并同时进入写入,最终导致 map 内部结构损坏。因此这个检测是调试辅助,不是并发安全保证。如果需要并发安全的 map,应该使用 sync.Map 或 map + sync.Mutex。
九、性能特征与最佳实践
预分配大小
// 不预分配:多次扩容m := make(map[string]int)for i := 0; i < 10000; i++ { m[key(i)] = i // 触发约 14 次扩容}// 预分配:一次分配m := make(map[string]int, 10000) // B = 14, 2^14 = 16384 > 10000key 类型的选择
| key 类型 | 哈希性能 | 对比性能 | 推荐场景 |
|---|---|---|---|
| int/uint | 极快 | 极快 | 首选 |
| string | 快(AES 哈希) | 快 | 常见 |
| float64 | 快 | 慢(NaN 问题) | 避免 |
| struct | 取决于字段 | 逐字段比较 | 小结构体 |
| interface | 慢(类型断言+哈希) | 慢 | 避免 |
常见陷阱
// 陷阱 1:不能取 map 元素的地址// m["key"] 是不可寻址的(因为扩容时地址会变)// _ = &m["key"] // 编译错误
// 陷阱 2:nil map 可以读但不能写var m map[string]int_ = m["key"] // 返回 0,不 panicm["key"] = 1 // panic: assignment to entry in nil map
// 陷阱 3:遍历时修改for k, v := range m { m[k] = v * 2 // 修改已有 key 是允许的 m["new"] = 1 // 添加新 key → 运行时 panic}十、Swiss Tables:Go 1.25 的默认 map 实现
Go 1.24 引入了基于 Swiss Tables 的全新 map 实现(实验性),1.25 起成为默认实现,替代了原有的基于溢出桶链表的哈希表。这是 Go map 自诞生以来最重大的底层变更。
为什么需要 Swiss Tables?
原有的溢出桶链表设计存在几个问题:
- 缓存不友好:溢出桶通过指针链接,遍历时缓存命中率低
- 内存开销:每个溢出桶都有独立的
bmap头部,小对象分配开销大 - GC 扫描成本:溢出桶链表增加了 GC 需要扫描的指针数量
Swiss Tables 通过**开放寻址(open addressing)和元数据字节(metadata bytes)**解决了这些问题。
Swiss Tables 设计
元数据字节(ctrl) 是 Swiss Tables 的关键创新:
// 每个槽的元数据字节编码了哈希的低 7 位(H2)const ( ctrlEmpty ctrl = 0b10000000 // 空槽(最高位为 1,与正常 H2 区分) ctrlDeleted ctrl = 0b11111110 // 已删除(墓碑标记,最高位为 1) // 正常值:hash(key) 低 7 位(H2),最高位为 0(范围 0x00-0x7F) // H2 匹配时 1/128 概率假阳性,需二次确认完整 key)与旧实现的 tophash(高 8 位)不同,Swiss Tables 使用低 7 位(H2),高 57 位(H1)用于定位 group。这种拆分的设计理由是:H2 作为 filter 快速排除不匹配的槽,H1 定位初始探测位置。
Group:8 个槽的探测单元
Swiss Tables 的核心操作单元是 group,每个 group 包含 8 个槽(abi.MapGroupSlots = 8):
type ctrlGroup uint64 // 8 个 ctrl 字节打包成 uint64
// matchH2:用位运算一次匹配 group 内所有 H2func (g ctrlGroup) matchH2(h uintptr) bitset { // 将 h 复制到 uint64 的每个字节 // XOR 后等于 0 的字节就是匹配的 // 再通过位掩码提取匹配位置 // 在 amd64 上被编译器内联替换为 SIMD 指令}group 的设计使得一次 matchH2 操作就能并行检查 8 个槽,等价于旧实现中遍历 tophash[8] 的 8 次比较。
查找过程
关键优化:Swiss Tables 使用 SIMD 指令(如 SSE2 的 _mm_cmpeq_epi8)一次比较 16 个元数据字节,大幅加速查找。
与旧实现的对比
| 特性 | 旧实现(溢出桶链表) | Swiss Tables |
|---|---|---|
| 冲突处理 | 溢出桶链表 | 开放寻址 + 二次探测 |
| 元数据 | tophash(高 8 位) | ctrl(低 7 位 H2 + 特殊标记) |
| 探测单元 | 桶(bmap,8 个槽) | 组(group,8 个槽) |
| 内存布局 | 键值对交错在 bmap 中 | ctrl/keys/values 在 group 内交错 |
| 缓存友好性 | 较差(溢出桶指针追踪) | 优秀(SIMD 匹配 + 连续内存) |
| 删除处理 | 标记为 emptyOne | 墓碑标记(ctrlDeleted) |
| 扩容方式 | 一次性 grow(新旧桶并存) | 可扩展哈希,按 table 渐进 grow |
| 最大负载 | 6.5/8 = 81.25% | 7/8 = 87.5% |
| GC 扫描 | 需要追踪溢出桶链表 | 连续数组,扫描更高效 |
| 源码位置 | runtime/map.go | internal/runtime/maps/ |
可扩展哈希:Swiss Tables 的渐进式扩容
旧实现的 grow 必须一次性分配新的桶数组,然后渐进搬迁。Swiss Tables 采用了不同的策略:可扩展哈希(extendible hashing)。
一个 Map 由多个 table 组成,每个 table 是一个完整的 Swiss Table(包含若干 group)。当某个 table 需要扩容时,只 grow 这一个 table,不影响其他 table:
// Map 由 directory(目录)和多个 table 组成// 哈希的前 N 位(globalDepth)用于在 directory 中选择 table// 每个 table 有自己的 localDepth
// 当一个 table 满了:// 1. 创建两个新 table,容量翻倍// 2. 将旧 table 的元素重新分配到新 table// 3. 更新 directory 中的指针// 其他 table 完全不受影响这种设计的优势在于:单次 grow 只涉及一个 table(最多 maxTableCapacity = 1024 个元素),而旧实现的 grow 涉及整个 map 的所有桶。对于大型 map,Swiss Tables 的 grow 延迟更低。
渐进式迁移
Go 的 Swiss Tables 实现通过构建标签实现架构级适配:
runtime/map.go→ 旧实现(保留用于不支持 Swiss Tables 的架构)runtime/map_noswiss.go→ 旧实现的辅助文件runtime/map_swiss.go→ Swiss Tables 的运行时胶水层internal/runtime/maps/→ Swiss Tables 核心实现
Go 1.24 引入 Swiss Tables,Go 1.25 在支持的架构上默认启用。可以通过 GOEXPERIMENT=noswiss 环境变量回退到旧实现。
十一、常见问题 FAQ
Q1:map 的负载因子为什么是 6.5?
6.5 是经过大量基准测试得出的经验值。每个桶 8 个槽,负载因子 6.5 意味着平均每个桶约 81% 满,在查找效率和内存利用率之间取得平衡。Java HashMap 的负载因子是 0.75,但它的桶结构不同(每个桶 1 个元素 + 链表/红黑树)。
Q2:为什么 map 遍历顺序是随机的?
机制上是 mapiterinit 用 fastrand() 随机化起始桶和桶内起始槽位,详见前文迭代器与随机化一节。设计意图是防止开发者依赖固定顺序:早期 Go 的遍历顺序是固定的,结果开发者依赖它在不同版本间写出不可移植的代码,Go 团队干脆把随机化内置进迭代器,让顺序本身不成为可依赖的不变量。Swiss Tables(Go 1.25)实现换了,这个随机化策略依然保留。
Q3:sync.Map 和 map+mutex 怎么选?
- 读多写少:
sync.Map更优(读操作走无锁路径) - 写多读少:
map + sync.RWMutex或map + sync.Mutex更优(sync.Map 的写操作开销大,需要维护 read/dirty 两个 map) - key 相对固定:
sync.Map更优(已缓存的 key 读取无锁) - key 动态变化:
map + Mutex更优(sync.Map 的 dirty 提升机制在 key 频繁变化时有额外开销)
Q4:map 扩容时性能会下降吗?
渐进式扩容将开销分摊到每次操作中,单次操作的性能下降很小(最多搬迁 2 个桶)。但在极端情况下(大量数据需要搬迁),整体吞吐量会暂时下降。
Q5:为什么不能对 map 元素取地址?
因为扩容时,所有键值对会被搬迁到新的内存位置,之前的地址会失效。如果允许取地址,就会产生悬垂指针。
小结
Go map 的设计围绕一个核心矛盾展开:查找效率与内存利用率的权衡。负载因子选 6.5 而不是更低的值,是因为每个桶 8 个槽在 81% 满时仍然有不错的查找性能,同时不会浪费太多内存。溢出桶链表是旧实现处理哈希冲突的方案,简单但缓存不友好;Swiss Tables(Go 1.24 引入,1.25 默认启用)用开放寻址加 ctrl 位匹配取代了溢出桶,查找时一次 matchH2 操作就能并行检查 group 内 8 个槽的 H2 值(amd64 上编译为 SIMD 指令),大幅提升了缓存命中率。Swiss Tables 还引入了可扩展哈希,将 map 拆分为多个 table,grow 时只影响单个 table(最多 1024 个元素),旧实现则需要一次性分配整个桶数组。渐进式扩容是一个容易被忽视的工程决策,它把一次性搬迁的开销分摊到每次 map 操作中,避免了扩容时的延迟尖峰。map 不支持并发写入是刻意的设计,并发安全的 map 需要在每次操作加锁,对单 goroutine 场景是纯粹的开销;如果需要并发安全,sync.Map 或 map + Mutex 是显式的选择。
参考资料
- Swiss Tables 设计 - Abseil 的 Swiss Tables 设计文档
- Go Swiss Tables 实现 - Go 1.25 Swiss Tables 源码
- Go Runtime Source: map.go - map 完整实现
- Go Blog: Go maps in action - 官方 map 使用指南
- Go 1.9: map 类型清晰化 - map 并发检测增强
- Keith Randall: Map Implementation in Go - GopherCon 演讲
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






