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