在 Go 语言中,当我们执行 m[k] = v 向 map 中插入或更新元素时,底层实际调用的是 runtime.mapassign 函数。
观察这个函数的签名,你会发现一个有趣的细节:
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointermapassign 并没有接收 value 作为参数。它的核心职责是寻址:在 map 中找到(或分配)一个用于存储该 key 的槽位,将 key 写入,并返回用于存储 value 的内存地址指针。至于将实际的 v 写入该地址,则是由编译器在调用 mapassign 之后生成的额外指令来完成的。
接下来,我们将顺着 Go 1.21 的源码,梳理 mapassign 是如何一步步完成这个寻址与插入任务的。
插入流程解析
1. 前置检查与并发控制
在执行实质性的插入操作前,mapassign 需要先确保当前环境的安全:
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}- Nil Map 检查:向一个
nilmap 赋值会直接触发 panic。这与读取nilmap 时安全返回零值的行为不同。 - 并发写检测:Go 的原生 map 不是并发安全的。如果通过
h.flags&hashWriting检测到有其他 goroutine 正在写入,程序会直接抛出fatal错误。
确认安全后,当前 goroutine 会将 hashWriting 标志位设置上,声明自己正在占用写状态。
2. 计算哈希与锁定状态
有了安全的写入环境,下一步是获取寻址所需的哈希值:
hash := t.Hasher(key, uintptr(h.hash0))
h.flags ^= hashWriting // 标记当前正在写入利用类型元数据中的哈希函数,结合 map 初始化时的随机种子 hash0,计算出 key 的哈希值。
3. 延迟初始化(Lazy Initialization)
如果 map 是通过 make(map[k]v) 创建且没有指定容量,它的内部结构并不会立刻分配内存。Go 将这部分开销推迟到了第一次真正插入数据时:
if h.buckets == nil {
h.buckets = newobject(t.Bucket) // 分配第 1 个 bucket
}4. 定位 Bucket 与协助扩容
拿到哈希值和 buckets 数组后,我们需要定位具体的 bucket:
again:
bucket := hash & bucketMask(h.B)
if h.growing() {
growWork(t, h, bucket)
}
b := (*bmap)(add(h.buckets, bucket*uintptr(t.BucketSize)))
top := tophash(hash)通过哈希值的低位,我们定位到了目标 bucket。
此时需要注意 map 的状态:如果 map 恰好处于扩容阶段(h.growing() 为 true),Go 的渐进式扩容机制会要求当前执行写入的 goroutine 协助搬迁数据。它会调用 growWork 将当前 bucket 及其对应的旧 bucket 数据迁移到新数组中,然后再继续执行插入。
5. 遍历查找与记录空槽位
定位到 bucket 后,程序会在当前 bucket 及其挂载的溢出桶(overflow buckets)中进行遍历。
在遍历过程中,mapassign 需要同时兼顾两个目标:
- 查找是否存在相同的 key(如果是,则为更新操作)。
- 记录遇到的第一个空槽位(如果遍历完未找到 key,则将新 key 插入此槽位)。
var inserti *uint8 // 记录空闲槽位的 tophash 指针
var insertk unsafe.Pointer // 记录空闲槽位的 key 地址
var elem unsafe.Pointer // 记录空闲槽位的 value 地址
bucketloop:
for {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
// 如果遇到空槽位,且之前尚未记录过,则保存该位置信息
if isEmpty(b.tophash[i]) && inserti == nil {
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
}
if b.tophash[i] == emptyRest {
break bucketloop // 后续全为空,提前结束遍历
}
continue
}
// tophash 匹配,进一步比较完整的 key 是否相等
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
if t.IndirectKey() {
k = *((*unsafe.Pointer)(k))
}
if !t.Key.Equal(key, k) {
continue
}
// 确认 key 相同,执行更新逻辑
if t.NeedKeyUpdate() {
typedmemmove(t.Key, k, key) // 更新 key
}
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
goto done // 直接跳转到结束,返回 elem 地址
}
// 当前桶遍历完毕,继续查找下一个溢出桶
ovf := b.overflow(t)
if ovf == nil {
break
}
b = ovf
}6. 处理 Bucket 满载情况(核心逻辑)
如果遍历完了所有的常规桶和溢出桶,都没有找到目标 key,说明这是一次插入新元素的操作。此时,程序会尝试使用刚才记录的空槽位(inserti)。
但是,如果当前 bucket 及其溢出桶已经全部满载(没有遇到任何空槽位),inserti 将保持为 nil。此时该如何处理?
源码中的处理逻辑如下:
// 1. 检查是否需要触发全局扩容
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again // 扩容会改变 bucket 的映射关系,必须从头重新定位和查找
}
// 2. 如果不需要全局扩容,但当前桶已满,则分配新的溢出桶
if inserti == nil {
// 为当前 bucket 分配一个新的溢出桶,并链接到末尾
newb := h.newoverflow(t, b)
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.KeySize))
}面对满载的 bucket,Go 会依次进行两步判断:
- 评估全局健康度(是否需要扩容):
检查 map 的装载因子是否过高(平均每个 bucket 超过 6.5 个元素),或者溢出桶数量是否过多。如果达到阈值,说明 map 的整体性能正在下降,此时会调用
hashGrow触发全局扩容。注意:触发扩容后,原有的 bucket 映射关系失效,程序必须通过goto again跳转回开头重新计算和定位。 - 处理局部冲突(分配溢出桶):
如果整体装载因子正常,仅仅是当前 bucket 因为哈希冲突严重而满载,Go 不会盲目进行全局扩容。它会调用
h.newoverflow(t, b)专门为这个 bucket 分配一个新的溢出桶,并将新元素安排在新桶的第一个槽位中。
7. 写入数据与状态清理
当成功找到合适的槽位(无论是原有的空槽位,还是新分配的溢出桶槽位)后,最后一步就是将数据写入并清理状态:
// 写入 key 并更新 tophash
typedmemmove(t.Key, insertk, key)
*inserti = top
h.count++ // 元素总数加 1
done:
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
h.flags &^= hashWriting // 清除写入标志位
if t.IndirectElem() {
elem = *((*unsafe.Pointer)(elem))
}
return elem // 返回 value 的内存地址程序会将 key 拷贝到目标内存,更新 tophash 数组,并增加 map 的元素计数 count。最后,清除 hashWriting 标志位,将计算好的 value 内存地址 elem 返回给编译器生成的代码,由其完成最终的赋值操作。
总结
梳理完整个流程,我们可以清晰地回答关于“插入新 key 时 bucket 已满”的处理策略:
- 优先检查扩容阈值:Go 会先评估整个 map 的装载因子和溢出桶数量。如果达到阈值,则触发全局扩容,并重新执行插入流程。
- 动态挂载溢出桶:如果未达到全局扩容条件(仅为局部哈希冲突),Go 会为当前 bucket 动态分配一个新的溢出桶,将新元素存入其中,从而巧妙地化解局部满载问题。
附:mapassign 完整源码
附上 Go 1.21 中 mapassign 的完整源码,供对照参考:
// Like mapaccess, but allocates a slot for the key if it is not present in the map.
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
if raceenabled {
callerpc := getcallerpc()
pc := abi.FuncPCABIInternal(mapassign)
racewritepc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.Key, key, callerpc, pc)
}
if msanenabled {
msanread(key, t.Key.Size_)
}
if asanenabled {
asanread(key, t.Key.Size_)
}
if h.flags&hashWriting != 0 {
fatal("concurrent map writes")
}
hash := t.Hasher(key, uintptr(h.hash0))
// Set hashWriting after calling t.hasher, since t.hasher may panic,
// in which case we have not actually done a write.
h.flags ^= hashWriting
if h.buckets == nil {
h.buckets = newobject(t.Bucket) // newarray(t.Bucket, 1)
}
again:
bucket := hash & bucketMask(h.B)
if h.growing() {
growWork(t, h, bucket)
}
b := (*bmap)(add(h.buckets, bucket*uintptr(t.BucketSize)))
top := tophash(hash)
var inserti *uint8
var insertk unsafe.Pointer
var elem unsafe.Pointer
bucketloop:
for {
for i := uintptr(0); i < bucketCnt; i++ {
if b.tophash[i] != top {
if isEmpty(b.tophash[i]) && inserti == nil {
inserti = &b.tophash[i]
insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
}
if b.tophash[i] == emptyRest {
break bucketloop
}
continue
}
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
if t.IndirectKey() {
k = *((*unsafe.Pointer)(k))
}
if !t.Key.Equal(key, k) {
continue
}
// already have a mapping for key. Update it.
if t.NeedKeyUpdate() {
typedmemmove(t.Key, k, key)
}
elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.KeySize)+i*uintptr(t.ValueSize))
goto done
}
ovf := b.overflow(t)
if ovf == nil {
break
}
b = ovf
}
// Did not find mapping for key. Allocate new cell & add entry.
// If we hit the max load factor or we have too many overflow buckets,
// and we're not already in the middle of growing, start growing.
if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) {
hashGrow(t, h)
goto again // Growing the table invalidates everything, so try again
}
if inserti == nil {
// The current bucket and all the overflow buckets connected to it are full, allocate a new one.
newb := h.newoverflow(t, b)
inserti = &newb.tophash[0]
insertk = add(unsafe.Pointer(newb), dataOffset)
elem = add(insertk, bucketCnt*uintptr(t.KeySize))
}
// store new key/elem at insert position
if t.IndirectKey() {
kmem := newobject(t.Key)
*(*unsafe.Pointer)(insertk) = kmem
insertk = kmem
}
if t.IndirectElem() {
vmem := newobject(t.Elem)
*(*unsafe.Pointer)(elem) = vmem
}
typedmemmove(t.Key, insertk, key)
*inserti = top
h.count++
done:
if h.flags&hashWriting == 0 {
fatal("concurrent map writes")
}
h.flags &^= hashWriting
if t.IndirectElem() {
elem = *((*unsafe.Pointer)(elem))
}
return elem
}