| 名词 | 小白解释 |
|---|---|
hmap | 整张 map 的管理中心,记录元素数量、桶数组地址和扩容状态 |
bmap | 一个 bucket 的底层结构;每个主桶最多有 8 个槽位 |
tophash | 从 hash 高位提取的小摘要,用来快速排除不可能匹配的槽位 |
overflow bucket | 主桶装满后额外连接的溢出桶 |
oldbuckets | 扩容期间暂时保留的旧桶数组 |
nevacuate | 迁移进度,表示编号小于它的旧桶已经搬完 |
Go 经典 Map:hmap、bmap、bucket 与渐进扩容
“经典实现”指 Go 1.23 及之前内置 map 的底层方案。hmap 是管理整张 map 的头部结构,bmap 是一个 bucket(桶)的底层结构;桶可以理解成一次容纳 8 组 key/value 的小盒子。标题里的渐进扩容表示容量增长时不一次搬完全部数据,而是让后续操作每次帮忙搬一小部分。
这篇文章只服务旧版源码和旧面试题,按“整体结构 → 桶内布局 → 查询 → 冲突 → 两种扩容 → 版本对照”展开。
tophash 是桶内 hash 摘要,overflow bucket 是溢出桶,oldbuckets 是旧桶数组,nevacuate 是迁移进度;出现这些词时,才使用本文这套回答。一、先认识整套旧版术语
二、hmap:整张旧 map 的管理中心
hmap 是整张经典 map 的“管理表”。它不直接把所有 key/value 塞进自己,而是记录元素数量、随机种子、当前桶数组,以及扩容搬到哪了。
type hmap struct { count int // 当前元素数量,len(m) 读取它 B uint8 // 主 bucket 数量 = 2^B noverflow uint16 // overflow bucket 的近似数量 hash0 uint32 // 每张 map 自己的随机 hash 种子 buckets unsafe.Pointer // 当前主桶数组 oldbuckets unsafe.Pointer // 扩容期间的旧桶数组 nevacuate uintptr // 旧桶迁移进度 }
uint8/uint16/uint32 是不同位数的无符号整数类型;unsafe.Pointer 是可以保存内存地址的通用指针类型。这里理解字段职责即可,不需要死背声明。
B 是指数:当 B=3 时,主桶数量是 2³=8;当 B 增加 1,主桶数量就翻倍。hash0 是随机种子,可以理解成每张 map 各自的一份“调味料”,让同一个 key 在不同 map 中不必得到相同的 hash。
count 管长度、B 管桶数量、buckets/oldbuckets 管新旧桶、nevacuate 管迁移进度。三、bmap:一个 bucket 里面怎样放 8 组数据
bmap 是一个具体的桶。可以把它想成一个有 8 列的收纳盒:同一列的 tophash[i]、key[i] 和 value[i] 共同描述一条数据。
tophash 只是快速初筛,不是 key 的唯一身份。两个不同 key 可能拥有相同摘要,最终必须比较完整 key。四、查询:低 B 位选桶,高位摘要筛槽
现在用一个具体例子查 m["cat"]。假设 B=3,主桶数量是 2³=8。hash 的低 3 位是 101,换成十进制就是 5,所以先去 buckets[5]。
完整查询过程:让 "cat" 从入口走到结果
v := m[key] 时,未命中得到零值;需要区分“零值”和“不存在”时要用 v, ok := m[key]。五、冲突:bucket 满了为什么需要 overflow
哈希冲突是不同 key 最终需要在同一个主 bucket 中查找。一个 bmap 只有 8 个槽;8 个都占用后,新元素不会覆盖旧元素,而是挂接一个 overflow bucket(溢出桶)继续存放。
overflow 越多,查询越可能跟着指针跳到更多内存位置。CPU cache 是处理器内部速度很快的一小块临时存储,它更喜欢相邻数据;长链会破坏这种内存局部性。
六、扩容:容量翻倍与等量扩容是两回事
负载因子表示平均每个主 bucket 放了多少元素。旧版插入数据时会观察两个信号:元素相对主桶是否太多,以及 overflow bucket 是否太多。
容量翻倍扩容
元素确实太多时,B 加 1,主桶数量从 2^B 变成 2^(B+1)。旧桶数据根据新增的那一位 hash 分到两个新位置。
等量扩容
元素未必很多,但 overflow 太多时,B 不变、主桶数量不变。runtime 建立同样大小的新桶数组,把有效数据重新摆紧凑。
i 翻倍后只可能去两个地方:i 或 i + 2^oldB。这就是经典面试题里的 X/Y 分流。为什么不一次搬完:把一次大停顿拆成很多小搬运
如果 map 很大,一次搬完所有 bucket 会让某一次写操作突然停顿很久。因此旧版采用渐进迁移:新写操作发生时,顺手搬与本次访问相关的旧桶,并继续推进迁移进度。
七、删除:槽位清空不等于立刻缩容
delete(m, key) 找到目标槽位后,会清理 key/value,并更新 tophash 中的空槽状态。删除不存在的 key 仍然安全。
删除大量元素不代表主桶数组立刻缩小。旧版可能通过等量扩容整理过多的 overflow,但如果业务明确需要释放大块 map 内存,常见做法仍是新建 map,把保留数据复制过去,再丢弃旧 map。
八、经典 bucket 和当前 Group 怎样区分
| 对比 | Go 1.23 及之前 | Go 1.24+ |
|---|---|---|
| 顶层 | hmap | Map + Directory |
| 基本分组 | bmap bucket,8 槽 | Group,8 槽 |
| 初筛信息 | tophash[8] | 8 字节 control word,保存 H2 |
| 冲突处理 | overflow bucket | 开放寻址 + group 级二次探测 |
| 增长方式 | 整组桶渐进迁移 | Table 独立 grow 或 split |
九、经典实现常见面试追问
十、经典实现 90 秒总结话术
如果讨论的是 Go 1.23 及之前: map 顶层由 hmap 管理。 hmap 记录元素数量 count、桶指数 B、当前 buckets、 扩容期间的 oldbuckets 和迁移进度 nevacuate。 主桶数量是 2^B,每个 bmap bucket 有 8 个槽位, 布局是 tophash[8]、keys[8]、values[8],末尾可连接 overflow bucket。 查询时先计算 hash: 低 B 位选择主 bucket,高位摘要形成 tophash; tophash 匹配后仍要比较完整 key,主桶没找到再沿 overflow 查找。 扩容有两种: 负载过高时 B 加 1、容量翻倍; overflow 过多时 B 不变,做等量扩容整理数据。 两种情况都保留 oldbuckets,并通过后续写操作渐进迁移。
附录、官方源码依据
本文依据 Go 1.23.12 的历史 runtime 源码整理。runtime 是 Go 程序运行时负责底层管理的部分;源码中的 same-size grow 对应等量扩容,growWork/evacuate 对应分批执行和迁移旧桶。