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

相关推荐
Мартин.2 分钟前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss
一 乐1 小时前
学籍管理平台|在线学籍管理平台系统|基于Springboot+VUE的在线学籍管理平台系统设计与实现(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·学习
昨天;明天。今天。1 小时前
案例-表白墙简单实现
前端·javascript·css
数云界1 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd2 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常2 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
ChinaDragonDreamer2 小时前
Vite:为什么选 Vite
前端
小御姐@stella2 小时前
Vue 之组件插槽Slot用法(组件间通信一种方式)
前端·javascript·vue.js
GISer_Jing2 小时前
【React】增量传输与渲染
前端·javascript·面试
eHackyd2 小时前
前端知识汇总(持续更新)
前端