前言
在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 分析
爲了知道map
在Go
中的具體實現, 我通過gdb
將m
的類型打印了出來, 這是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原理
如果你對Java
中HashMap
的實現有了解, 那麼這裏也一樣, 數組+鏈表來實現hash
表.
在內存的分佈上, map
大致是這樣的一個結構:
其中每個Bucket
都可以保存8個KV
, 數據在存放的時候, 會根據hash
函數的結果得出在Bucket 列表
的偏移量, 然後將值放到對應的位置.
overflow Bucket
是當Bucket
自身存放不下時, 與其組成鏈表來容納更多數據
至於Bucket
結構體爲什麼要將K/V
分開放, 在源碼中也給出解釋了, 如果將K/V
放到一起, 遇到map[int8]int64
這樣的, 就會遇到內存對齊的問題, 浪費一部分內存.
插入
插入操作通過調用mapassign函數, 其大體步驟如下:
- 使用hash 值定位元素在哪一個 Bucket 中
- 遍歷 Bucket 中的元素, 找到第一個空位, 將數據插入
如何處理 hash 衝突
殊途同歸, Go
中已然是用鏈表來解決hash
衝突的.
我們不是通過 hash 來確定了元素存放在哪一個bucket
中嘛. 其實, 每一個Bucket
就是一個鏈表. 它的extraBucket
字段用來鏈接到鏈表的下一個元素. 只不過這個鏈表中, 每個元素都可以存放8個K/V
.
如何快速找到空位
遍歷Bucket
內容時, 爲了快速定位, 加了一個小小的緩存, 會將key
的hash
值高8位存起來, 用於快速比較是否相等.
擴容機制
Go
中, map
每次擴容都會將原來的容量乘2, 也是有一個指數因子來判斷是否需要擴容. 大差不差.
查看和修改的操作, 在這裏就不贅述了, 按照插入的流程尋找元素即可.
刪除
map
元素刪除的操作十分簡單, 可以看下源碼實現. 簡單說來就是:
- 找到元素
- 將
key/value
的內容清空 - 將長度
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]byte
或map[int]*[128]byte
, 再次運行就會發現, GC
後內存佔用明顯下降了. 更換指針很容易理解, 增大value
的內存佔用, 也會讓Go
在編譯期將其轉爲指針類型.
解決
到這裏, 我們知道了map
自身的內存佔用只增不減, 也知道了爲什麼會出現這個問題.
那麼, 如何解決呢? 如果不進行解決, 在某一個流量高峯期, map
中保存了大量的數據, 後面流量降下來了, 但是map
的內存佔用會居高不下.
我簡單想了幾種方案:
- 定期備份. 每個一段時間, 將整個
map
拷貝一份到新的map
中 value
使用指針類型, 這樣map
中保留的內存僅爲指針所佔空間, 與value
大小無關. 而value
的對象是會被GC
回收的. 我簡單測試了下,map[int]*[128]byte
類型的map
, 100w 元素, 全部刪除後GC
, 內存佔用(map
自身)僅爲38M
.
當然了, 肯定還有很多花裏胡哨的解決方案, 比如使用多個小map
等等, 但這2種方案應該已經能夠解決日常開發的問題了.