Go语言中常见100问题-#28 map引发的内存泄露

前言

在使用map时,我们需要了解它的扩容和缩容特性,以防止使用不当导致内存泄露问题。

案例引入

下面通过一个具体的例子说明,该例创建了一个key为int,value为[128]byte的map m. 然后向m中添加100百万个元素,最后再删除所有的元素并运行GC.

golang 复制代码
m := make(map[int][128]byte)

在对m的每次操作之后,通过printAlloc打印内存使用情况,完整代码如下。

golang 复制代码
func main() {
    n := 1_000_000
    m := make(map[int][128]byte)
    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)
}

func randBytes() [128]byte {
    return [128]byte{}
}

func printAlloc() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("%d MB\n", m.Alloc/1024/1024)
}

上述程序的输出结果如下,创建一个空的m占用的内存为0MB,向里面添加100百万个元素后占用的内存达到最大为461MB,删除m中的全部元素并执行GC操作后还占用293MB内存,为啥不是0MB呢?

console 复制代码
0 MB
461 MB
293 MB

原因分析

Go语言中常见100问题-#27 map初始化方法及最佳实践中分析了golang中map是由8个元素的hash桶构成,在内部实现上,map是一个指针类型,指向 runtime.hmap结构体,该结构体包含多个字段,例如字段B,标识map中有多少个桶。

golang 复制代码
type hmap struct {
    B uint8 // log_2 of # of buckets
    // (can hold up to loadFactor * 2^B items)
    // ...
}

添加100百万个元素后,B的值为18,因为2^18=262144. 当删除这100百万个元素后,B值仍然是18,因此删除前后map拥有的buckets数量不变。这正是删除操作之后map占用内存没有显著下降的原因。删除元素只是将bucket中每个slot中的元素设置为零值。所以说map中的buckets只会增加不会减少。

前面程序占用内存从461M减少到293M的原因是map中的value被回收(即value中的128byte),map中的buckets并没有减少。map m在内存中的效果如下所示。

解决方法

现在回过头看看在什么情况下map的这种不能缩容机制会产生问题。假设创建一个缓存,用map[int][128]byte记录缓存内容。map的key为用户ID,value为128字节的数组。现在想要保存最后的1000个用户,因为这个大小是固定的,并不担心map不能缩容问题。但是,如果我们想保存1个小时的用户数据,与此同时,公司想在黑色星期五搞一个大促销,可能有几百万的用户进入我们的系统, 在黑色星期五之后,缓存map中的buckets仍然为峰值的数量不会下降,这会导致内存飙升后不会显著下降。

如何解决上述内存飙升不下降的问题呢?最挫方法是重启服务,不过这种方法在生产环境中是不可取的。可行的方法主要有两种:

一种方法是在合适的时候对当前的m进行拷贝,新建一个map,释放掉旧map. 例如,我们可以每隔一个小时创建一个新的map, 将旧map中的数据拷贝到新map中,释放掉旧map,这种方法的缺点是在在进行下一次垃圾回收之前,可能会占用当前两倍的内存,因数据在新旧map中都存在。

另一种方法是将map的value改为指针类型: map[int]*[128]byte, 当然这种方法不能解决buckets数量保持不变的问题,但是可以减少value占用的内存,因为现在value是一个指针类型,在64位系统上占用8字节,在32位系统上占用4字节,相比前面占用128字节,还是能够节省不少内存。

实验验证

下表是采用指针和不用指针,执行相同操作,各个阶段占用内存情况。对比发现,将value定义为指针类型,当删除map中所有元素后,内存占用会小很多,只有38M。

| step |map[int][128]byte |map[int]*[128]byte |

| --- | --- | --- |

| 初始化空map | 0MB | 0MB |

| 向map中添加100万个元素| 461MB | 182MB |

| 删除map中的所有元素并执行GC | 293MB | 38MB |

map的key或value超过128字节后,并不会直接将值存在bucket中,而是存储它们key或value的指针

思考总结

通过前面的程序可以看到,向map中添加n个元素然后再删除全部元素后,map中的buckets数量会保持不变。要牢记go语言中map的大小只会增加,这可能导致大内存消耗,因为没有自动缩容机制,占用的大量内存不会释放。优化方法可以采用上面创建新map或将value定义为指针。

相关推荐
索然无味io18 分钟前
XML外部实体注入--漏洞利用
xml·前端·笔记·学习·web安全·网络安全·php
ThomasChan12335 分钟前
Typescript 多个泛型参数详细解读
前端·javascript·vue.js·typescript·vue·reactjs·js
马剑威(威哥爱编程)1 小时前
2025春招 SpringCloud 面试题汇总
后端·spring·spring cloud
爱学习的狮王1 小时前
ubuntu18.04安装nvm管理本机node和npm
前端·npm·node.js·nvm
东锋1.31 小时前
使用 F12 查看 Network 及数据格式
前端
zhanggongzichu1 小时前
npm常用命令
前端·npm·node.js
anyup_前端梦工厂1 小时前
从浏览器层面看前端性能:了解 Chrome 组件、多进程与多线程
前端·chrome
chengpei1471 小时前
chrome游览器JSON Formatter插件无效问题排查,FastJsonHttpMessageConverter导致Content-Type返回不正确
java·前端·chrome·spring boot·json
Quantum&Coder1 小时前
Objective-C语言的计算机基础
开发语言·后端·golang
我命由我123451 小时前
NPM 与 Node.js 版本兼容问题:npm warn cli npm does not support Node.js
前端·javascript·前端框架·npm·node.js·html5·js