Go:Map 竟然会内存泄露?!
2022-10-28 18:12:38 Author: mp.weixin.qq.com(查看原文) 阅读量:16 收藏

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

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

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

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

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

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

func main() {
    n := 1_000_000
    m := make(map[int][128]byte)
    printAlloc()

    for i := 0; i < n; i++ { // Adds 1 million elements
        m[i] = [128]byte{}
    }
    printAlloc()

    for 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
}

func printAlloc() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("%d KB\n", m.Alloc/1024)
}

我们分配一个空 map,添加 100 万个元素,删除 100 万个元素,然后运行 GC。我们还确保使用 `runtime.KeepAlive`[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 是指向 `runtime.hmap`[2] 结构体的指针。此结构体包含多个字段,包括一个 B 字段,用于提供 map 中的存储桶数:

type hmap struct {
    B uint8 // 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 类型的内存消耗。下表显示了比较。

Stepmap[int][128]bytemap[int]*[128]byte
分配一个空 map0 MB0 MB
增加 100 万个元素461 MB182 MB
移除所有元素同时运行 GC293 MB38 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

我是 polarisxu,北大硕士毕业,曾在 360 等知名互联网公司工作,10多年技术研发与架构经验!2012 年接触 Go 语言并创建了 Go 语言中文网!著有《Go语言编程之旅》、开源图书《Go语言标准库》等。


文章来源: https://mp.weixin.qq.com/s?__biz=MzAxNzY0NDE3NA==&mid=2247490401&idx=1&sn=c9927a10b35b8f66f11f44fd09aa0842&chksm=9be33480ac94bd96b269c89f50b4ceeda2a2237d70502a25fe7e363e1489aab29be19d23ed52#rd
如有侵权请联系:admin#unsafe.sh