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

相关推荐
MariaH1 小时前
Express框架使用
后端
MacroZheng1 小时前
横空出世!Claude Code画图神器来了,比Visio快10倍!
java·人工智能·后端
布局呆星1 小时前
Spring Boot + AOP 操作日志实战:自定义注解、切面编程、SecurityContext 全链路贯通,一次讲透
java·spring boot·后端
lazy H1 小时前
Maven 依赖爆红怎么办?IDEA 中 Maven 项目常见问题和解决方法总结
java·后端·学习·maven·intellij-idea
CodeSheep1 小时前
又是梁文锋,有点猛啊。
前端·后端·程序员
SimonKing1 小时前
低调低调,白嫖文生图,文生视频模型,无Token限制
java·后端·程序员
我登哥MVP1 小时前
SpringCloud Alibaba 核心组件解析:服务熔断和降级
java·spring boot·后端·spring·spring cloud·java-ee·maven
aramae1 小时前
《计算机网络(第5版)》第二章 物理层
服务器·网络·后端·计算机网络
lazy H1 小时前
Spring Boot 连接 MySQL 失败怎么办?常见报错原因和解决方法总结
spring boot·后端·学习·mysql·spring