Go map 內存泄露

前言

Go中, map這個結構使用的頻率還是比較高的. 其實在所有的語言中, map使用的頻率都是很高的.

之前在使用中, 一直都知道map的內存在元素刪除的時候不會回收, 但一直沒有仔細的研究爲什麼. 今天就來好好揣摩揣摩.

func main() {
	m := make(map[int][128]byte)
	for i := 0; i < 100000; i++ {
		b := [128]byte{}
		for j := 0; j < 128; j++ {
			b[j] = byte(j + 1)
		}
		m[i] = b
	}
	// 打印堆內存
	var ms runtime.MemStats
	runtime.ReadMemStats(&ms)
	fmt.Printf("堆內存: %d B, map size: %d\n", ms.Alloc, len(m))
	// 釋放 map
	for i := 0; i < 100000; i++ {
		delete(m, i)
	}
	runtime.ReadMemStats(&ms)
	fmt.Printf("堆內存(釋放後): %d B, map size: %d\n", ms.Alloc, len(m))
	// 手動觸發 GC
	runtime.GC()
	runtime.ReadMemStats(&ms)
	fmt.Printf("堆內存(GC): %d B, map size: %d\n", ms.Alloc, len(m))
	// 保存 map 的引用,防止 GC 回收
	runtime.KeepAlive(m)
}

老規矩, 還是先來說說是個什麼現象(本文所有例子, 基於 Go1.18)). 如果你運行這個程序, 那麼會得到這樣的結果:

堆內存: 32197640 B, map size: 100000
堆內存(釋放後): 32198752 B, map size: 0
堆內存(GC): 21113120 B, map size: 0

可以看到, 再將map內容清空後, 運行GC, 內存佔用仍高達20M. 而這個現象, 就是在前面提到過的, Go中的map內存佔用只會增加不會減少.

探究

gdb 分析

爲了知道mapGo中的具體實現, 我通過gdbm的類型打印了出來, 這是ptype m的結果:

type = struct hash<int,[128]uint8> {
int count;
uint8 flags;
uint8 B;
uint16 noverflow;
uint32 hash0;
bucket<int,[128]uint8> *buckets;
bucket<int,[128]uint8> *oldbuckets;
uintptr nevacuate;
runtime.mapextra *extra;
}

hash結構體明顯是編輯器編譯過後的, 爲了方便, 我直接在源碼中通過字段名搜索, 果然找到了字段一模一樣的結構體hmap, 此結構體位於runtime/map.go文件中.

再使用print *m命令查看不同階段結構體中的內容:

初始化之前:

{count = 0, flags = 0 '\000', B = 0 '\000', noverflow = 0, hash0 = 2403831944, buckets = 0xc00013e380, oldbuckets = 0x0, nevacuate = 0, extra = 0x0}

初始化之後:

{count = 100000, flags = 0 '\000', B = 14 '\016', noverflow = 2679, hash0 = 4095224677, buckets = 0xc001540000, oldbuckets = 0x0, nevacuate = 8192, extra = 0xc00012c018}

delete 所有內容之後:

{count = 0, flags = 0 '\000', B = 14 '\016', noverflow = 2679, hash0 = 112371461, buckets = 0xc001540000, oldbuckets = 0x0, nevacuate = 8192, extra = 0xc00012c018}

GC 之後:

可以看到, 刪除所有內容之後, 只有count的值發生了變化. 分析到這裏, 就必須要看一下map是如果實現的了,

map原理

如果你對JavaHashMap的實現有了解, 那麼這裏也一樣, 數組+鏈表來實現hash表.

在內存的分佈上, map大致是這樣的一個結構:

image-20230325162200036

其中每個Bucket都可以保存8個KV, 數據在存放的時候, 會根據hash函數的結果得出在Bucket 列表的偏移量, 然後將值放到對應的位置.

overflow Bucket是當Bucket自身存放不下時, 與其組成鏈表來容納更多數據

