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))。

相关推荐
爱勇宝1 小时前
第 1 章:别把“需求文档”当成真正的需求
前端·后端·程序员
IT_陈寒6 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码7 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo7 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志8 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..8 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects8 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰8 小时前
一些基础和问题解决
后端
陈随易8 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN8 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp