hmap是Go map的顶层元数据结构,不直接存储键值对,而是管理桶数组(buckets)、扩容状态(oldbuckets、nevacuate)、元素数量(count)及哈希种子(hash0)等核心字段。

Go map 的底层结构体 hmap 是什么
Go 的 map 不是简单的一块连续内存,而是一个由运行时动态管理的复合结构,核心是 hmap 结构体(定义在 src/runtime/map.go)。它本身只存元信息,真正装键值对的是背后分散的 bmap(bucket)——你可以把它理解为哈希桶数组的“头”。
hmap 中几个关键字段直接决定内存行为:
-
B:决定主 bucket 数量为2^B(初始为 4,即 16 个 bucket) -
buckets:指向首块 bucket 内存的指针,连续分配 -
oldbuckets:扩容中用于渐进式搬迁的旧 bucket 区域 -
overflow:指向溢出 bucket 链表的指针数组,每个 bucket 满了就 malloc 一个新 bucket 链上去
一个 bmap bucket 里到底存了什么
每个 bucket 固定容纳最多 8 个键值对(bucketCnt = 8),但不是按 key-value 交替排列。实际布局是「高 8 位 hash 数组 + 所有 key 连续排布 + 所有 value 连续排布 + 一个 overflow 指针」。
这种分段紧凑布局是为了避免内存对齐浪费。比如 map[int64]int8,如果 kv 交错,int64 后紧跟 int8 会导致大量 padding;而把所有 key 放一起,所有 value 放一起,就能让编译器按需对齐,节省空间。
查找时先比 tophash(key 哈希值的高 8 位),命中后再逐个比完整 key —— 这是快速过滤的关键一步。
为什么 len(m) 很快,但遍历顺序却不可预测
len(m) 直接读 hmap.count 字段,O(1)。
而遍历时,Go 会从 buckets[0] 开始扫描,但具体走哪些 bucket、哪个 overflow 链、每个 bucket 内部从哪个 slot 开始检查,都取决于当前哈希种子 hash0 和 key 的哈希结果。每次运行程序,hash0 都是随机生成的(防 DOS 攻击),所以即使相同数据,两次 for range m 的输出顺序也大概率不同。
注意:这不是 bug,是设计使然。依赖遍历顺序的代码本身就是错的。
扩容时内存怎么变,oldbuckets 何时释放
当负载因子(count / (2^B))超过阈值(约 6.5)或某个 bucket 溢出链太长时,触发扩容:B 加 1,bucket 总数翻倍。
但 Go 不一次性搬完,而是用 nevacuate 记录已搬迁的 bucket 下标,后续每次写操作(mapassign)、甚至某些读操作(mapaccess)都会顺手搬一两个 bucket。这样避免 STW(Stop-The-World)停顿。
oldbuckets 内存不会立刻 free,要等所有 bucket 都搬完、且没有 goroutine 正在访问它时,才由 GC 回收。这意味着扩容期间内存占用会短暂达到峰值(新旧两套 bucket 都在)。
容易踩的坑:在扩容未完成时并发读写,虽然 Go runtime 保证了安全性,但性能会下降(要查两套 bucket);若此时大量写入,可能触发二次扩容。


















