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
GC逃逸的map緩存設計



思考

  • 對於沒有指針的map對應的case,其GC的回收時間也隨着KV數量的增大而增大,此處不符合預期,目前還在研究中。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章