Interview Lab / Go / Map / Classic

Go 经典 Map:hmap、bmap、bucket 与渐进扩容

“经典实现”指 Go 1.23 及之前内置 map 的底层方案。hmap 是管理整张 map 的头部结构,bmap 是一个 bucket(桶)的底层结构;桶可以理解成一次容纳 8 组 key/value 的小盒子。标题里的渐进扩容表示容量增长时不一次搬完全部数据,而是让后续操作每次帮忙搬一小部分。

这篇文章只服务旧版源码和旧面试题。先从一次普通的 m["Ada"] 查询出发,看清旧实现要解决的定位、冲突和长停顿问题,再逐个引入 hmap、bmap 与扩容字段。

Go 1.23 及之前hmap / bmap桶内 hash 摘要满桶后继续连接分批搬迁
不要当成当前实现:Go 1.24+ 默认已经改用 Swiss Table。学习当前 map 请回到Swiss Table 主线文章。旧题中的 tophash 是桶内 hash 摘要,overflow bucket 是溢出桶,oldbuckets 是旧桶数组,nevacuate 是迁移进度;出现这些词时,才使用本文这套回答。
一、为什么旧 Map 需要“桶”:先从一次查询推导

假设 users 是“用户编号到用户名”的索引,业务只写 users[42],希望立即找到编号 42 的用户。如果底层把所有用户排成普通列表,每次查询都可能从头比较到尾;用户越多,耗时越长。

users := map[int]string{
    7:  "Ada",
    42: "Lin",
}

userID := 42          // 本次要找的用户编号
name := users[userID] // 业务只关心拿到 "Lin"

没有分组

所有 key 堆在一起,只能不断比较;查询成本会随数据量增长。

先分桶

先用 hash 把 key 定位到一个小桶,再只检查桶内少量槽位。

hash(哈希)是把 key 计算成整数编号的过程。旧版 map 先用这个编号选择 bucket(桶);一个桶像有 8 个格子的收纳盒,只负责一小部分候选 key。

它解决的问题:先分桶避免每次扫描整张 map。节省的成本:平均只检查一个桶及少量溢出桶。带来的代价:不同 key 可能落到同一桶,数据变多后还要处理冲突与扩容。

有了这张需求图,再认识源码名词

名词小白解释
runtime(运行时)Go 程序运行时负责内存、调度和内置 map 等底层工作的部分;本文源码结构都由它实现
hmap整张 map 的管理中心,记录元素数量、桶数组地址和扩容状态
bmap一个 bucket 的底层结构;每个主桶最多有 8 个槽位
tophash从 hash 高位提取的小摘要,用来快速排除不可能匹配的槽位
overflow bucket主桶装满后额外连接的溢出桶
oldbuckets扩容期间暂时保留的旧桶数组
nevacuate迁移进度,表示编号小于它的旧桶已经搬完
hmap管理整体
buckets主桶数组
bmap每桶 8 槽
tophash桶内初筛
overflow满后追加
先形成画面:hmap 像仓库管理员,buckets 是一排货架,每个 bmap 是有 8 个格子的货箱;货箱满了,再接 overflow 货箱。
二、hmap 与 B:从 1、2、4、8 个主桶讲懂

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 是可以保存内存地址的通用指针类型。这里理解字段职责即可,不需要死背声明。

hmap 管理 bucket 数组的整体结构 左侧 hmap 保存 count、B、hash0、buckets、oldbuckets 和 nevacuate,右侧展示当前桶数组以及扩容时暂存的旧桶数组。 hmap 自己不装货,它负责管理“货架” hmap 管理表 count23 个元素 B3 → 2³=8 桶 hash0随机种子 buckets当前桶数组 oldbuckets扩容时才有 nevacuate搬迁进度 buckets → 当前 8 个主桶 bucket 08 个槽 bucket 18 个槽 bucket 2正在访问 … bucket 7共 2ᴮ 个 每个 bucket 本质上是一个 bmap 最多 8 组 key/value;装满后可连接 overflow bucket 真正的数据主要放在这里,不在 hmap 字段里 扩容期间才同时存在 oldbuckets 旧桶 0 ✓已迁移 旧桶 1 ✓已迁移 旧桶 2下一个 旧桶 3…待迁移 迁移完成后,oldbuckets 会被清空
先记住层级:hmap → buckets 数组 → bmap 桶 → 8 个槽位 → overflow 桶。后面所有查询与扩容都在这张图上发生。

