前言
在使用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定义为指针。