mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2282 字
6 分钟
Go map 底层实现:从 hmap 到桶的完整解析
2023-01-30

Go 的 map[key]value 用起来如此简单,一行代码就能完成增删改查。但在这简洁的语法背后,是一个精心设计的哈希表实现——它需要处理哈希冲突、动态扩容、迭代顺序随机化、并发检测等多个复杂问题。

本文带你深入 runtime/map.gointernal/runtime/maps/ 的源码,揭示 map 的每一个底层细节。Go 1.25 默认使用基于 Swiss Tables 的新实现,本文会同时介绍两种实现。

本文要点#

  • hmap:map 的运行时头部结构
  • bmap(桶):每个桶存储 8 个键值对
  • 哈希冲突处理:溢出桶链表
  • 扩容策略:等量扩容(整理)和增量扩容(翻倍)
  • 渐进式扩容:每次操作最多搬迁 2 个桶
  • 迭代器的实现与随机化
  • 并发检测:为什么写 map 会 panic
  • map 的性能特征与最佳实践

hmap:map 的运行时头部#

每个 Go map 在运行时都是一个 hmap 结构体,定义在 runtime/map.go

// 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 // 可选的溢出桶信息
}
graph TD subgraph "hmap 结构" COUNT["count: 3<br/>(当前元素数)"] B["B: 0<br/>(桶数 = 2^0 = 1)"] HASH0["hash0: 0x1a2b3c<br/>(哈希种子)"] BUCKETS["buckets → 桶数组"] OLDBUCKETS["oldbuckets: nil<br/>(未在扩容)"] end BUCKETS --> B0["桶 0<br/>bmap"] style B0 fill:#4CAF50,color:#fff

关键字段#

字段作用说明
countlen(m) 的返回值直接读取,O(1)
B桶数的对数桶数 = 2^B,初始 B=0(1个桶)
hash0哈希种子创建时随机生成,防止 HashDoS
buckets当前桶数组指向连续的 2^B 个桶
oldbuckets旧桶数组扩容期间非 nil,指向扩容前的桶

bmap:桶的结构#

每个桶(bmap)可以存储 8 个键值对

src/runtime/map.go
// 编译器会根据 key 和 value 的类型动态生成 bmap
type bmap struct {
tophash [8]uint8 // 每个槽的哈希高 8 位
// 后面跟着(编译器生成):
// keys [8]keyType // 8 个键
// values [8]valueType // 8 个值
// overflow *bmap // 溢出桶指针
}

为什么是 8 个?#

8 是精心选择的平衡值:

  • 太小:溢出桶太多,查找效率下降
  • 太大:桶太大,缓存局部性差
  • 8 个键值对 + tophash 刚好适合 CPU 缓存行(64 字节)

tophash 的作用#

tophash 存储每个键哈希值的高 8 位,用于快速过滤不匹配的槽位:

graph LR A["查找 key"] --> B["计算 hash(key)"] B --> C["top = hash 的高 8 位"] C --> D["遍历桶的 tophash[0..7]"] D --> E{"top 匹配?"} E --> |"是"| F["比较完整 key"] E --> |"否"| G["跳过(快速过滤)"] F --> H{"key 相等?"} H --> |"是"| I["找到!返回 value"] H --> |"否"| D style G fill:#FF9800,color:#fff style I fill:#4CAF50,color:#fff

tophash 的特殊值:

含义
0-5空槽的特殊标记
emptyRest (0)此槽及后续所有槽都空
emptyOne (1)此槽空,但后续可能有数据
evacuatedX (2)扩容时:数据已搬到新桶 X 侧
evacuatedY (3)扩容时:数据已搬到新桶 Y 侧
evacuatedEmpty (4)扩容时:此槽空,已标记搬迁
≥ 5正常的 tophash 值

哈希冲突处理:溢出桶#

当一个桶的 8 个槽都满了,新键值对会放入溢出桶(overflow bucket):

graph LR subgraph "桶 0" T0["tophash[0..7]<br/>keys[0..7]<br/>values[0..7]"] end subgraph "溢出桶 1" T1["tophash[0..7]<br/>keys[0..7]<br/>values[0..7]"] end subgraph "溢出桶 2" T2["tophash[0..7]<br/>keys[0..7]<br/>values[0..7]"] end T0 --> |"overflow"| T1 T1 --> |"overflow"| T2 style T0 fill:#4CAF50,color:#fff style T1 fill:#FF9800,color:#fff style T2 fill:#F44336,color:#fff

溢出桶的查找需要遍历链表,性能从 O(1) 退化为 O(k),其中 k 是溢出桶的数量。Go 通过扩容来控制溢出桶的数量。

扩容策略#

Go map 有两种扩容触发条件和两种扩容类型:

触发条件#