先别背“以 2 为底的对数”:把 B 当成翻倍次数

假设仓库最开始只有 1 个主桶。每翻倍一次,主桶数量就按 1 → 2 → 4 → 8 → 16 变化。B 记录的就是走到这一步用了多少次翻倍:B=0 对应 1 个主桶,B=3 对应 8 个主桶。

B 的值怎样得到主桶数量主桶编号定位需要看 hash 低几位
02⁰ = 1只有 00 位,不用选择
12¹ = 20~11 位:0、1
22² = 2 × 2 = 40~32 位:00~11
32³ = 2 × 2 × 2 = 80~73 位:000~111
42⁴ = 2 × 2 × 2 × 2 = 160~154 位:0000~1111
“几次方”到底在数什么:2³ 表示有 3 个 2 相乘,即 2 × 2 × 2。为了避免数错,不说“2 自乘了 3 次”;直接数乘法里有几个 2 最稳妥。
B 等于 3 时,八个主桶与三个二进制位的对应关系 B 从 0 增加到 3,主桶数量从 1 连续翻倍到 8。三个只能取 0 或 1 的二进制位一共组成八种编号,从 000 到 111,分别对应主桶 0 到 7。 B = 3:既表示 2³ = 8 个主桶,也表示定位时看低 3 位 B = 01 个桶 B = 12 个桶 B = 24 个桶 B = 38 个桶 B 每加 1,只做一件事:把主桶数量翻一倍 000bucket 0 001bucket 1 010bucket 2 011bucket 3 100bucket 4 101buckets[5] 110bucket 6 111bucket 7 例如 hash 低 3 位是 101,就选中 buckets[5]3 个二进制位各有 0 / 1 两种可能:2 × 2 × 2 = 8 种编号
最应记住:B 同时连接了“桶有多少个”和“定位要看多少位”。B=3 时有 8 个主桶,编号 0~7 恰好可由 3 个二进制位表示。

“以 2 为底的对数”只是把问题反过来问

对数在这里不是新的计算魔法,只是在问“2 的多少次方等于这个数量”。已知 B=3,正着算 2³=8;已知有 8 个主桶,反过来找指数,答案是 log₂(8)=3。log₂ 读作“以 2 为底的对数”,下标 2 表示我们一直拿 2 的幂来比较。

正着问:知道 B

B=3 → 2³=8
答案是实际主桶数量 8。

反着问:知道数量

8=2³ → log₂(8)=3
答案是指数 B,也就是 3。

runtime 保存 B 很顺手,因为主桶数量总是 2 的整数次幂,而且选桶正好要取 hash 的低 B 位。源码会构造一个低 B 位全为 1 的掩码,再做位运算;你现在只需记住它的结果等价于 hash % 2^B,不需要先学会位运算。

一句话记忆:B 是指数,2^B 才是主 bucket 数量;B 每加 1,主桶数量翻倍;log₂(主桶数量) 只是反过来求 B。

hash0 是随机种子,可以理解成每张 map 各自的一份“调味料”,让同一个 key 在不同 map 中不必得到相同的 hash。

不要死背所有字段。面试重点是:count 管长度、B 管桶数量、buckets/oldbuckets 管新旧桶、nevacuate 管迁移进度。
三、bmap:一个 bucket 里面怎样放 8 组数据

bmap 是一个具体的桶。可以把它想成一个有 8 列的收纳盒:同一列的 tophash[i]、key[i] 和 value[i] 共同描述一条数据。

bmap 的八个槽位和连续内存布局 一个 bmap 先连续保存 8 个 tophash,再连续保存 8 个 key 和 8 个 value,最后保存 overflow 指针;第 3 列被高亮,表示同一下标组成一条记录。 一个 bmap:8 列槽位,同列才是一组 key/value 槽位编号 01234567 tophash[8] keys[8] values[8] 0x310x8A0x420xA70x190xE20x76空 "ant""dog""fox""cat""bee""owl""yak"— 128599367— overflow 指针满了再指向下一个 bmap 第 3 列摘要 + key + value共同组成一条记录 先放 8 个 key,再放 8 个 value,可减少某些类型组合产生的内存对齐空隙。
查询时先横向扫第一行的 tophash;只有摘要相同,才向下比较同一列的完整 key,最后取同列 value。
不要误解:tophash 只是快速初筛,不是 key 的唯一身份。两个不同 key 可能拥有相同摘要,最终必须比较完整 key。
四、查询:低 B 位选桶,高位摘要筛槽

