Golang 中map與GC“糾纏不清”的關係
Map是什麼?
map的定義:是能夠在時間複雜度爲O(1)的情況下,對目標數據進行添加、刪除、查詢的一種哈希表,採用的格式爲Key-Value存儲,每一個value都對應的有一個key
GC是什麼?
GC定義
GC 是一種垃圾收集器,目標對象是用戶程序在運行過程中的堆,也就是heap,旨在清楚程序運行完之後產生的垃圾,釋放內存以便循環利用
GC中的垃圾怎麼定義
一般情況來說內存分爲堆和棧。棧的話是由操作系統和編譯器來進行垃圾回收(一般棧出問題了都是比較嚴重的事故),堆的話則是用程序來進行動態分配的,這裏的“垃圾“主要指的就是在棧中、被分配的、且在函數執行的週期中沒有再被引用的對象。
Map需要被GC回收嗎?
不一定,參考go 1.5的解釋
After go version 1.5, if you use a map without pointers in keys and values, the GC will omit its content.
指針類型的數據結構包括啥? string類型,結構體類型,或者任何基本類型+指針的定義(*int, *float),甚至可以是返回類型爲指針的function
原理猜想:可能的原因是非指針的類型,例如int32,uint32,不會被分配到堆上,從而避免了被GC掃到。
什麼場景下會需要Map避開GC?
這個問題等價於GC在什麼場景下會比較影響效率,通常情況下,GC是對棧進行操作,並且在進行GC的時候會停掉整個程序的運行(但是在Go 1.5之後有concurrent GC,這裏不做討論),那麼實際上影響整個程序運行效率的實際上就是GC運行的時長,而GC運行的時長主要與程序動態分配的棧內存有關,所以可以推測:在申請了大量的棧內存的場景,例如設計百萬數量,甚至千萬數量級別的內存緩存中,我們需要考慮GC回收所佔用的延時。
究竟影響了多少指標?
讓我們做個實驗,探究在不同數據量+使用不同KV存儲類型的map的變量條件下,GC的時間長短。
數據量:
三個水位:十,一百,一千萬
map的kv類型有
var demo1 = make(map[int]*int)// KV類型->對應有指針的map
var demo2 = make(map[int]int) // KV類型->對應無指針的map
type demoWithOneE = struct {
E int
}
type demoWithTwoE = struct {
E int
E1 int
}
type demoWithThreeE = struct {
E int
E1 int
E2 int
}
相關測試邏輯:
從1到N(N=10,100,1000000)分別給map賦值,賦值完成之後進行runtime.GC(),並記錄GC的時間
// TestForPointerMap 測試指針map
func main() {
TestForPointerMapBenchMark()
TestForNonPointerMapBenchMark()
}
// TestForStructPointerMapBenchMark 測試結構體指針map
func TestForStructPointerMapBenchMark(wg *sync.WaitGroup) {
defer wg.Done()
runtime.GC()
var demo1 = make(map[int]*demo) // KV類型->對應有指針的map
for i := 0; i < N; i++ {
v := i
... // 這裏分別初始化三種不同的結構體,每種結構體的成員變量類型相同,個數不同
}
// 進行runtime.GC(),並統計時間
println("開始統計")
start := time.Now()
runtime.GC() // 開啓這一輪的GC
result := time.Since(start)
println(fmt.Sprintf("GC time: %v", result))
}
// TestForPointerMapBenchMark 測試指針map
func TestForPointerMapBenchMark() {
runtime.GC()
var demo1 = make(map[int]*int) // KV類型->對應有指針的map
for i := 0; i < N; i++ {
v := i
demo1[i] = &v
}
// 進行runtime.GC(),並統計時間
println("開始統計")
start := time.Now()
runtime.GC() // 開啓這一輪的GC
result := time.Since(start)
println(fmt.Sprintf("GC time: %v", result))
}
// TestForNonPointerMapBenchMark 測試指針map
func TestForNonPointerMapBenchMark() {
runtime.GC()
var demo2 = make(map[int]int) // KV類型->對應無指針的map
for i := 0; i < N; i++ {
demo2[i] = i
}
println("開始統計")
// 進行runtime.GC(),並統計時間
start := time.Now()
runtime.GC() // 開啓這一輪的GC
result := time.Since(start)
println(fmt.Sprintf("Non GC time: %v", result))
}
不同指標下的結果量化統計與分析
結果 | 無指針(int) | 有指針(int) | 有指針(demoWithOneE) | 有指針(demoWithTwoE) | 有指針(demoWithThreeE) |
---|---|---|---|---|---|
10 | 103.76µs | 122.167µs | 148.924µs | 111.608µs | 151.322µs |
1000 | 235.812µs | 209.053µs | 150.502µs | 136.332µs | 109.873µs |
10000000 | 1.283464ms | 11.629275ms | 11.370434ms | 20.11954ms | 31.112292ms |
結論1
隨着KV數量級的增加,帶指針的map需要更多申請更多的堆空間,因此程序需要申請GC需要更多的時間來進行垃圾回收
結論2
在有指針的前提下,可以看到含有不同成員變量的結構體,所佔用的GC時間也不相同,但從數據可以看出:
在大數據量的情況下,GC回收時間與結構體中的成員變量數量呈線性關係。原因是在結構體中,成員數量越多,單個結構體需要申請的內存就越大。
不過目前沒有嘗試過結構體中多種不同的成員變量類型的組合,但猜想由於內存對齊,實際實驗結果也會有類似的結論
基於GC逃逸的map緩存設計
通常情況下map中使用string等指針類型的數據結構有助於簡化開發成本,同時也能解決大部分問題。但是在數百萬甚至千萬級別的內存緩存中,GC的週期回收時間可以達到秒級。
因此,我們需要儘量避免map中存在指針,解決的辦法可以採用“二級索引”的思想:
我們動態申請內存(此處基於不同的逐出策略,需要做一定的內存申請策略的調整),之後建立map[int]int key爲int表示的是邏輯key(可以定義爲string類型,也可以定義爲函數類型等等帶指針的數據結構)的哈希函數,其中value存放的爲真正數據在內存中的offset。
例如,我們插入一個<"hello","world">
的KV值,首先先將hello
哈希成一串整形數字,再將world轉化爲byte存儲在我們申請的內存中,由於world佔用5個byte,所以offset=5
思考
- 對於沒有指針的map對應的case,其GC的回收時間也隨着KV數量的增大而增大,此處不符合預期,目前還在研究中。