Go:Map 和 內(nèi)存泄露
大家好,我是程序員幽鬼。分享一篇關(guān)于 map 和“內(nèi)存泄露”的文章。
摘要:map 總是可以在內(nèi)存中增長(zhǎng);它從不收縮。因此,如果它導(dǎo)致一些內(nèi)存問(wèn)題,你可以嘗試不同的選項(xiàng),例如強(qiáng)制 Go 重新創(chuàng)建 map 或使用指針。
在 Go 中使用 map 時(shí),我們需要了解 map 如何增長(zhǎng)和收縮的一些重要特征。讓我們深入研究一下,以防止可能導(dǎo)致內(nèi)存泄漏的問(wèn)題。
首先,要查看此問(wèn)題的具體示例,讓我們?cè)O(shè)計(jì)一個(gè)場(chǎng)景,其中我們將使用以下 map:
每個(gè)m值都是一個(gè) 128 字節(jié)的數(shù)組。我們將執(zhí)行以下操作:
- 分配一個(gè)空的 map。
- 添加 100 萬(wàn)個(gè)元素。
- 刪除所有元素,并運(yùn)行垃圾收集(GC)。
在每一步之后,我們都要打印堆的大?。ㄊ褂胮rintAlloc實(shí)用程序函數(shù))。它向我們展示了此示例的內(nèi)存行為:
() {
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)
}
我們分配一個(gè)空 map,添加 100 萬(wàn)個(gè)元素,刪除 100 萬(wàn)個(gè)元素,然后運(yùn)行 GC。我們還確保使用 [1] 保持對(duì) map 的引用,這樣 map 就不會(huì)被垃圾收集了。讓我們運(yùn)行這個(gè)示例:
0 MB <-- m 被分配后
461 MB <-- 我們?cè)黾?100 萬(wàn)個(gè)元素后
293 MB <-- 我們刪除 100 萬(wàn)個(gè)元素后
我們能觀察到什么?首先,堆大小是最小的。然后,在向 map 添加了100萬(wàn)個(gè)元素后,它會(huì)顯著增長(zhǎng)。但是,如果我們預(yù)期在刪除所有元素后堆大小會(huì)減小,那么這并不是 map 在Go中的工作方式。最后,即使 GC 已經(jīng)收集了所有元素,堆大小仍然是 293 MB。因此,內(nèi)存雖然縮小了,但并不像我們預(yù)期的那樣。原因是什么?我們需要深入研究 map 在Go中的工作原理。
map 提供了鍵-值對(duì)的無(wú)序集合,其中所有鍵都是不同的。在 Go 中,map 基于哈希表數(shù)據(jù)結(jié)構(gòu):一個(gè)數(shù)組,其中每個(gè)元素都是指向一個(gè)鍵值對(duì)桶的指針,如圖 1 所示。
圖1— 一個(gè)哈希表示例,重點(diǎn)放在 bucket 0 上。
每個(gè) bucket 是一個(gè)由八個(gè)元素組成的固定大小數(shù)組。如果插入到已經(jīng)滿了的 bucket 中(bucket 溢出),Go 會(huì)創(chuàng)建另一個(gè)包含八個(gè)元素的 buckets,并將前一個(gè)元素鏈接到該bucket。圖 2 顯示了一個(gè)示例:
圖 2 — 如果 bucket 溢出,Go 會(huì)分配一個(gè)新的 bucket,并將前一個(gè) bucket 鏈接到該bucket。
在底層,Go map 是指向 [2] 結(jié)構(gòu)體的指針。此結(jié)構(gòu)體包含多個(gè)字段,包括一個(gè) B 字段,用于提供 map 中的存儲(chǔ)桶數(shù):
hmap {
B // log_2 of # of buckets
// (can hold up to loadFactor * 2^B items)
// ...
}
添加 100 萬(wàn)個(gè)元素后,B 的值等于 18,即 21? = 262144 個(gè)桶。當(dāng)我們移除 100 萬(wàn)個(gè)元素時(shí),B 的值是多少?仍然是18。因此,map 仍然包含相同數(shù)量的桶。
原因是 map 中的存儲(chǔ)桶數(shù)無(wú)法縮減。因此,從 map 中刪除元素不會(huì)影響現(xiàn)有存儲(chǔ)桶的數(shù)量;它只是將桶中的槽清零。map 只能增長(zhǎng)并有更多的桶;它從不收縮。
在前一個(gè)示例中,我們從 461MB 到 293MB,因?yàn)槔占嗽兀\(yùn)行 GC 不會(huì)影響 map 本身。即使額外的存儲(chǔ)桶數(shù)(由于溢出而創(chuàng)建的存儲(chǔ)桶)也保持不變。
讓我們后退一步,討論 map 不能收縮的事實(shí)何時(shí)會(huì)成為問(wèn)題。想象一下,使用 map[int][128]byte 構(gòu)建緩存。此 map 包含每個(gè)客戶 ID(int 類型),一個(gè) 128 字節(jié)的序列?,F(xiàn)在,假設(shè)我們想保留最后 1000 名客戶。map 大小將保持不變,因此我們不必?fù)?dān)心 map 無(wú)法收縮的事實(shí)。
然而,假設(shè)我們要存儲(chǔ)一小時(shí)的數(shù)據(jù)。與此同時(shí),我們公司決定對(duì)黑色星期五進(jìn)行一次大促銷:一個(gè)小時(shí)后,我們的系統(tǒng)可能會(huì)連接到數(shù)百萬(wàn)客戶。但在黑色星期五之后的幾天,我們的 map 將包含與高峰時(shí)段相同數(shù)量的桶。這就解釋了為什么在這種情況下,我們可以體驗(yàn)到內(nèi)存消耗很高,但沒(méi)有顯著減少。
如果我們不想手動(dòng)重啟服務(wù)來(lái)清理 map 消耗的內(nèi)存量,有什么解決方案?一種解決方案可以是定期重新創(chuàng)建當(dāng)前 map 的副本。例如,每小時(shí),我們可以構(gòu)建一個(gè)新 map,復(fù)制所有元素,然后發(fā)布上一個(gè)元素。此選項(xiàng)的主要缺點(diǎn)是,在復(fù)制之后直到下一次垃圾回收之前,我們可能會(huì)在短時(shí)間內(nèi)消耗兩倍于當(dāng)前內(nèi)存的內(nèi)存。
另一個(gè)解決方案是更改 map 類型以存儲(chǔ)數(shù)組指針:map[int]*[128]byte。這并不能解決我們將擁有大量桶的事實(shí);然而,每個(gè) bucket 條目將為該值保留指針的大小,而不是 128 字節(jié)(64 位系統(tǒng)為 8 字節(jié),32 位系統(tǒng)為 4 字節(jié))。
回到最初的場(chǎng)景,讓我們?cè)诿總€(gè)步驟之后比較每個(gè) map 類型的內(nèi)存消耗。下表顯示了比較。
Step | ? | ? |
分配一個(gè)空 map | 0 MB | 0 MB |
增加 100 萬(wàn)個(gè)元素 | 461 MB | 182 MB |
移除所有元素同時(shí)運(yùn)行 GC | 293 MB | 38 MB |
正如我們所看到的,刪除所有元素后,使用map[int]*[128]byte類型所需的內(nèi)存量明顯減少。此外,在這種情況下,由于進(jìn)行了一些優(yōu)化以減少所消耗的內(nèi)存,在峰值時(shí)間內(nèi)所需的內(nèi)存量不太重要。
注意: 如果一個(gè)鍵或值超過(guò) 128 字節(jié),Go 不會(huì)將其直接存儲(chǔ)在 map bucket 中。相反,Go 存儲(chǔ)一個(gè)指針來(lái)引用鍵或值。
結(jié)論
正如我們所看到的,將 n 個(gè)元素添加到 map 中,然后刪除所有元素意味著在內(nèi)存中保留相同數(shù)量的 bucket。因此,我們必須記住,因?yàn)?Go map 只能增加大小,所以它的內(nèi)存消耗也會(huì)增加。沒(méi)有自動(dòng)化的策略來(lái)縮小它。如果這導(dǎo)致高內(nèi)存消耗,我們可以嘗試不同的選項(xiàng),例如強(qiáng)制 Go 重新創(chuàng)建 map 或使用指針檢查是否可以優(yōu)化。
原文鏈接: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