src/runtime/map.go
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.5count / 2^B > 6.5增量扩容增加桶数,减少溢出
溢出桶太多noverflow > 2^B等量扩容整理桶,消除空槽

增量扩容(翻倍)#

graph TD subgraph "扩容前(B=1, 2个桶)" OLD0["桶 0: 4个元素"] OLD1["桶 1: 4个元素 + 2个溢出桶"] end subgraph "扩容后(B=2, 4个桶)" NEW0["桶 0: 2个元素"] NEW1["桶 1: 2个元素"] NEW2["桶 2: 2个元素"] NEW3["桶 3: 2个元素"] end OLD0 --> NEW0 OLD0 --> NEW2 OLD1 --> NEW1 OLD1 --> NEW3 style NEW0 fill:#4CAF50,color:#fff style NEW1 fill:#4CAF50,color:#fff style NEW2 fill:#4CAF50,color:#fff style NEW3 fill:#4CAF50,color:#fff

等量扩容(整理)#

当溢出桶太多但负载因子不高时(大量删除后重新插入),等量扩容不增加桶数,只是重新排列数据,消除空槽和溢出桶:

扩容前(B=1, 2个桶,但有很多空槽和溢出桶):
桶 0: [A, _, C, _, E, _, G, _] → 溢出桶: [I]
桶 1: [B, _, D, _, F, _, H, _] → 溢出桶: [J]
等量扩容后(B=1, 2个桶,紧凑排列):
桶 0: [A, C, E, G, I, _, _, _]
桶 1: [B, D, F, H, J, _, _, _]

渐进式扩容#

Go map 的扩容是渐进式的——不是一次性搬迁所有数据,而是在每次 map 操作时搬迁少量桶。

src/runtime/map.go
func growWork(t *maptype, h *hmap, bucket uintptr) {
// 每次操作搬迁 1 个当前桶
evacuate(t, h, bucket&h.oldbucketmask())
// 再搬迁 1 个未搬迁的旧桶(推进扩容进度)
if h.growing() {
evacuate(t, h, h.nevacuate)
}
}
flowchart LR subgraph "渐进式扩容过程" S1["写入 map<br/>搬迁 1 个桶"] S2["读取 map<br/>搬迁 1 个桶"] S3["遍历 map<br/>搬迁 2 个桶"] S4["... 重复<br/>直到全部搬迁"] S5["扩容完成<br/>oldbuckets = nil"] end S1 --> S2 --> S3 --> S4 --> S5 style S5 fill:#4CAF50,color:#fff

扩容期间的查找#

扩容期间,查找需要同时检查新旧桶

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
}
}
// ... 在 b 中查找
}

迭代器与随机化#

Go map 的迭代顺序是故意随机化的,这是为了防止开发者依赖固定顺序:

src/runtime/map.go
func mapiterinit(t *maptype, h *hmap, it *hiter) {
// 随机选择起始桶
r := uintptr(fastrand())
it.startBucket = r & bucketMask(h.B)
it.offset = uint8(r >> h.B & (bucketCnt - 1))
}

这就是为什么每次 for k, v := range m 的顺序都不同。

并发检测#

Go map 不是并发安全的。运行时会检测并发写入并 panic:

src/runtime/map.go
func mapwrite(t *maptype, h *hmap) {
if h.flags&hashWriting != 0 {
throw("concurrent map writes") // fatal,不可 recover
}
h.flags |= hashWriting // 标记正在写入
// ... 写入逻辑
h.flags &^= hashWriting // 清除写入标记
}

注意:这个检测不是线程安全的(没有用原子操作),它只能”尽量检测”并发冲突,不能保证捕获所有情况。

性能特征与最佳实践#

预分配大小#

// 不预分配:多次扩容
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 > 10000