先回答根问题:hash("cat", hash0) 在真实机器里到底是什么?

经典 Go Map 查询并不是先得到一串神秘的“高位/低位符号”,而是先得到一个真正的整数。Go 1.23 的 runtime 查询源码可以概括成 hash := t.Hasher(key, uintptr(h.hash0)):key 和这张 map 的随机种子 hash0 交给对应 key 类型的哈希函数,返回值是一个 uintptr 整数。

uintptr 的宽度跟机器指针宽度一致。在常见的 amd64、arm64 服务器和 Apple Silicon Mac 上,它是 64 位;在 32 位机器上则是 32 位。所以下面不再用“简化成 16 位”的玩具模型,而是直接按常见 64 位机器来观察。

先区分“真实类型”和“教学数值”:uintptr 是真实 runtime 类型;下面的 0xA73C91F052D86E05 是为了把位的位置画清楚而构造的一次教学示例值,不是字符串 "cat" 永远固定的 hash。真实运行时,同一个 "cat" 在不同 map 中可能因为 hash0 不同而得到不同 hash。
// 假设某次 64 位机器上的查询得到:
hash = 0xA73C91F052D86E05

// 十六进制按 8 个字节看:
A7 3C 91 F0 52 D8 6E 05

// 展开成 64 个二进制位:
10100111 00111100 10010001 11110000 01010010 11011000 01101110 00000101
^^^^^^^^                                                             ^^^
bit 63~56                                                           bit 2~0
高 8 位                                                              低 3 位
64 位机器上从 uintptr hash 中取得高 8 位和低 3 位 教学示例 hash 为 0xA73C91F052D86E05。最高一个字节 A7 对应 bit 63 到 56,通过右移 56 位取得并生成 tophash;最低一个字节 05 的最后三位是 101,对应 bit 2 到 0,通过与 111 做按位与得到十进制 5,因此选择 buckets 5。 先有一个 64 位整数,再从同一个整数两端取位 教学示例:hash = 0xA73C91F052D86E05(真实的 cat hash 不是固定值) A7 3C 91 F0 52 D8 6E 05 最高 1 字节最低 1 字节 高 8 位 = bit 63~56 A7 = 1010 0111 = 0xA7 64 位机器:hash >> 56 → 取得最高字节 → 生成 tophash B=3 → 低 3 位 = bit 2~0 05 = 0000 0101 → 最后 3 位是 101 hash & 0b111 = 0b101 = 5 → bucket index = 5 → buckets[5] “高”和“低”不是另外两份 hash,而是同一个整数的左端和右端 32 位机器思想不变:高 8 位变成 bit 31~24;低 B 位仍是 bit B-1~0
从真实机器角度记:hash 是一个 uintptr 整数。64 位机器的高 8 位就是 bit 63~56;B=3 时低 3 位就是 bit 2~0。源码通过右移取最高字节,通过掩码取最低 B 位。

CPU 实际怎样“取”这两部分

在 64 位机器上,要取得最高 8 位,可以把 64 位整数整体右移 64-8=56 位:uint8(hash >> 56)。经典 runtime 的 tophash 函数本质就是这样做;如果取出的原始值小于 minTopHash=5,还会再加 5,用来避开 0~4 这些内部状态值。本例最高字节是 0xA7,本身大于 5,所以无需调整。

要取得最低 B 位,runtime 构造掩码 (1<<B)-1。当 B=3 时,掩码就是二进制 111;执行 hash & 0b111 后,只保留 bit 2~0。示例 hash 最后一个字节是 05 = 0000 0101,最后 3 位恰好是 101,也就是十进制 5。

对应到源码就是两句:bucketIndex = hash & ((1<<B)-1) 负责决定“去哪一个主桶”;top = uint8(hash >> (机器字长-8)) 负责取得最高字节作为桶内摘要。两者使用的是同一个完整 hash。

现在用一个具体例子查 m["cat"]。假设 B=3,这张 map 的 buckets 是一排 2³=8 个主桶:buckets[0]、buckets[1],一直到 buckets[7]。方括号里的数字叫数组下标,它是这排桶的位置编号,并且从 0 开始。

