Maps and Memory Leaks in Go
在使用 Go 中的 map 时,我们需要了解 map 如何增长和收缩的一些重要特性。让我们深入了解这一点,以防止可能导致内存泄漏的问题。
首先,为了查看这个问题的具体示例,让我们设计一个场景,我们将在其中使用以下的 map:
m := make(map[int][128]byte)其中 m 的每个值都是一个包含 128 字节的数组。我们将执行以下操作:
分配一个空的 map。
添加 100 万个元素。
删除所有元素,并运行垃圾回收(GC)。
在每个步骤之后,我们想打印堆的大小(使用 printAlloc 实用函数)。这可以展示这个例子在内存方面的行为:
func main() {
n := 1_000_000
m := make(map[int][128]byte)
printAlloc()
for i := 0; i < n; i++ { // 添加 100 万个元素
m[i] = [128]byte{}
}
printAlloc()
for i := 0; i < n; i++ { // 删除 100 万个元素
delete(m, i)
}
runtime.GC() // 手动触发 GC
printAlloc()
runtime.KeepAlive(m) // 保持对 m 的引用,以防 map 被收集
}
func printAlloc() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%d MB\n", m.Alloc/(1024*1024))
}我们分配了一个空的 map,添加了 100 万个元素,删除了 100 万个元素,然后运行了 GC。我们还确保使用 runtime.KeepAlive 保持对 map 的引用,以便 map 也不会被回收。让我们运行这个例子:
0 MB <-- 分配 m 之后
461 MB <-- 添加 100 万个元素之后
293 MB <-- 删除 100 万个元素之后我们可以观察到什么?最初,堆的大小很小。然后,在将 100 万个元素添加到 map 后,堆的大小显著增长。但是,如果我们期望在删除所有元素后堆的大小会减小,这不是 Go 中 map 的工作方式。最终,即使 GC 已经收集了所有元素,堆的大小仍然是 293 MB。因此,内存减少了,但并非我们可能期望的那样。这是为什么呢?我们需要深入了解 Go 中的 map 是如何工作的。
在 Go 中,map 提供了一个无序的键值对集合,其中所有键都是唯一的。在 Go 中,map 基于哈希表数据结构:一个数组,其中每个元素是指向键值对桶的指针,如图 1 所示。
图 1:哈希表示例,重点关注桶 0。
每个桶都是一个固定大小的数组,有八个元素。在向已满的桶(桶溢出)插入元素的情况下,Go 会创建另一个包含八个元素的桶,并将前一个桶链接到它上面。图 2 展示了一个示例:
图 2:在桶溢出的情况下,Go 分配一个新的桶,并将前一个桶链接到它上面。
在底层,Go 的 map 是一个指向 runtime.hmap 结构体的指针。该结构体包含多个字段,包括 B 字段,给出了 map 中桶的数量:
type hmap struct {
B uint8 // 桶的数量的 log_2
// (可以容纳 loadFactor * 2^B 个项)
// ...
}在添加了 100 万个元素后,B 的值等于 18,这意味着有 2¹⁸ = 262,144 个桶。当我们删除了 100 万个元素后,B 的值是多少呢?仍然是 18。因此,该 map 仍然包含相同数量的桶。
原因是 map 中的桶数不能减少。因此,从 map 中删除元素不会影响现有桶的数量;它只是将桶中的槽清零。一个 map 只能增长并拥有更多的桶;它永远不会收缩。
在上面的例子中,我们从 461 MB 减少到 293 MB,因为元素已经被收集,但运行 GC 并没有影响 map 本身。即使额外桶的数量(由于溢出而创建的桶)也保持不变。
让我们退后一步,讨论 map 不能收缩的事实何时可能成为问题。想象一下,使用 mapintbyte 构建一个缓存。这个 map 持有每个客户 ID(int)的 128 字节序列。现在,假设我们想保存最后 1,000 位客户的数据。map 的大小将保持不变,因此我们不必担心 map 不能收缩的事实。
然而,假设我们想要存储一小时的数据。同时,我们的公司决定在黑色星期五推出一个大促销:在一个小时内,我们可能有数百万与我们系统连接的客户。但在黑色星期五几天后,我们的 map 将包含与高峰时期相同数量的桶。这就解释了为什么在这种情况下我们可能会遇到内存消耗高,而在很大程度上不会减少的情况。
如果我们不想手动重新启动服务以清理 map 消耗的内存量,有什么解决方案呢?一个解决方案可能是定期重新创建当前 map 的副本。例如,每小时,我们可以构建一个新的 map,复制所有元素,并释放先前的 map。这个选项的主要缺点是,在复制之后直到下一次垃圾回收之前,我们可能会在短时间内消耗两倍的当前内存。
另一个解决方案是将 map 类型更改为存储数组指针:map[int]*[128]byte。虽然这并不能解决 map 拥有大量桶的问题;但是,每个桶条目将为值保留指针的大小,而不是 128 字节(在 64 位系统上为 8 字节,在 32 位系统上为 4 字节)。
回到原始场景,让我们比较每种 map 类型在每个步骤后的内存消耗。以下表格显示了比较。
| step | mapintbyte | map[int]*[128]byte |
|---|---|---|
| 分配一个空的map | 0 MB | 0 MB |
| 添加 100 万个元素 | 461 MB | 182 MB |
| 删除所有元素并运行GC | 293 MB | 38 MB |
注意:
如果键或值超过 128 字节,Go 将不会直接将其存储在 map 桶中。相反,Go 会存储一个指针来引用键或值。
正如我们所见,向 map 添加 n 个元素,然后删除所有元素意味着保持相同数量的桶在内存中。因此,我们必须记住,由于 Go map 只能增长,因此它的内存消耗也会增加。没有自动的策略来收缩它。如果这导致内存消耗过高,我们可以尝试不同的选项,如强制 Go 重新创建 map 或使用指针来检查是否可以进行优化。
当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »