大家好,我是程序员幽鬼。分享一篇关于 map 和“内存泄露”的文章。

摘要:map 总是可以在内存中增长;它从不收缩。因此,如果它导致一些内存问题,你可以尝试不同的选项,例如强制 Go 重新创建 map 或使用指针。

在 Go 中使用 map 时,我们需要了解 map 如何增长和收缩的一些重要特征。让我们深入研究一下,以防止可能导致内存泄漏的问题。

首先,要查看此问题的具体示例,让我们设计一个场景,其中我们将使用以下 map:

每个m值都是一个 128 字节的数组。我们将执行以下操作:

  • 分配一个空的 map。
  • 添加 100 万个元素。
  • 删除所有元素,并运行垃圾收集(GC)。

在每一步之后,我们都要打印堆的大小(使用printAlloc实用程序函数)。它向我们展示了此示例的内存行为:

我们分配一个空 map,添加 100 万个元素,删除 100 万个元素,然后运行 GC。我们还确保使用 [1] 保持对 map 的引用,这样 map 就不会被垃圾收集了。让我们运行这个示例:

我们能观察到什么?首先,堆大小是最小的。然后,在向 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 中的存储桶数:

添加 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 类型的内存消耗。下表显示了比较。

​map[int][128]byte​​map[int]*[128]byte​

正如我们所看到的,删除所有元素后,使用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