Go 语言 map 底层实现

当时项目使用的是 Go 1.23 关于map底层的源码 runtime/map.go

项目里有个地方,先往 map 里写了大量数据,处理完之后用 delete 逐一删掉所有 key,内存没有降下来。GC 也等过了,还是挂在那。还去调了 runtime.GC() 试了一下,没用,才老老实实去看源码的。我这里是直接用火焰图定位到了问题,搞了我好几个星期。


delete(m, key) 对应运行时的 mapdelete,看看它干了啥:

go 复制代码
b.tophash[i] = emptyOne   // map.go:808
h.count--

就这两件事。key/value 如果含指针会清掉,但桶本身没动,h.buckets 那个指针还指着原来的桶数组,一个字节都没少。

hmap 长这样,B 是桶数量的对数,map 共有 2^B 个桶:

go 复制代码
// map.go:109
type hmap struct {
    count     int
    flags     uint8
    B         uint8          // 桶数量的对数:共有 2^B 个桶
    noverflow uint16
    hash0     uint32

    buckets    unsafe.Pointer // 指向 2^B 个桶的数组
    oldbuckets unsafe.Pointer // 扩容时保存旧桶数组
    nevacuate  uintptr

    extra *mapextra
}

全局搜了一下对 h.B 的赋值,只在 makemaphashGrow 里出现,hashGrow 里是 h.B += biggerbigger 最小是 0,不可能是负数。B 这辈子只会涨不会跌,桶申请了就一直在那,delete key 不会还桶。


bmap 的定义里只有 tophash 数组,key 和 value 跟在后面的内存里,运行时靠 unsafe.Pointer 便宜量偏来偏去手动访问:

go 复制代码
type bmap struct {           // map.go:143
    tophash [abi.MapBucketCount]uint8
    // 紧随其后:keys[8] / elems[8] / overflow *bmap
}

访问第 i 个 key 和 value:

go 复制代码
k := add(unsafe.Pointer(b), dataOffset + i*uintptr(t.KeySize))
e := add(unsafe.Pointer(b), dataOffset + abi.MapBucketCount*uintptr(t.KeySize) + i*uintptr(t.ValueSize))

这种方式如果写过C或者C++的话,应该经常这么做,游戏开发中确实会经常使用到了指针位移,因为缓存命中率高一些,速度也快。

卖弄一下我发现的有意思的点。 为啥将所有 key 集中存放再集中存放 value,而非交替排列? 源码注释解释道:

css 复制代码
packing all the keys together and then all the elems together makes the
code a bit more complicated than alternating key/elem/key/elem/... but it allows
us to eliminate padding which would be needed for, e.g., map[int64]int8.

map[int64]int8 来举例,key 是 8 字节 value 是 1 字节,如果 key/value 交替排列的话,每个 int8 后面都得填充 7 个字节来保证 int64 对齐,但全部 key 放一起、全部 value 放一起就省掉的了这些填充。呃,C++用户在搞class或者struct的时候一定有关注过内存对齐的问题,我记得有个pushpop的宏可以按照字节对齐来着,但golang是没有滴。而且这个有ECS设计的想法,这里可以吸收一些经验用到内存管理中。

在我的映像中,ECS会将大量相同的对象使用一整块内存集体放在一起,然后指针偏移,或者是数组下标访问,这样内存命中率会非常高,有个一级缓存二级缓存的说法,不太记得了。

比如大量单位的位移,大量的属性变动与计算,这个使用也可以使用相同的走法。

偏离了,以后有时间在写ECS的吧。还是回到得到hash后的处理,tophash 不光存哈希高位,还兼职记录槽位状态,0-4 全是特殊值,正常存的 tophash 必须 >= minTopHash

枚举名 枚举值 枚举含义
emptyRest 0 此槽及之后所有槽均为空
emptyOne 1 此槽为空
evacuatedX 2 已迁移到新表低半区
evacuatedY 3 已迁移到新表高半区
evacuatedEmpty 4 空槽且桶已迁移
minTopHash 5 正常哈希值的最小值

mapdelete 把 tophash 设成 emptyRest 而不是直接清零,是因为查找时碰到 emptyRest 可以立即 break,相当于"我和我后面的都空了",省的继续扫后面的槽。

tophash 的计算在 tophash() 函数里(map.go:188),取哈希值最高 8 位,如果算出来小于 minTopHash 就加上去,不能跟那几个状态值撞:

go 复制代码
top := uint8(hash >> (goarch.PtrSize*8 - 8))
if top < minTopHash {
    top += minTopHash
}

访问 m[key] 的时候运行时调的是 mapaccess1。先用哈西值低 B 位找桶,再在桶里用 tophash 快速过滤槽位:

go 复制代码
// map.go:431
hash := t.Hasher(key, uintptr(h.hash0))
m := bucketMask(h.B)                      // 即 2^B - 1
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.BucketSize)))

hash & m 取低 B 位当桶下标,比如 B=3 掩码是 0b0111hash0

桶内找 key 的时候先比 tophash,不匹配直接跳,只有撞上了才做完整 key 比教,所以大部分不匹配的槽位都是一字节比对 就跳过了:

go 复制代码
// map.go:444
top := tophash(hash)
bucketloop:
for ; b != nil; b = b.overflow(t) {
    for i := uintptr(0); i < abi.MapBucketCount; i++ {
        if b.tophash[i] != top {
            if b.tophash[i] == emptyRest {
                break bucketloop
            }
            continue
        }
        k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.KeySize))
        if t.Key.Equal(key, k) {
            // 找到,返回 value
        }
    }
}

B 什么时候会涨。hashGrow(map.go:1139)里判断两种情况:一是超过负责因子 count > 6.5 * 2^B,桶翻倍 B++;二是溢出桶数亮接近正常桶数亮(noverflow >= 2^B),这种情况 B 不涨,但数据会重新整理填上空洞,叫 sameSizeGrow

官方给出试验数据,6.5 是的空间和查询效率之间的折中点:

arduino 复制代码
loadFactor    %overflow  bytes/entry  hitprobe  missprobe
    6.50         20.90        10.79      4.25       6.50

一个桶的 8 个槽满了会挂溢出桶。B >= 4makeBucketArray 会提前多申请约 2^(B-4) 个溢出桶备用(见 map.go:364,nbuckets += bucketShift(b - 4)),省的频繁 alloc。

扩容不是一口气干完的。hashGrow 只申请新桶切换指针,旧桶留着,然后每次写操作里 growWork 顺手推进千已,每次搬 1~2 个旧桶。翻倍扩容时,旧桶 i 里的 key 会被分三到新桶 i(低半区 X)或 i + 旧桶数亮(高半区 Y),看 hash 的第 B 位是 0 还是 1:

go 复制代码
if hash&newbit != 0 {    // map.go:1314
    useY = 1
}

读操作时也要判断旧桶有没有迁完,没迁完就还从旧桶读(map.go:434 if !evacuated(oldb))。

相关推荐
牛奔7 小时前
Go 如何打印调试深层或嵌套的结构体
开发语言·后端·golang
红烧大青虫7 小时前
HarmonyOS应用开发实战:小事记 - 数据迁移策略:RDB 表结构变更的版本号管理与 onUpgrade 回调
后端·华为·harmonyos·鸿蒙系统
geovindu8 小时前
go: Iterative Algorithms
开发语言·后端·算法·golang·迭代算法
阳光是sunny9 小时前
LangGraph实战教程:defer延迟节点——让收尾工作自动排到最后
前端·人工智能·后端
前端工作日常9 小时前
我学习到的Java中domain和dto区别
java·后端
kite01219 小时前
Go语言Map深度解析与最佳实践
开发语言·后端·golang
阳光是sunny9 小时前
LangGraph实战教程:控制流详解
前端·人工智能·后端
hold?fish:palm10 小时前
kv存储主从复制的设计与实现
c++·redis·后端
swipe11 小时前
07|(前端转后全栈)为什么后端也要缓存?从前端缓存思维理解 Redis
前端·后端·全栈
9523611 小时前
Docker - 基础
运维·后端·docker·容器