- 文档
- 教程
【免费下载链接】go-questions
📖 Go 程序员面试笔试宝典 | 从问题切入,串连 Go 语言相关的所有知识,融会贯通。 https://golang.design/go-questions
导读
本文围绕「Go 语言中无法对 map 的 key 或 value 取地址」这一语言特性展开,先给出编译层面的直接证据,再深入到runtime的hmap/bmap内存模型、渐进式扩容的evacuate搬迁过程,解释为什么即使通过unsafe.Pointer等 hack 手段拿到地址也不能长期持有。读完你不仅能讲清「为什么不能取址」,还能把 map 底层存储、扩容搬迁与 slice 传参差异等面试高频考点一并串起来。
结论先行:编译器在编译期就禁止了对 map 元素取址
对 map 的 key 或 value 进行取址是编译不过的。以下代码无法通过编译:
package main import "fmt" func main() { m := make(map[string]int) fmt.Println(&m["qcrao"]) }编译器给出的报错信息是:
./main.go:8:14: cannot take the address of m["qcrao"]这个报错不是运行时的 panic,而是编译期错误。也就是说,Go 语言从语言层面直接禁止了对 map 元素取址,代码根本无法编译生成。要理解这条规则为什么存在,必须回到 map 的底层实现。
底层根源一:map 是引用语义,bucket 数组的地址随时可能整体变化
回顾 map 的底层实现。表示 map 的结构体是hmap,它是 hashmap 的缩写(对应源码src/runtime/hashmap.go):
// A header for a Go map. type hmap struct { // 元素个数,调用 len(map) 时,直接返回此值 count int flags uint8 // buckets 的对数 log_2 B uint8 // overflow 的 bucket 近似数 noverflow uint16 // 计算 key 的哈希的时候会传入哈希函数 hash0 uint32 // 指向 buckets 数组,大小为 2^B // 如果元素个数为0,就为 nil buckets unsafe.Pointer // 等量扩容的时候,buckets 长度和 oldbuckets 相等 // 双倍扩容的时候,buckets 长度会是 oldbuckets 的两倍 oldbuckets unsafe.Pointer // 指示扩容进度,小于此地址的 buckets 迁移完成 nevacuate uintptr extra *mapextra // optional fields }关键点在于:hmap.buckets是一个指针,指向一块连续分配的 bucket 数组。map 的 key 和 value 并不是像普通变量那样拥有一个固定地址,而是被存放在这块由运行时动态管理的连续内存的「槽位」里。
从创建过程也能印证这一点:通过汇编语言可以看到,make(map[string]int)底层调用的是makemap函数,它的返回值是*hmap指针(对应源码src/runtime/hashmap.go):
func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap { // ... }对比之下,makeslice返回的是slice结构体本身:
func makeslice(et *_type, len, cap int) slice这正是「map 和 slice 作为函数参数时的区别」的根源:一个是*hmap指针,一个是slice结构体。Go 语言函数传参都是值传递,*hmap指针被 copy 后仍然指向同一个 map,因此函数内部对 map 的修改会影响实参;而 slice 被 copy 后成为新的结构体,对它的操作不会影响实参。这也从侧面说明:map 本身就是一个「间接层」,元素的位置从不暴露给用户,用户手里只握着*hmap这个句柄。
底层根源二:bucket 内存模型中 key/value 存在可变槽位里
再看 bucket 的结构。bmap在源码(src/runtime/hashmap.go)中表面上只包含一个 tophash 数组:
type bmap struct { tophash [bucketCnt]uint8 }但这只是表面结构,编译期间会给它「加料」,动态地创建一个新结构:
type bmap struct { topbits [8]uint8 keys [8]keytype values [8]valuetype pad uintptr overflow uintptr }bmap就是我们常说的「桶」,桶里面最多装 8 个 key。key 经过哈希计算后,用哈希值的高 8 位决定 key 落入桶内的哪个槽位;用哈希值的低B位决定落入哪个桶。也就是说,一个元素的「地址」并不是由变量绑定决定的,而是由它的哈希值在某一时刻计算出来的结果决定的。
bucket 的内存模型可以直观地用下图表示(摘自本仓库content/map/assets/1.png,注意 key 和 value 是各自放在一起的,并不是key/value/key/value/...的形式,这样可以在某些情况下省略 padding 字段节省内存):
这带来一个直接后果:key/value 在 bucket 中的槽位序号(地址)完全由哈希结果和当时的扩容状态决定,随时可能被改写。取出来的地址既没有稳定的语义,也不具备任何合法性保证。
底层根源三:扩容会让 key/value 物理搬迁,旧地址彻底失效
原文档强调:即使通过其他 hack 的方式(例如unsafe.Pointer等)获取到了 key 或 value 的地址,也不能长期持有,因为一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。这一点有充分的源码依据。
map 扩容触发条件(对应源码src/runtime/hashmap.go的mapassign函数):
// 触发扩容时机 if !h.growing() && (overLoadFactor(int64(h.count), h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { hashGrow(t, h) } // 装载因子超过 6.5 func overLoadFactor(count int64, B uint8) bool { return count >= bucketCnt && float32(count) >= loadFactor*float32((uint64(1)<<B)) } // overflow buckets 太多 func tooManyOverflowBuckets(noverflow uint16, B uint8) bool { if B < 16 { return noverflow >= uint16(1)<<B } return noverflow >= 1<<15 }扩容分为两种:
- 装载因子超过阈值(6.5)触发的双倍扩容:元素太多而 bucket 太少,将
B加 1,bucket 数量变为原来的 2 倍。此时老 bucket 中的 key 会「裂变」到两个新 bucket 中,key 需要重新计算哈希(rehash),位置必然改变。 - overflow bucket 过多触发的等量扩容:元素不多但 bucket 很分散,开辟同等大小的新 bucket 空间,将老 bucket 中的元素搬过去使其排列更紧密。位置同样会变。
无论哪种扩容,都需要把原有 key/value 重新搬迁到新的内存地址。为了不影响性能,Go map 采用渐进式扩容:原有 key 不会一次性搬迁完毕,每次最多只搬迁 2 个 bucket。hashGrow()只是分配新 buckets 并把老 buckets 挂到oldbuckets字段上,真正搬迁发生在growWork()/evacuate()函数中(插入、修改、删除 key 时都会尝试推进搬迁)。
2 倍扩容时老 bucket 裂变到两个新 bucket 的过程可以直观地看下面这张图(摘自本仓库content/map/assets/16.png,新 map 中0-3称为 x part,4-7称为 y part,key 依据 hash 的低位决定落入哪一部分):
因此,「保存下来的元素地址」在扩容发生时就不再指向该 key/value 了——它可能指向一个被搬走的空槽位,甚至指向一块被清理掉的内存区域。这正是原文档所说的「之前保存的地址也就失效了」。
不只是扩容:删除操作也会让地址上残留的值不再有意义
即使不考虑扩容,删除操作也会让「已取地址」变成陷阱。map 的删除底层执行函数是mapdelete(对应源码src/runtime/hashmap.go)。找到 key 的位置后,会对 key 和 value 做「清零」操作:
// 对 key 清零 if t.indirectkey { *(*unsafe.Pointer)(k) = nil } else { typedmemclr(t.key, k) } // 对 value 清零 if t.indirectvalue { *(*unsafe.Pointer)(v) = nil } else { typedmemclr(t.elem, v) }删除后,对应位置的 tophash 被置为empty,槽位被标记为可复用。如果你此前通过 hack 手段保存了该位置的地址,那么这块内存随时会被后续插入的 key 复用、覆盖。也就是说,即使不扩容,map 元素地址的生命周期也完全不受你控制。
正确做法:想持有元素就用指针类型作为 value
理解了上面的原理,实际开发中的最佳实践就很清晰了:不要在 map 中存放「需要长期持有地址」的值,如果需要,就把指针作为 value 存进 map。
例如:
type User struct { ID int Name string } func main() { // 存放指针,指针本身的地址稳定,指向的对象也由你掌控生命周期 m := make(map[string]*User) u := &User{ID: 1, Name: "qcrao"} m["qcrao"] = u // 读取出来的是同一个指针,仍然可以安全地修改对象 if got, ok := m["qcrao"]; ok { got.Name = "new name" } }这样做的本质是:map 里保存的是「指向对象的指针」这个值,指针指向的对象内存由你自己管理,不受 map 扩容、删除的影响。而像map[string]int、map[string]struct{...}这类值类型,元素的「值」才是你需要的数据,你应该通过m[key] = ...的语法去读写它,而不是惦记它的地址。
小结:一句话回答面试官
- 为什么不能对 map 的 key/value 取址?因为 map 底层是
*hmap引用语义,key/value 存放在动态管理的 bucket 槽位中,其地址由哈希值与扩容状态决定,扩容搬迁(evacuate)和删除清零(typedmemclr)都会让旧地址失效;Go 编译器直接在编译期用cannot take the address of m["key"]禁止了这种危险操作。 - 通过
unsafe.Pointerhack 拿到地址能长期持有吗?不能。一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址随之失效。 - 想要长期持有「对象」怎么办?用
map[string]*T保存指针,对象生命周期由你掌控。
相关深入阅读:map 的底层结构见 1-map的底层实现原理是什么.md,扩容细节见 6-map 的扩容过程是怎样的.md,删除过程见 5-map 的删除过程是怎样的.md,slice 与 map 作为函数参数的区别在 1-map的底层实现原理是什么.md 的「引申1」一节也有展开。
- 文档
- 教程
【免费下载链接】go-questions
📖 Go 程序员面试笔试宝典 | 从问题切入,串连 Go 语言相关的所有知识,融会贯通。 https://golang.design/go-questions
相关推荐
抖音批量下载工具 douyin-downloader:三步跑通无水印作品下载
抖音批量下载工具 douyin downloader:三步跑通无水印作品下载 douyin downloader 是一个免费开源的命令行工具,用来把抖音视频、图
网页爬虫CLIRust 编译器 E0197 错误解析:为什么固有 impl(inherent impl)不能标记为 unsafe
Rust 编译器 E0197 错误解析:为什么固有 impl(inherent impl)不能标记为 unsafe 本文基于 Rust 编译器源码仓库中的错误码
编程语言编译器语言运行时标准库Rust 编译错误 E0198 深度解析:为什么 negative impl(负实现)不能标记为 `unsafe`
Rust 编译错误 E0198 深度解析:为什么 negative impl(负实现)不能标记为 unsafe E0198 是 rustc 在编译期报告的一个错
编程语言编译器语言运行时标准库
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考