继续使用上面的 64 位教学 hash 0xA73C91F052D86E05。因为当前 B=3,runtime 只保留这个整数的最低 3 个 bit,也就是 bit 2~0。最后一个字节 05 展开为 0000 0101,最低 3 位就是 101。这三个位置从右向左分别代表 1、2、4,所以 101₂ = 1×4 + 0×2 + 1×1 = 5,最终选择 buckets[5]。这个 5 是数组下标;由于下标从 0 开始,它是一排 8 个主桶中的第 6 个位置。

先把这一跳说完整:B=3 → 一共有 8 个主桶 → 低 3 位 101 等于十进制 5 → 在 0~7 号桶中选中 buckets[5]。进入这个主桶以后,才轮到 tophash 筛选桶内的 8 个槽位。
一个 hash 同时提供 bucket 下标和 tophash hash 高 8 位 0xA7 生成 tophash;低 B 位 101 换算成十进制 5,作为 buckets 数组下标;中间位保留在完整 hash 中。 同一个 hash,被切成两种“路标” hash("cat", hash0) 高 8 位:0xA7生成 tophash 中间很多位……仍属于完整 hash 低 B 位:101换算成下标 5 桶内先看摘要 进入 buckets[5]0 起算的第 6 个主桶 低位回答“去哪个桶”,高位摘要回答“桶里哪些槽值得比较”
最应记住:同一个完整 hash 被拆成两条线索,低 B 位选主桶,高位摘要只负责桶内初筛。

完整查询过程:让 "cat" 从入口走到结果

经典 Go map 的七步完整查询流程,包含命中与未命中条件分支 key cat 和随机种子计算出一个 uintptr hash。以 64 位教学值 0xA73C91F052D86E05 为例,bit 63 到 56 是最高 8 位 0xA7,用于生成 tophash;B 等于 3 时,bit 2 到 0 是最低 3 位 101,换算成十进制 5,因此选中 buckets[5]。进入桶后,tophash 0xA7 在槽位 1 和 3 留下候选;槽位 1 的完整 key 是 dog,被排除,槽位 3 的完整 key 是 cat,命中 value 99。若所有候选都被排除,则检查 overflow 指针:非空就进入下一个 bmap,空就返回零值和 ok 等于 false。 查询 m["cat"]:先缩小范围,再确认完整 key 1 key "cat" + hash0交给该 key 类型的 hash 函数 2 得到 uintptr hash:0xA73C91F052D86E0564 位教学值:真实 cat hash 会随 hash0 等因素变化 3 bit 63~56 = 0xA7 → tophash 同一个 hash 拆成两条线索 bit 2~0 = 101 → 十进制 5hash & 0b111 = 101₂ = 5 高位线索到桶内使用 4 map 的 buckets 数组:B=3,所以共有 8 个主桶 buckets[0]下标 0 buckets[1]下标 1 buckets[2]下标 2 buckets[3]下标 3 buckets[4]下标 4 buckets[5]101 → 5 ✓ buckets[6]下标 6 buckets[7]下标 7 选中编号 5它是第 6 个位置 进入 buckets[5]:下标相同的一列才是一组摘要、key 和 value 槽位下标 01234567 tophash[8] 0x310xA70x420xA70x190xE20x76空 keys[8] "ant""dog""fox""cat""bee""owl""yak"— values[8] 128599367— overflow 指针:本例为 nil(空),没有下一节溢出桶 扫描 8 个摘要只留下下标 1、3再沿同列比较 key 5 摘要相同不等于命中:逐列比较完整 key 候选槽 1:key = "dog""dog" ≠ "cat" → 排除 候选槽 3:key = "cat"完整 key 相等 → 取同列 value=99 返回 99ok=true 若所有候选都不相等(本例不走) 6 检查 overflow 指针它决定“继续找”还是“确认不存在” 指针非 nil 进入下一个 8 槽 bmap重复“扫摘要 → 比完整 key”;多一次内存跳转 指针为 nil 7主桶和全部 overflow 都未命中返回 value 零值;v, ok := m[key] 时 ok=false
最应记住:查询先从 8 个主桶选中 buckets[5],再由摘要 0xA7 留下下标 1 和 3,最后比较完整 key;最常见误读是把“摘要相同”当成命中,或忽略 overflow 是否为 nil 的条件分支。
常见错误:低 B 位只负责选择 bucket;tophash 只负责桶内初筛。真正命中必须比较完整 key。直接写 v := m[key] 时,未命中得到零值;需要区分“零值”和“不存在”时要用 v, ok := m[key]。
五、冲突:bucket 满了为什么需要 overflow

