Go 程序运行时,有些场景下会导致进程进入某个“高点”,然后就再也下不来了 。
比如,多年前曹大写过的一篇文章讲过,在做活动时线上涌入的大流量把 goroutine 数抬升了不少,流量恢复之后 goroutine 数也没降下来,导致 GC 的压力升高,总体的 CPU 消耗也较平时上升了 2 个点左右 。
有一个 issue 讨论为什么 allgs(runtime 中存储所有 goroutine 的一个全局 slice) 不收缩,一个好处是:goroutine 复用 , 让 goroutine 的创建更加得便利 , 而这也正是 Go 语言的一大优势 。
最近在看《100 mistakes》,书里专门有一节讲 map 的内存泄露 。其实这也是另一个在经历大流量后,无法“恢复”的例子:map 占用的内存“只增不减” 。
之前写过的一篇《深度解密 Go 语言之 map》里讲到过 map 的内部数据结构,并且分析过创建、遍历、删除的过程 。
在 Go runtime 层,map 是一个指向 hmap 结构体的指针,hmap 里有一个字段 B , 它决定了 map 能存放的元素个数 。
hamp
结构体代码如下:
type hmap struct { countint flagsuint8 Buint8 // ...}
若我们想初始化一个长度为 100w 元素的 map , B 是多少呢?
用 B 可以计算 map 的元素个数:loadfactor * 2^B,loadfactor 目前是 6.5,当 B=17 时 , 可放 851,968 个元素;当 B=18,可放 1,703,936 个元素 。因此当我们将 map 的长度初始化为 100w 时,B 的值应是 18 。
loadfactor 是装载因子,用来衡量平均一个 bucket 里有多少个 key 。如何查看占用的内存数量呢?用 runtime.MemStats:
package mainimport ( "fmt" "runtime")const N = 128func randBytes() [N]byte { return [N]byte{}}func printAlloc() { var m runtime.MemStats runtime.ReadMemStats(&m) fmt.Printf("%d MB\n", m.Alloc/1024/1024)}func main() { n := 1_000_000 m := make(map[int][N]byte, 0) printAlloc() for i := 0; i < n; i++ {m[i] = randBytes() } printAlloc() for i := 0; i < n; i++ {delete(m, i) } runtime.GC() printAlloc() runtime.KeepAlive(m)}
如果不加最后的 KeepAlive,m 会被回收掉 。当 N = 128 时 , 运行程序:
$ go run main2.go0 MB461 MB293 MB
可以看到,当删除了所有 kv 后,内存占用依然有 293 MB,这实际上是创建长度为 100w 的 map 所消耗的内存大小 。当我们创建一个初始长度为 100w 的 map:package mainimport ( "fmt" "runtime")const N = 128func printAlloc() { var m runtime.MemStats runtime.ReadMemStats(&m) fmt.Printf("%d MB\n", m.Alloc/1024/1024)}func main() { n := 1_000_000 m := make(map[int][N]byte, n) printAlloc() runtime.KeepAlive(m)}
运行程序,得到 100w 长度的 map 的消耗的内存为:$ go run main3.go293 MB
这时有一个疑惑 , 为什么在向 map 写入了 100w 个 kv 之后,占用内存变成了 461MB?我们知道,当 val 大小 <= 128B 时 , val 其实是直接放在 bucket 里的,按理说,写入 kv 与否,这些 bucket 占用的内存都在那里 。换句话说,写入 kv 之后 , 占用的内存应该还是 293MB,实际上却是 461MB 。
这里的原因其实是在写入 100w kv 期间 map 发生了扩容 , buckets 进行了搬迁 。我们可以用 hack 的方式打印出 B 值:
func main() { //... var B uint8 for i := 0; i < n; i++ {curB := *(*uint8)(unsafe.Pointer(uintptr(unsafe.Pointer(*(**int)(unsafe.Pointer(&m)))) + 9))if B != curB {fmt.Println(curB)B = curB}m[i] = randBytes() } //... runtime.KeepAlive(m)}
【Go map 竟然也会发生内存泄露?】运行程序,B 值从 1 一直变到 18 。搬迁的过程可以参考前面提到的那篇 map 文章,这里不再赘述 。而如果我们初始化的时候直接将 map 的长度指定为 100w , 那内存变化情况为:
293 MB293 MB293 MB
当 val 小于 128B 时,初始化 map 后内存占用量一直不变 。原因是 put 操作只是在 bucket 里原地写入 val , 而 delete 操作则是将 val 清零,bucket 本身还在 。因此,内存占用大小不变 。而当 val 大小超过 128B 后,bucket 不会直接放 val,转而变成一个指针 。我们将 N 设为 129,运行程序:
0 MB197 MB38 MB
虽然 map 的 bucket 占用内存量依然存在,但 val 改成指针存储后内存占用量大大降低 。且 val 被删掉后,内存占用量确实降低了 。总之,map 的 buckets 数只会增,不会降 。所以在流量冲击后,map 的 buckets 数增长到一定值,之后即使把元素都删了也无济于事 。内存占用还是在,因为 buckets 占用的内存不会少 。
对于 map 内存泄露的解法:
推荐阅读
- Java8中那些方便又实用的Map函数
- Spring Boot 中使用 tkMapper
- 重大发现,AQS加锁机制竟然跟Synchronized有惊人的相似
- 信号量 C# 多线程访问之 SemaphoreSlim【C# 进阶】
- 小白也会 HTML 动态爱心-详细教程
- MyBatis笔记03------XXXMapper.xml文件解析
- 云小课|MRS基础原理之MapReduce介绍
- Redis系列8:Bitmap实现亿万级数据计算
- 浅入浅出 1.7和1.8的 HashMap
- 一 网络安全:信息收集之玩转nmap(理论篇)