至於Bucket結構體爲什麼要將K/V分開放, 在源碼中也給出解釋了, 如果將K/V放到一起, 遇到map[int8]int64這樣的, 就會遇到內存對齊的問題, 浪費一部分內存.

插入

插入操作通過調用mapassign函數, 其大體步驟如下:

  1. 使用hash 值定位元素在哪一個 Bucket 中
  2. 遍歷 Bucket 中的元素, 找到第一個空位, 將數據插入

如何處理 hash 衝突

殊途同歸, Go中已然是用鏈表來解決hash衝突的.

我們不是通過 hash 來確定了元素存放在哪一個bucket中嘛. 其實, 每一個Bucket就是一個鏈表. 它的extraBucket字段用來鏈接到鏈表的下一個元素. 只不過這個鏈表中, 每個元素都可以存放8個K/V.

如何快速找到空位

遍歷Bucket內容時, 爲了快速定位, 加了一個小小的緩存, 會將keyhash值高8位存起來, 用於快速比較是否相等.

擴容機制

Go中, map每次擴容都會將原來的容量乘2, 也是有一個指數因子來判斷是否需要擴容. 大差不差.

查看修改的操作, 在這裏就不贅述了, 按照插入的流程尋找元素即可.

刪除

map元素刪除的操作十分簡單, 可以看下源碼實現. 簡單說來就是:

  1. 找到元素
  2. key/value的內容清空
  3. 將長度count減1

結構體

簡單介紹下hmap各個字段的含義:

type hmap struct {
	count     int // 當前 map 中元素個數, len 函數用的就是它
	flags     uint8 // 標誌位
	B         uint8  // 指數, 標識當前桶的個數爲 2^B
	noverflow uint16 // 溢出桶的大致數目
	hash0     uint32 // 隨機種子

	buckets    unsafe.Pointer // Bucket 數組指針
	oldbuckets unsafe.Pointer // 數組擴容時遷移過程中指向就地址的
	nevacuate  uintptr        // progress counter for evacuation (buckets less than this have been evacuated)

	extra *mapextra // 用來組成 Bucket 鏈表的內容
}
type mapextra struct {
	overflow    *[]*bmap // 指向溢出Bucket的地址
	oldoverflow *[]*bmap // 同上, 遷移過程使用
	nextOverflow *bmap // 指向第一個空閒的 Bucket, 追加時可快速獲取到
}

解惑

現在對Go中的map有了一定的瞭解了, 再回來看最開始的問題, 爲什麼內存沒有被回收呢? 很簡單, 刪除元素的時候, 僅僅是將key/value內容置空, 但map佔用的內存仍然沒有釋放. 刪除後再向map中添加數據, 是可以使用已經清空內存的.

也就是說, 在將數據從map中刪除的時候, 僅僅是map自身的內存沒有被回收, value中存放的如果是一個結構體, 那麼是不影響結構體本身GC的.

爲了驗證這個猜想, 你可以將最開始例子中的map換成map[int][1024]bytemap[int]*[128]byte, 再次運行就會發現, GC後內存佔用明顯下降了. 更換指針很容易理解, 增大value的內存佔用, 也會讓Go在編譯期將其轉爲指針類型.

解決

到這裏, 我們知道了map自身的內存佔用只增不減, 也知道了爲什麼會出現這個問題.

那麼, 如何解決呢? 如果不進行解決, 在某一個流量高峯期, map中保存了大量的數據, 後面流量降下來了, 但是map的內存佔用會居高不下.

我簡單想了幾種方案:

  1. 定期備份. 每個一段時間, 將整個map拷貝一份到新的map
  2. value使用指針類型, 這樣map中保留的內存僅爲指針所佔空間, 與value大小無關. 而value的對象是會被GC回收的. 我簡單測試了下, map[int]*[128]byte類型的map, 100w 元素, 全部刪除後GC, 內存佔用(map自身)僅爲38M.

當然了, 肯定還有很多花裏胡哨的解決方案, 比如使用多個小map等等, 但這2種方案應該已經能夠解決日常開發的問題了.


原文地址: https://hujingnb.com/archives/894

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章