key 类型的选择#

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,不 panic
m["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.25 引入了基于 Swiss Tables 的全新 map 实现,替代了原有的基于溢出桶链表的哈希表。这是 Go map 自诞生以来最重大的底层变更。

为什么需要 Swiss Tables?#

原有的溢出桶链表设计存在几个问题:

  1. 缓存不友好:溢出桶通过指针链接,遍历时缓存命中率低
  2. 内存开销:每个溢出桶都有独立的 bmap 头部,小对象分配开销大
  3. GC 扫描成本:溢出桶链表增加了 GC 需要扫描的指针数量

Swiss Tables 通过**开放寻址(open addressing)元数据字节(metadata bytes)**解决了这些问题。

Swiss Tables 核心设计#

graph TD subgraph "Swiss Table 结构" CTRL["ctrl []uint8<br/>元数据数组<br/>每个槽 1 字节"] KEYS["keys []KeyType<br/>键数组(连续内存)"] VALS["values []ValueType<br/>值数组(连续内存)"] end CTRL --> |"匹配时"| KEYS KEYS --> |"对应"| VALS style CTRL fill:#FF9800,color:#fff style KEYS fill:#4CAF50,color:#fff style VALS fill:#2196F3,color:#fff

元数据字节(ctrl) 是 Swiss Tables 的关键创新:

src/internal/runtime/maps/table.go
// 每个槽的元数据字节编码了哈希的高 7 位
const (
emptyCtrl = 0b10000000 // 空槽
deletedCtrl = 0b11111110 // 已删除(墓碑标记)
// 其他值:hash(key) >> 57 的结果(高 7 位 + 最低位设为 1)
)

查找过程#

flowchart TD A["查找 key"] --> B["计算 hash(key)"] B --> C["h2 = hash 高 7 位"] C --> D["在 ctrl 数组中<br/>SIMD 并行匹配 h2"] D --> E{"找到匹配的 ctrl[i]?"} E --> |"是"| F["比较 keys[i] 与目标 key"] F --> G{"key 相等?"} G --> |"是"| H["找到!返回 values[i]"] G --> |"否"| D E --> |"遇到 empty"| I["未找到"] E --> |"遇到 deleted"| D style D fill:#FF9800,color:#fff style H fill:#4CAF50,color:#fff

关键优化:Swiss Tables 使用 SIMD 指令(如 SSE2 的 _mm_cmpeq_epi8)一次比较 16 个元数据字节,大幅加速查找。

与旧实现的对比#

特性旧实现(溢出桶链表)Swiss Tables
冲突处理溢出桶链表开放寻址 + 线性探测
元数据tophash(高 8 位)ctrl(高 7 位 + 特殊标记)
内存布局键值对交错在 bmap 中ctrl/keys/values 分离存储
缓存友好性较差(溢出桶指针追踪)优秀(SIMD 匹配 + 连续内存)
删除处理标记为 emptyOne墓碑标记(deletedCtrl)
GC 扫描需要追踪溢出桶链表连续数组,扫描更高效
源码位置runtime/map.gointernal/runtime/maps/

渐进式迁移#

Go 1.25 通过构建标签(build tag)实现了渐进式迁移:

  • runtime/map.go → 旧实现(保留用于不支持 Swiss Tables 的架构)
  • runtime/map_noswiss.go → 旧实现的辅助文件
  • runtime/map_swiss.go → Swiss Tables 的运行时胶水层
  • internal/runtime/maps/ → Swiss Tables 核心实现

在默认情况下,Go 1.25 在支持的架构上使用 Swiss Tables。可以通过 GOEXPERIMENT=noswiss 环境变量回退到旧实现。

源码参考#

Swiss Tables 的核心实现位于 internal/runtime/maps/,主要文件包括:

常见问题 FAQ#

Q1:map 的负载因子为什么是 6.5?#

6.5 是经过大量基准测试得出的经验值。每个桶 8 个槽,负载因子 6.5 意味着平均每个桶约 81% 满,在查找效率和内存利用率之间取得平衡。Java HashMap 的负载因子是 0.75,但它的桶结构不同(每个桶 1 个元素 + 链表/红黑树)。

Q2:为什么 map 遍历顺序是随机的?#

Go 团队认为:如果开发者依赖固定遍历顺序,会在不同 Go 版本间产生不可移植的代码。随机化强制开发者不依赖顺序,提高代码的健壮性。

Q3:sync.Map 和 map+mutex 怎么选?#

  • 读多写少sync.Map 更优(读操作无锁)
  • 写多读少map + sync.RWMutex 更优(sync.Map 的写操作开销大)
  • key 相对固定sync.Map 更优

Q4:map 扩容时性能会下降吗?#

渐进式扩容将开销分摊到每次操作中,单次操作的性能下降很小(最多搬迁 2 个桶)。但在极端情况下(大量数据需要搬迁),整体吞吐量会暂时下降。

Q5:为什么不能对 map 元素取地址?#

因为扩容时,所有键值对会被搬迁到新的内存位置,之前的地址会失效。如果允许取地址,就会产生悬垂指针。

小结#

  1. hmap 是 map 的运行时头部,包含桶数组指针、扩容状态和哈希种子
  2. bmap(桶) 存储 8 个键值对,tophash 用于快速过滤
  3. 溢出桶 处理哈希冲突,但过多溢出桶会触发扩容
  4. 两种扩容:增量扩容(翻倍,负载因子 > 6.5)和等量扩容(整理,溢出桶太多)
  5. 渐进式扩容:每次操作最多搬迁 2 个桶,避免一次性大量搬迁
  6. 并发不安全:运行时尽力检测并发写入,但不是线程安全的

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go map 底层实现:从 hmap 到桶的完整解析
https://blog.souloss.com/posts/golang/go-map/
作者
Souloss
发布于
2023-01-30
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时