当时项目使用的是 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 的赋值,只在 makemap 和 hashGrow 里出现,hashGrow 里是 h.B += bigger,bigger 最小是 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的时候一定有关注过内存对齐的问题,我记得有个push和pop的宏可以按照字节对齐来着,但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 掩码是 0b0111。hash0
桶内找 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 >= 4 时 makeBucketArray 会提前多申请约 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))。