---
tags: [go, golang, go-principle, map]
create time: 2026-06-07 15:15
---
# Map 底层原理
## 概述
本文深入 Go runtime 源码,解析 `map` 的哈希表实现、桶(bucket)结构、渐进式扩容机制,以及遍历随机化的设计考量。Map 是 Go 中使用最频繁的数据结构之一,理解其底层原理对排查并发问题和优化性能至关重要。
> [!question] ❓ 思考
> 为什么同一个 map 变量多次遍历的顺序可能不同?Go 为什么要用"写时迁移"而不是在创建时就一次性建好所有桶?负载因子为什么是 6.5 而不是其他值?
## 正文
### 一、Map 的整体架构:hmap + bmap
Go 的 map 是一个指向 `hmap` 结构体的指针。真正存储数据的是 `buckets` 数组,每个桶(`bmap`)可容纳 8 个键值对:
```mermaid
graph TB
hmap["hmap
count/flags/B/buckets/oldbuckets..."] --> buckets["buckets 数组
[b0][b1][b2]..."]
buckets --> b0["b0: topbits+keys+vals
(8 kv pairs)"]
buckets --> b1["b1: topbits+keys+vals
(8 kv pairs)"]
buckets --> bo["overflow bucket
额外桶"]
hmap --> extra["extra: overflow 链表管理"]
style hmap fill:#e3f2fd
style buckets fill:#fff9c4
```
#### hmap 核心字段
```go
// src/runtime/map.go
type hmap struct {
count int // 元素个数 (len(map))
flags uint8 // 状态标志
B uint8 // log2(桶数),桶数 = 2^B
noverflow uint16 // 溢出桶数量近似值
hash0 uint32 // 哈希种子
buckets unsafe.Pointer // 当前桶数组
oldbuckets unsafe.Pointer // 旧桶数组(扩容期间使用)
nevacuate uintptr // 扩容进度计数器
extra *mapextra // 溢出桶管理
}
```
#### bmap 结构
```go
type bmap struct {
topbits [8]uint8 // 8 个 key 的 hash 高 8 位(快速比较用)
keys [8]keytype // 8 个 key(连续存储)
values [8]valuetype // 8 个 value(连续存储)
overflow *bmap // 指向溢出桶
}
```
> [!note] 📝 源码要点
> key 和 value 不是交错存储的(不是 k1,v1,k2,v2...),而是先存完 8 个 key 再存 8 个 value。这样可以消除字节对齐带来的空间浪费。
### 二、Hash 值的分段使用
Go 将计算出的 hash 值分为两部分使用:
```mermaid
flowchart LR
H["hash = 0xABCD1234"] --> high["高8位: 0xAB → topbits[0]"]
H --> low["低8位: 0x34 → 定位槽位"]
style H fill:#e1f5fe
style high fill:#fff9c4
style low fill:#fff9c4
```
查找流程:
1. 用 hash 的低 N 位找到目标桶
2. 用 hash 的高 8 位与桶内 `topbits` 做**快速比较**
3. 只有 topbits 匹配时才做完整的 key 比较
这种设计避免了每次都要比较完整 key 的开销。
### 三、读写操作流程
#### 3.1 读取流程
```mermaid
flowchart TD
Start["读 map[key]"] --> Empty{"map 为空?"}
Empty -->|是| Zero["返回零值"]
Empty -->|否| Writing{"正在写?"}
Writing -->|是| Panic["fatal error: concurrent map read and map write"]
Writing -->|否| Hashing["计算 hash"]
Hashing --> Growing{"正在扩容?"}
Growing -->|是| CheckBucket{桶已迁移?}
CheckBucket -->|是| NewBucket["在新桶中查找"]
CheckBucket -->|否| OldBucket["在旧桶中查找"]
Growing -->|否| NormalBucket["在当前桶中查找"]
NewBucket --> TopMatch["比较 topbits"]
OldBucket --> TopMatch
NormalBucket --> TopMatch
TopMatch --> KeyMatch{"key 相等?"}
KeyMatch -->|是| Found["返回 value"]
KeyMatch -->|否| NextSlot{"后继空状态?"}
NextSlot -->|是| NotFound["返回零值,key 不存在"]
NextSlot -->|否| NextTop{"topbits 匹配?"}
NextTop -->|是| KeyMatch
NextTop -->|否| Overflow{"溢出桶?"}
Overflow -->|有| OldBucket
Overflow -->|无| NotFound
style Panic fill:#ffebee
style Found fill:#e8f5e9
style NotFound fill:#fff3e0
```
#### 3.2 写入流程
写入比读取多了一步:**判断是否需要扩容**。
```go
// src/runtime/map.go (简化)
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h) // 触发扩容
goto again
}
// ... 正常插入逻辑
}
```
触发扩容的两个条件:
- **负载因子 > 6.5**:每个桶平均 6.5 个元素(接近满的 8 个)
- **溢出桶过多**:说明数据结构已经严重倾斜
### 四、渐进式扩容:写时迁移
这是 Go map 设计中最精妙的部分——**扩容不是一次性完成的,而是在每次写入时逐步迁移**。
```mermaid
flowchart TD
GH["hashGrow: 分配新桶数组
大小为旧的 2 倍"] --> GW["growWork: 迁移一个旧桶"]
GW --> EVAC["evacuate: 逐个槽位迁移"]
EVAC --> DEST{"双倍扩容?"}
DEST -->|是| TwoDest["数据可能去两个目标桶
原位置 / 原位置+偏移量"]
DEST -->|否| OneDest["数据留在原位置"]
TwoDest --> Done{"所有旧桶迁移完?"}
OneDest --> Done
Done -->|否| GW
Done -->|是| Free["释放旧桶数组"]
style GH fill:#e3f2fd
style Free fill:#e8f5e9
```
关键点:
- `hashGrow` 只负责分配新桶并挂到 `oldbuckets` 上
- `growWork` 在每次 `mapassign` / `mapdelete` 时被调用,迁移当前桶 + 额外一个桶
- `nevacuate` 记录迁移进度,小于该值的桶已完成迁移
- 双倍扩容时,旧桶 i 的数据可能分散到新桶 i 或 i + 2^(B-1)
- 等量扩容时,数据位置不变
> [!tip] 💡 理解要点
> "写时迁移"让扩容的成本分摊到每一次写入操作上,避免了一次性大量拷贝导致的 STW(Stop-The-World)。
### 五、删除操作:不会释放内存
```go
delete(m, key) // 标记为 emptyRest,但内存不释放
```
删除会将对应槽位的 `tophash` 设为 `emptyRest`,并向后推进这个状态(以便查找时提前终止)。但注意:**被删除的 key/value 占用的内存永远不会释放**,这是 map 频繁增删会导致内存泄漏的原因。
### 六、遍历:为什么顺序是随机的?
```go
for k, v := range m {
// 每次遍历的顺序可能不同!
}
```
原因有两个:
1. **随机起始点**:遍历时随机选择一个桶和槽位作为起点
2. **扩容导致位置变化**:如果遍历过程中触发了扩容,key 的位置会发生变化
这其实是 Go 有意为之的设计——防止程序员依赖遍历顺序,因为那本质上是不稳定的。
```go
// src/runtime/map.go
r := uintptr(fastrand())
it.startBucket = r & bucketMask(h.B)
it.offset = uint8(r >> h.B & (bucketCnt - 1))
```
### 七、线程安全性
> [!warning] ⚠️ 重要
> Go 原生 map **不是线程安全的**。并发读写会触发 `fatal error: concurrent map read and map write`。如果需要并发安全,请使用 `sync.Map` 或手动加锁。
## 小结
- Map 基于哈希表实现,每个桶存 8 个键值对
- 使用 hash 高 8 位做快速比较,低 N 位定位桶
- 扩容采用渐进式写时迁移,成本分摊到每次写入
- 删除不释放内存,频繁增删需注意内存增长
- 遍历顺序随机化是故意设计,不应依赖
## 关联笔记
- [[hzh/GolangStar/Go语言基础/Go语言Map]] — Map 的基础用法
- [[hzh/GolangStar/Go语言原理/sync.map原理]] — 并发安全的 sync.Map
- [[hzh/GolangStar/Go面试题库/Map面试题]] — Map 相关高频面试题