哈希冲突是不同 key 最终需要在同一个主 bucket 中查找。一个 bmap 只有 8 个槽;8 个都占用后,新元素不会覆盖旧元素,而是挂接一个 overflow bucket(溢出桶)继续存放。

独立的拥挤场景:假设整张 map 原有 8 个元素,它们都在 buckets[5],"cat" 是第 9 个

主桶装满后从插入到溢出桶查询命中的完整过程 这是一个与上一节分开的拥挤场景。假设整张 map 原有的八个 key 的 hash 低三位都是 101,它们依次填满 buckets[5] 的下标 0 到 7;第九个 key cat 到来时,整张 map 的负载尚未达到翻倍条件,因此 cat 被放进第一个 overflow bucket 的下标 0。查询 cat 时,主桶八个 tophash 都不等于 0xA7,因此不比较完整 key;随后沿非空 overflow 指针跳到溢出桶,在下标 0 先匹配摘要,再比较完整 key,命中 value 99。 查询 m["cat"]:主桶没有 → 沿真实指针 → overflow 槽 0 命中 前置定位:B=3 → 8 个主桶;hash 低 3 位 101 = 5 → buckets[5]要找的桶内摘要 tophash = 0xA7 第 ① 站:主桶 buckets[5],8 个槽全部占用 槽位下标01234567 tophash[8]0x310x8A0x420x190xE20x760x550x21 keys[8]"ant""dog""fox""bee""owl""yak""elk""hen" values[8]128536742 扫完 8 个摘要,没有 0xA7 → 不做任何完整 key 比较;overflow 指针非 nil 一次内存跳转 第 ② 站:overflow bucket 1,本地槽位下标仍从 0 到 7 槽位下标01234567 tophash[8]0xA70x0C0x5B空空空空空 keys[8]"cat""pig""ram"————— values[8]991115————— 槽 0:0xA7 匹配 → 同列 key "cat" 相等 → 同列 value=99命中后直接返回,不再检查本桶后续槽位和下一条指针 返回 99ok=true 这个状态怎样形成:整张 map 原有 8 个 key,都由低 3 位 101 选中 buckets[5],填满下标 0~7。第 9 个 key "cat" 到来时全局负载尚未触发 grow,主桶又没有空槽,因此 runtime 挂接实体 overflow bucket,把它放入本地下标 0。查询代价:相比主桶命中,多一次指针跳转,并额外扫描一组 8 个摘要。若本节也未命中:下一条 overflow 指针非 nil 就继续;为 nil 就返回零值和 ok=false。本例在槽 0 命中,不走该分支。
最应记住:主桶满不等于 map 满,而是接上另一个本地下标仍为 0~7 的 bmap;最常见误读是把溢出桶写成主桶数组的“8~15 号槽”,其实它是由指针连接的独立对象。

overflow 越多,查询越可能跟着指针跳到更多内存位置。CPU(Central Processing Unit,中央处理器)cache 是处理器内部速度很快的一小块临时存储,它更喜欢相邻数据;长链会破坏这种内存局部性。

冲突不等于 hash 完全相同:只要低 B 位相同,就会先落到同一个主 bucket。即使 tophash 也相同,仍然要比较完整 key。
六、扩容:容量翻倍与等量扩容是两回事
先直接回答最容易产生的疑问:每个 bmap 始终只有 8 个槽位,这个结构不会因为 map 变大而变成 16 槽或 32 槽。数据多时,经典 map 主要靠增加主 bucket 的数量来分散数据;但“扩容”还有一种只整理、不增加主桶数量的特殊情况。

先分清三个数量:元素、主桶、每桶槽位

数量由谁表示扩容时会怎样
map 里现有多少组 key/valuecount随插入、删除变化
有多少个主 bucket2^B普通翻倍扩容时增加一倍;等量扩容时不变
每个 bmap 有多少槽位固定为 8始终不变;主桶和 overflow 桶都是 8 槽

例如扩容前 B=3,有 8 个主桶,每个桶仍是 8 槽;普通翻倍后 B=4,变成 16 个主桶,但每个桶仍是 8 槽。可以把它想成“8 格货箱的数量增加了”,不是“每只货箱从 8 格改造成 16 格”。

不要把 8 × 2^B 当成绝对容量上限。它只是在数所有主桶一共有多少个固定槽位。主桶装满后还能连接 8 槽的 overflow bucket,所以经典 map 并不是到这个数字就立刻报错或拒绝插入。

为什么有时增加主桶,有时主桶数量不变

