Files
cs-note/hzh/GolangStar/Go语言原理/map原理.md
T

7.4 KiB

tags, create time
tags create time
go
golang
go-principle
map
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 个键值对:

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

hmap 核心字段

// 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 结构

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 值分为两部分使用:

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 读取流程

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 写入流程

写入比读取多了一步:判断是否需要扩容。

// 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 设计中最精妙的部分——扩容不是一次性完成的,而是在每次写入时逐步迁移。

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

关键点:

  • hashGrow 只负责分配新桶并挂到 oldbuckets 上
  • growWork 在每次 mapassign / mapdelete 时被调用,迁移当前桶 + 额外一个桶
  • nevacuate 记录迁移进度,小于该值的桶已完成迁移
  • 双倍扩容时,旧桶 i 的数据可能分散到新桶 i 或 i + 2^(B-1)
  • 等量扩容时,数据位置不变

[!tip] 💡 理解要点 "写时迁移"让扩容的成本分摊到每一次写入操作上,避免了一次性大量拷贝导致的 STW(Stop-The-World)。

五、删除操作:不会释放内存

delete(m, key)  // 标记为 emptyRest,但内存不释放

删除会将对应槽位的 tophash 设为 emptyRest,并向后推进这个状态(以便查找时提前终止)。但注意:被删除的 key/value 占用的内存永远不会释放,这是 map 频繁增删会导致内存泄漏的原因。

六、遍历:为什么顺序是随机的?

for k, v := range m {
    // 每次遍历的顺序可能不同!
}

原因有两个:

  1. 随机起始点:遍历时随机选择一个桶和槽位作为起点
  2. 扩容导致位置变化:如果遍历过程中触发了扩容,key 的位置会发生变化

这其实是 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 位定位桶
  • 扩容采用渐进式写时迁移,成本分摊到每次写入
  • 删除不释放内存,频繁增删需注意内存增长
  • 遍历顺序随机化是故意设计,不应依赖

关联笔记