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数量的增大而增大,此处不符合预期,目前还在研究中。