负载因子表示“元素数量 ÷ 主桶数量”,也就是平均每个主桶对应多少个元素。它看的是全局平均,不代表每个桶恰好都装了同样多。旧版插入数据时会观察两个信号:元素相对主桶是否太多,以及 overflow bucket 是否太多。

① 普通翻倍扩容:真的需要更多空间

元素确实太多、平均负载超过源码阈值时,B 加 1,主桶数量从 2^B 变成 2^(B+1)。常说的阈值约为 6.5,但源码还要求元素数先超过 8,不能把“6.5”理解成任何小 map 到点就立刻扩。

② 等量扩容:空间不少,只是摆得太乱

元素未必很多,但累计的 overflow 太多时,B 不变、主桶数量也不变。runtime 建立同样大小的新桶数组,把仍然有效的数据重新摆紧凑,减少稀疏的 overflow 链。

源码里的“太多”是具体条件:准备插入新元素时,若新 count > 8 且 count ÷ 2^B > 6.5,触发翻倍扩容;若负载没有超标,但近似 overflow 数 noverflow ≥ 2^B,触发等量扩容,B 大于 15 时比较阈值按 2^15 封顶。两项同时成立时,源码优先按负载过高处理,也就是翻倍,而不是“哪个信号先出现就选哪个”。
固定八槽的 bucket 在两种扩容中的变化 上半部分展示普通翻倍扩容,B 从 3 变成 4,主桶从 8 个变成 16 个,每桶仍是 8 槽。下半部分展示等量扩容,B 保持 3,主桶保持 8 个,只把分散在溢出桶的数据重新整理紧凑。 两种 grow 都会换一排新桶,但只有第一种让主桶翻倍 翻倍前:B=38 个主桶 × 每桶 8 槽 这里每个小格代表一个 8 槽 bmap B + 1 翻倍后:B=416 个主桶 × 每桶仍然 8 槽 整理前:B=3,仍是 8 个主桶有效数据散落,挂着许多稀疏 overflow … B 不变,只整理 整理后:B=3,还是 8 个主桶有效数据摆紧凑,多余 overflow 可被回收 共同点:新建桶数组并渐进搬迁;区别:B 和主桶数量是否翻倍。
最应记住:8 槽是单个 bmap 的固定结构;B 控制的是主桶有多少个。普通 grow 增加主桶,same-size grow 只换一排同样多的新桶来整理数据。
为什么“扩容”这个中文词容易误导:源码把两种过程都叫 grow。普通 grow 确实增加容量;same-size grow(等量增长)并不增加主桶数量,更像“搬到同样大的新仓库并重新整理”。
翻倍扩容时旧桶的数据分流到两个新桶 旧 B 等于 2 时,旧桶 2 中的元素在新 B 等于 3 后查看新增的第 2 位;该位为 0 留在新桶 2,为 1 去新桶 6,也就是 2 加 2 的 oldB 次方。 翻倍扩容只多看 1 个 hash 位 例:oldB=2,旧桶 i=2;newbit = 1 << oldB = 4 旧 bucket 2旧定位只看低 2 位:10 key A新位=0 key B新位=1 新 bucket 2新增位为 0:原地编号不变key A 新 bucket 6新增位为 1:2 + 2² = 6key B X 路:留在 iY 路:去 i + 2^oldB 不是重新随机决定位置;只是原有低位前面再多看一位。
旧桶 i 翻倍后只可能去两个地方:i 或 i + 2^oldB。这就是经典面试题里的 X/Y 分流。

为什么不一次搬完:把一次大停顿拆成很多小搬运

如果 map 很大,一次搬完所有 bucket 会让某一次写操作突然停顿很久。因此旧版采用渐进迁移:新写操作发生时,顺手搬与本次访问相关的旧桶,并继续推进迁移进度。

源码把“搬一个旧桶”叫 evacuate(直译为“疏散”)。旧槽位的 tophash 会改成搬迁状态:evacuatedX(值 2)表示该数据走 X 路,evacuatedY(值 3)表示走 Y 路;普通有效摘要从 minTopHash=5 开始,因此不会和 0~4 的空槽、搬迁状态混淆。若算出的摘要小于 5,runtime 会先加 5 再保存。

