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

相关推荐
泰山小张只吃荷园8 分钟前
期末Python复习-输入输出
java·前端·spring boot·python·spring cloud·docker·容器
悦涵仙子43 分钟前
vueuse中的useTemplateRefsList
前端·javascript·vue.js
萧萧玉树44 分钟前
分布式在线评测系统
前端·c++·后端·负载均衡
桃园码工1 小时前
第一章:Go 语言概述 2.安装和配置 Go 开发环境 --Go 语言轻松入门
开发语言·后端·golang
haima951 小时前
ubuntu安装chrome无法打开问题
前端·chrome
放逐者-保持本心,方可放逐1 小时前
XSS 与 CSRF 记录
前端·xss·csrf·浏览器安全
徊忆羽菲1 小时前
利用HTML5和CSS来实现一个漂亮的表格样式
前端·css·html5
不爱说话郭德纲1 小时前
Stylus、Less 和 Sass 的使用与区别
前端·css·面试·less·sass·stylus
凄凄迷人2 小时前
如何调试 chrome 崩溃日志(MAC)
前端·chrome·macos·crash
蒙特网站2 小时前
网站布局编辑器前端开发:设计要点与关键考量
前端·javascript·学习·html