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定义为指针。

相关推荐
苏武难飞几秒前
分享一个THREE.JS中无限滚动的技巧
前端·javascript·css
liangblog3 分钟前
Spring Boot中手动实例化 `JdbcTemplate` 并指定 数据源
java·spring boot·后端
bitbrowser3 分钟前
2026 PC端多Chrome账号管理指南:从日常切换到防关联实战
前端·chrome
羊小猪~~8 分钟前
算法/力扣--栈与队列经典题目
开发语言·c++·后端·考研·算法·leetcode·职场和发展
人间打气筒(Ada)10 分钟前
go:如何实现接口限流和降级?
开发语言·中间件·go·限流·etcd·配置中心·降级
小陈工11 分钟前
Python Web开发入门(二):Flask vs Django,项目结构大比拼
前端·数据库·python·安全·web安全·django·flask
橘子编程11 分钟前
HTML5 权威指南:从入门到精通
前端·css·vue.js·html·html5
不超限13 分钟前
InfoSuite AS部署Vue项目
前端·javascript·vue.js
程序员小寒14 分钟前
JavaScript设计模式(五):装饰者模式实现与应用
前端·javascript·设计模式
wefly201718 分钟前
零基础上手m3u8live.cn,免费无广告的M3U8在线播放器,电脑手机通用
前端·javascript·学习·电脑·m3u8·m3u8在线播放