搬迁标记不等于“旧数据永远留着”。tophash 负责让查询识别旧桶已经搬走;在没有仍在遍历旧桶的操作持有引用且桶中含指针时,runtime 还会清除旧桶的 key/value 指针。全部旧桶搬完后,hmap.oldbuckets 被设为 nil;旧数组在没有其他引用后等待垃圾回收,也就是由 Go 自动找出不再使用的内存并回收,而不是这一刻同步释放。
oldbuckets 和 nevacuate 表示渐进迁移进度 新 buckets 已投入使用,旧桶 0 和 1 已迁移并带有 evacuated 搬迁状态,nevacuate 等于 2 指向下一个待搬旧桶;本次写入先搬目标旧桶,再额外推进一个桶。全部搬完后 hmap 将 oldbuckets 设为 nil,旧数组在没有其他引用后等待垃圾回收。 扩容开始后:新旧两排桶暂时同时存在 buckets:新桶数组(正在使用) 新 0新 1新 2新 3新 4新 5新 6新 7 oldbuckets:旧桶数组(还没全部搬完) 旧 0 ✓ 已迁移槽位标记 evacuated旧 1 ✓ 已迁移数据已到新桶旧 2 ← 下一个nevacuate = 2旧 3仍待迁移 写growWork:先迁移本次将访问的旧桶,再推进 nevacuate 指向的旧桶每次只做一小部分,把总搬运成本分摊到后续写操作;全部完成后 oldbuckets=nil。 扩容期间查询会判断对应旧桶是否已经迁移,必要时仍从 oldbuckets 查。
最应记住:扩容期间新旧桶会暂时并存,nevacuate 指向下一个待搬旧桶,后续写操作顺手推进;最常见误读是把 oldbuckets=nil 说成旧数组当场、同步释放。
面试一句话:负载过高时容量翻倍;overflow 过多但负载不高时等量扩容;两种扩容都保留 oldbuckets,并通过后续写操作渐进迁移。
七、删除:槽位清空不等于立刻缩容

delete(m, key) 找到目标槽位后,会清理 key/value,并更新 tophash 中的空槽状态。删除不存在的 key 仍然安全。

删除大量元素不代表主桶数组立刻缩小。旧版可能通过等量扩容整理过多的 overflow,但如果业务明确需要释放大块 map 内存,常见做法仍是新建 map,把保留数据复制过去,再丢弃旧 map。

八、经典 bucket 和当前 Group 怎样区分
对比Go 1.23 及之前Go 1.24+
顶层hmapMap + Directory
基本分组bmap bucket,8 槽Group,8 槽
初筛信息tophash[8]8 字节 control word,保存 H2
冲突处理overflow bucket开放寻址 + group 级二次探测
增长方式整组桶渐进迁移Table 独立 grow 或 split
不要把相似点当成同一个结构:两者都把数据按 8 个槽分组,也都用 hash 摘要初筛,但 Group 不是把 bmap 改了个名字。它们的内存组织、冲突路径和扩容方式都不同。

返回 Go 1.24+ Swiss Table 主线文章 →

九、经典实现常见面试追问
1. hmap 和 bmap 分别是什么?
hmap 管整张 map 的元数据和桶数组;bmap 表示一个最多容纳 8 组 key/value 的 bucket。
2. “B 是 bucket 数量以 2 为底的对数”是什么意思?
意思是 B 回答“2 的多少次方等于主桶数量”。8 个主桶等于 2³,所以 B=3;反过来写就是 log₂(8)=3。B 不是桶数量,2^B 才是。
3. 为什么 B=3 还表示要取 hash 的低 3 位?
3 个二进制位能组成 000~111 共 8 种编号,刚好对应 0~7 号主桶。所以 B 同时表示桶数量的指数和定位所需的位数。
4. 为什么一个 bucket 是 8 个槽?扩容后会变成 16 槽吗?
不会。这是经典 runtime 的固定 bmap 布局:8 个 tophash、8 个 key、8 个 value。普通扩容增加的是主 bucket 的数量,单个 bmap 仍是 8 槽。
5. hash 的“高 8 位”和“低 B 位”到底指哪里?
经典实现里 hasher 返回 uintptr。在常见 64 位机器上,它是 64 位整数:高 8 位是 bit 63~56,源码通过 hash >> 56 取得最高字节;低 B 位是 bit B-1~0,通过 hash & ((1<<B)-1) 取得。B=3 时最低 3 位若为 101,就是十进制 5,对应 buckets[5]。
6. tophash 相同就命中吗?
不是。它只是摘要,最后必须比较完整 key。
7. map 每次 grow 都会增加 bucket 数量吗?
不是。负载过高时 B 加 1,主桶翻倍;overflow 过多时会等量扩容,B 和主桶数量都不变,只把数据搬到同样大小的新桶数组中重新整理。
8. 为什么有等量扩容?
元素可能经过反复增删后并不多,但留下很多稀疏 overflow。等量扩容不增加主桶数量,只重新紧凑排列数据。
9. 为什么渐进迁移?
把大 map 一次搬完会造成明显停顿。渐进迁移把工作分摊到后续写操作中。
10. 两种扩容的精确触发条件是什么?
准备插入后的 count 必须先大于 8,并且平均负载超过 6.5,才做翻倍扩容;只有负载未超标但 noverflow 达到源码阈值时,才做等量扩容。若两个判断同时为真,负载判断优先,所以会翻倍。
11. evacuatedX、evacuatedY 和 minTopHash 分别解决什么问题?
evacuatedX 和 evacuatedY 用旧槽位的 tophash 记录数据已经走向翻倍后两个候选桶中的哪一个;minTopHash=5 让普通摘要避开 0~4 这些空槽和搬迁状态值。全部搬完后 hmap 清空 oldbuckets 引用,旧数组再由垃圾回收处理。
十、经典实现 90 秒总结话术
如果讨论的是 Go 1.23 及之前:

