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数量的增大而增大,此处不符合预期,目前还在研究中。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章