Go map扩容是渐进式边读写边迁移的过程,触发条件为负载因子≥6.5或溢出桶数≥主桶数;分翻倍扩容与sameSizeGrow两种,且从不缩容。

Go 的 map 扩容不是一次性搬完所有数据,而是边读写边迁移的渐进式过程;不理解这点,就容易在压测或长时运行服务中观察到内存不降、遍历变慢、GC 压力突增等现象。
扩容触发条件:负载因子 ≥ 6.5 或溢出桶过多
运行时检查两个条件,任一满足即启动扩容:
len(m) >= (1 (即当前元素数 ÷ 桶数 ≥ 6.5)——这是主因,硬编码在src/runtime/map.go的loadFactorThresholdh.noverflow >= (1 (溢出桶数量 ≥ 主桶数量)——用于应对哈希冲突集中但总元素不多的场景,比如大量 key 的低 B 位 hash 完全相同
注意:make(map[int]int, 100) 中的 100 只是 hint,它影响初始 B 值(如算出 B=7 → 128 个主桶),但不会改变上述阈值逻辑。小 map(B=0 或 1)可能插入 7–10 个元素就触发首次扩容。
翻倍扩容 vs sameSizeGrow:B 值是否变化是关键
绝大多数扩容是翻倍:B 加 1,主桶数量从 2^B 变为 2^(B+1),地址空间扩大一倍。
但有一种例外叫 sameSizeGrow:当 oldbuckets != nil(即已在扩容中),且新写入 key 的 hash 高位恰好落在旧桶范围内,运行时可能只分配新溢出桶,B 不变。此时 h.flags 会带 sameSizeGrow 标志(值为 8)。
这种行为不常见,但能缓解极端哈希倾斜导致的单桶溢出爆炸,避免立即翻倍带来过大内存压力。
渐进式搬迁:nevacuate 控制迁移进度
扩容后,oldbuckets 不释放,nevacuate 记录下一个待迁移的旧桶编号。每次 mapassign(写)或 mapaccess(读)操作,都会顺手搬运该桶里部分 key-value 对到新结构中。
这意味着:
- 遍历时可能穿插迁移逻辑,顺序更不可控
- 内存峰值 = 新桶全部 + 未迁移完的旧桶 + 溢出桶,比单纯“翻倍”还高
- 如果 map 长时间只读不写,
nevacuate停滞,oldbuckets一直占着内存,直到下次写操作触发
可通过 runtime.ReadMemStats 观察 MapSys 是否持续偏高,或用 pprof 查看 map_buckhash_sys 分布间接判断是否卡在搬迁阶段。
扩容期间读写行为:oldbuckets 和 buckets 同时存在
运行时必须兼容两套桶结构。查找一个 key 时:
- 先按当前
B值计算应落桶号(新桶索引) - 若
oldbuckets != nil,再用旧B值算一次(旧桶索引),分别在新旧结构中查找 - 写操作一定发生在新桶,同时触发该旧桶的迁移(最多搬 8 个键值对)
所以即使你只做 for range m,只要 map 正在扩容,迭代器也会推进 nevacuate,并可能访问 oldbuckets。这也是为什么不能靠 len(m) 判断是否扩容中——它只返回 count,和底层结构完全无关。
真正容易被忽略的是:map 从不缩容。删光所有元素后,buckets 和已分配的溢出桶仍驻留内存,除非整个 map 被 GC 回收。高频创建销毁小 map 比复用一个大 map 更省内存。