map 顶层由 hmap 管理。
hmap 记录元素数量 count、桶指数 B、当前 buckets、
扩容期间的 oldbuckets 和迁移进度 nevacuate。

主桶数量是 2^B:例如 B=3 时 2³=8,
反过来就是 log₂(8)=3;低 B 位也刚好能表示全部主桶编号。
每个 bmap bucket 始终只有 8 个槽位,扩容不会把单桶改成 16 槽,
布局是 tophash[8]、keys[8]、values[8],末尾可连接 overflow bucket。

查询时 hasher 会返回一个 uintptr hash。
在常见 64 位机器上,它就是一个 64 位整数:
低 B 位通过 hash & ((1<<B)-1) 选择主 bucket;
最高 8 位通过右移取得并生成 tophash(原始值小于 5 时 runtime 会调整);
tophash 匹配后仍要比较完整 key,主桶没找到再沿 overflow 查找。

扩容有两种:
负载过高时 B 加 1、容量翻倍;
overflow 过多时 B 不变,做等量扩容整理数据。
两种情况都保留 oldbuckets,并通过后续写操作渐进迁移。
顶层hmap
分组bmap 8 槽
冲突overflow
扩容渐进迁移
回答结束要补版本:“以上是 Go 1.23 及之前经典实现;Go 1.24+ 默认已经改用 Swiss Table。”
十一、应用场景:什么时候需要经典 Map 知识

什么时候用

阅读 Go 1.23 及之前的运行时源码、排查仍使用旧 Go 版本的服务,或者面试题明确出现 hmap、bmap、tophash、overflow bucket 时,使用本文模型分析。

为什么适合

这些名词能准确解释旧版 map 的桶内查找、溢出链和渐进迁移,也能帮助判断旧服务为何因大量 overflow 出现查询变慢或内存占用偏高。

注意什么

先确认 Go 版本,再谈底层结构。业务代码仍应围绕 map 的语言行为和并发安全设计,不能依赖桶数量、槽位布局等运行时实现细节。

什么时候不该硬用

讨论 Go 1.24+ 当前实现时,不要再用 hmap 和 overflow bucket 解释查询与扩容;应切换到 Swiss Table、Group、control word 和 Table 分裂模型。

业务化表达:“如果线上服务仍运行 Go 1.23 或更早版本,我会用经典桶模型分析 overflow 和渐进迁移;升级到 Go 1.24+ 后会重新按 Swiss Table 验证性能,不能直接套用旧结论。”
附录、官方源码依据

本文依据 Go 1.23.12 的历史 runtime 源码整理;源码中的 same-size grow 对应等量扩容,growWork/evacuate 对应分批执行和迁移旧桶。

  • Go 1.23.12 runtime/map.go
  • 阈值依据:overLoadFactor 判断负载是否超标,tooManyOverflowBuckets 判断溢出桶是否过多,hashGrow 决定翻倍还是等量扩容。
  • 迁移依据:evacuatedX、evacuatedY 和 minTopHash 保存槽位状态;evacuate 搬一个旧桶,advanceEvacuationMark 推进整张表的迁移进度。
  • Go 1.24+ Swiss Table 主线文章