大家好,我是程序员幽鬼。分享一篇关于 map 和“内存泄露”的文章。
摘要:map 总是可以在内存中增长;它从不收缩。因此,如果它导致一些内存问题,你可以尝试不同的选项,例如强制 Go 重新创建 map 或使用指针。
在 Go 中使用 map 时,我们需要了解 map 如何增长和收缩的一些重要特征。让我们深入研究一下,以防止可能导致内存泄漏的问题。
首先,要查看此问题的具体示例,让我们设计一个场景,其中我们将使用以下 map:
每个m值都是一个 128 字节的数组。我们将执行以下操作:
- 分配一个空的 map。
- 添加 100 万个元素。
- 删除所有元素,并运行垃圾收集(GC)。
在每一步之后,我们都要打印堆的大小(使用printAlloc实用程序函数)。它向我们展示了此示例的内存行为:
() {
n := 1_000_000
m := make([][128])
printAlloc()
i := 0; i < n; i++ { // Adds 1 million elements
m[i] = [128]{}
}
printAlloc()
i := 0; i < n; i++ { // Deletes 1 million elements
delete(m, i)
}
runtime.GC() // Triggers a manual GC
printAlloc()
runtime.KeepAlive(m) // Keeps a reference to m so that the map isn’t collected
}
() {
m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%d KB\n", m.Alloc/1024)
}
我们分配一个空 map,添加 100 万个元素,删除 100 万个元素,然后运行 GC。我们还确保使用 [1] 保持对 map 的引用,这样 map 就不会被垃圾收集了。让我们运行这个示例:
0 MB <-- m 被分配后
461 MB <-- 我们增加 100 万个元素后
293 MB <-- 我们删除 100 万个元素后
我们能观察到什么?首先,堆大小是最小的。然后,在向 map 添加了100万个元素后,它会显著增长。但是,如果我们预期在删除所有元素后堆大小会减小,那么这并不是 map 在Go中的工作方式。最后,即使 GC 已经收集了所有元素,堆大小仍然是 293 MB。因此,内存虽然缩小了,但并不像我们预期的那样。原因是什么?我们需要深入研究 map 在Go中的工作原理。
map 提供了键-值对的无序集合,其中所有键都是不同的。在 Go 中,map 基于哈希表数据结构:一个数组,其中每个元素都是指向一个键值对桶的指针,如图 1 所示。
图1— 一个哈希表示例,重点放在 bucket 0 上。
每个 bucket 是一个由八个元素组成的固定大小数组。如果插入到已经满了的 bucket 中(bucket 溢出),Go 会创建另一个包含八个元素的 buckets,并将前一个元素链接到该bucket。图 2 显示了一个示例:
图 2 — 如果 bucket 溢出,Go 会分配一个新的 bucket,并将前一个 bucket 链接到该bucket。
在底层,Go map 是指向 [2] 结构体的指针。此结构体包含多个字段,包括一个 B 字段,用于提供 map 中的存储桶数:
hmap {
B // log_2 of # of buckets
// (can hold up to loadFactor * 2^B items)
// ...
}
添加 100 万个元素后,B 的值等于 18,即 2¹⁸ = 262144 个桶。当我们移除 100 万个元素时,B 的值是多少?仍然是18。因此,map 仍然包含相同数量的桶。
原因是 map 中的存储桶数无法缩减。因此,从 map 中删除元素不会影响现有存储桶的数量;它只是将桶中的槽清零。map 只能增长并有更多的桶;它从不收缩。
在前一个示例中,我们从 461MB 到 293MB,因为垃圾收集了元素,但运行 GC 不会影响 map 本身。即使额外的存储桶数(由于溢出而创建的存储桶)也保持不变。
让我们后退一步,讨论 map 不能收缩的事实何时会成为问题。想象一下,使用 map[int][128]byte 构建缓存。此 map 包含每个客户 ID(int 类型),一个 128 字节的序列。现在,假设我们想保留最后 1000 名客户。map 大小将保持不变,因此我们不必担心 map 无法收缩的事实。
然而,假设我们要存储一小时的数据。与此同时,我们公司决定对黑色星期五进行一次大促销:一个小时后,我们的系统可能会连接到数百万客户。但在黑色星期五之后的几天,我们的 map 将包含与高峰时段相同数量的桶。这就解释了为什么在这种情况下,我们可以体验到内存消耗很高,但没有显著减少。
如果我们不想手动重启服务来清理 map 消耗的内存量,有什么解决方案?一种解决方案可以是定期重新创建当前 map 的副本。例如,每小时,我们可以构建一个新 map,复制所有元素,然后发布上一个元素。此选项的主要缺点是,在复制之后直到下一次垃圾回收之前,我们可能会在短时间内消耗两倍于当前内存的内存。
另一个解决方案是更改 map 类型以存储数组指针:map[int]*[128]byte。这并不能解决我们将拥有大量桶的事实;然而,每个 bucket 条目将为该值保留指针的大小,而不是 128 字节(64 位系统为 8 字节,32 位系统为 4 字节)。
回到最初的场景,让我们在每个步骤之后比较每个 map 类型的内存消耗。下表显示了比较。
Step | | |
分配一个空 map | 0 MB | 0 MB |
增加 100 万个元素 | 461 MB | 182 MB |
移除所有元素同时运行 GC | 293 MB | 38 MB |
正如我们所看到的,删除所有元素后,使用map[int]*[128]byte类型所需的内存量明显减少。此外,在这种情况下,由于进行了一些优化以减少所消耗的内存,在峰值时间内所需的内存量不太重要。
注意: 如果一个键或值超过 128 字节,Go 不会将其直接存储在 map bucket 中。相反,Go 存储一个指针来引用键或值。
结论
正如我们所看到的,将 n 个元素添加到 map 中,然后删除所有元素意味着在内存中保留相同数量的 bucket。因此,我们必须记住,因为 Go map 只能增加大小,所以它的内存消耗也会增加。没有自动化的策略来缩小它。如果这导致高内存消耗,我们可以尝试不同的选项,例如强制 Go 重新创建 map 或使用指针检查是否可以优化。
原文链接:https://teivah.medium.com/maps-and-memory-leaks-in-go-a85ebe6e7e69。
参考资料
[1]runtime.KeepAlive: https://pkg.go.dev/runtime#KeepAlive
[2]runtime.hmap: https://github.com/golang/go/blob/f983a9340d5660a9655b63a371966b5df69be8c5/src/runtime/map.go#L116