[Golang]垃圾回收:三色标记法 & 内存优化实操
Background
近期,一个线上模块在执行账单核对任务时,发生了pod内存占用过高导致重启的情况。为了定位这个问题,了解了一下Golang
的垃圾回收机制和一些内存占用分析的方法。在这里分享一下
首先交代一下问题发生的场景:
- Pod配置 : 2核4G
- 核对逻辑 :
- 正向核对 -- 将上游的数据与我们的数据核对,并使用
Map
保存已核对的数据 - 反向核对 -- 遍历我们的数据,如果存在
Map
中则为已核对,不在Map中则为异常
- 正向核对 -- 将上游的数据与我们的数据核对,并使用
- 数据量级 : 约千万级的账单
垃圾回收机制
在C和C++中,我们是需要通过malloc和free来手动管理内存,而在Java和Go中,我们不需要感知内存的操作,内存回收机制(GC)会帮自动帮我对内存进行扫描和回收,让开发者更专注于业务。
常用的垃圾回收有两种策略
引用计数法
这种策略为每个对象维护一个引用计数值,当这个对象被引用时,则计数值 +1
,当对象的引用被释放时,计数值 -1
。当计数值为 0
时,表示没有其他对象在使用它了,此时就可以进行回收动作了。
C++中共享指针shared_ptr
的基本原理就类似,记录对象被引用的次数,当引用次数为0 的时候,也就是最后一个指向某对象的共享指针析构的时候,共享指针的析构函数就把指向的内存区域释放掉。
这种回收方式易于实现,响应快,但时存在循环引用的情况,如果对象A引用了对象B,对象B同时也引用了对象A,就会造成循环引用,计数值不会为0,内存无法释法 ,导致内存泄露。
可达性分析
Java和Golang的GC实际采用的还是可达性分析的垃圾回收机制,我们如何判断一个对象是否可达呢?
- 首先第一步是找出所有全局变量和当前函数栈里的变量,标记为可达
- 其次是从标记可达的数据开始,进一步标记它们可访问的变量,循环往复
- 最后没有标记的变量,即为不可达,可以进行清理
其中,三色标记法
就是可达性分析算法中的一种。
三色标记法
三色标记法将对象用三种颜色区分,分别是白色
,黑色
,灰色
。
1、 最开始的时候,所有对象都是白色的。
2、从根节点开始遍历(全局对象和当前函数栈对象),将遍历到的对象变成灰色对象
3、遍历所有灰色对象,将遍历到的对象变为灰色对象;然后将遍历过的灰色对象变为黑色对象
4、循环步骤3,直到有灰色对象变成灰色。
5、回收白色对象的内存,回收结束后又把所有对象变回白色对象
以上就是三色标记法的基本流程,但在回收的过程当中,有可能又创建了新的对象,或者取消了对某个对象的引用,导致引用关系的变化。
所以在最开始的时候,Golang的GC采用的是STW(stop the world的方式),中断你的程序逻辑,确定引用关系,等到回收结束再恢复。
后面Golang团队又提出了插入写屏障和删除写屏障机制,减少STW时间来优化。
插入写屏障
规则 : 当一个对象引用另外一个对象时,将另外一个对象标记为灰色。
PS.插入屏障仅在堆内存对象中生效,不对栈内存生效(这是因为go在并发运行时,大部分的操作都发生在栈上,函数调用会非常频繁。数十万goroutine的栈都进行屏障保护自然会有性能问题),需要对栈内存进行STW,然后进行一次rescan重新扫描
当堆中对象引用另外一个对象时,将另一个对象标记为黑色;而当栈中对象引用另一个对象时,则不触发屏障,标记为白色。
图中,对象5引用了对象3,将其标记为灰色,对象1引用了对象9,将其标记为白色(图网上找的,标记集合有点小问题)
如果直接这么回收,会导致对象9被清除,所以在回收结束后,会对栈区进行STW,栈区元素恢复成白色,重新扫描,保证可用对象不被清除。
删除写屏障
在删除引用时,如果被删除引用的对象自身为灰色或者白色,那么被标记为灰色。
此时,对象3及其引用的对象对能存活到下一轮回收才被删除。
混合写屏障
GOlang 团队结合这两种屏障,提出了混合写屏障,避免了STW的问题,提高GC效率。
- GC刚开始的时候,会将栈上的可达对象全部标记为黑色。
- GC期间,任何在栈上新创建的对象,均为黑色。
- 堆上被删除的对象标记为灰色
- 堆上新添加的对象标记为灰色
关于屏障的具体图示和解析,可以参考文章:community.apinto.com/d/34057-gol…
内存分析
了解完Golang垃圾回收的机制,让我们回到我们最开始的问题上来,核对任务出现内存暴涨的情况,首先需要定位一下内存用到什么地方去了。
首先,核对任务中存储数据使用的是一个map[string]strcut{}
的结构来存储(空struct不占内存,value使用其他结构,即使赋值为nil也会占一个指针大小的内存),因此猜测内存占用主要来自于这千万数据的id字符串(map的key)
整个任务使用了超4个G内存,从Pod的内存监控来看,大概可以看出,有2.24G的内存为RSS占用,即加载到内存中的共享库等数据占用,这部分的内存暂时无法减少(没有监控也可以通过top等命令看到这个数据)
然后用PProf对代码内存进行了简单的分析(pprof之前写过篇文章介绍使用方式,比较简略:juejin.cn/post/714770…)
可以看出来这个核对任务执行期间,总共占用了2个多G的内存,符合实际情况。
map优化
从图上看到,我们保存数据的map实际上是进行多次扩容过的,每次扩容会申请一个2倍的内存,旧内存不会立刻删除(Map应该是使用到了才会进行数据迁移)
因此 优化1就是提取为map创造一个合适的大小,减小扩容的次数(make(map[string]struct{},1024*1024)
)
字符串编码
第二部分的内存占用出现在上图的部分,右侧是文件下载的逻辑,暂时不考虑进行优化。而左侧是调用上游的sdk,进行账单信息的组装,这部分按理说是应该用完就清除,不该积累到500MB。
通过分析代码,发现了了出现问题的一步
// billMap := make(map[string]struct{},1024)
// ...
// 上游组装的结构体叫 upstreamStruct
billId := upstreamStruct.BillId
billMap[billId] = struct{}
这一步中,我们的map是一个root对象,核对任务全程存在,通过billId引用了upstreamStruct,这个对象会变标黑,无法通过三色标记法删除。
通过将这个billId提取出来,可以减少这部分数据结构的占用
// billMap := make(map[string]struct{},1024)
// ...
// 上游组装的结构体叫 upstreamStruct
billId :=fmt.Sprintf("%s", upstreamStruct.BillId)
billMap[billId] = struct{}
从优化结果看出,我的猜测应该是正确的,内存也被释放掉了。
但优化结果还是不够满意,这部分字符串占用的空间仍然太大了。因此希望对字符串进行压缩。
尝试了几种方式:
- bitmap:通过做一个布隆过滤器,不保存这个字符串了-- 布隆过滤器大小固定,内存可控,但存在错误率即hash本身存在碰撞可能
- hash:通过hash将字符串hash成一个小一点的int或字符串,存在碰撞率(id字符串本身就不是很长)
- 编码
最终采取手动将字符串进行无损编码,由于数据的字符都是数字和下划线组成,0-9加下划线一共10个字符,加一位结束符,只有12个符号,用4个bit就完全足够表示了(4bit可以表达16个字符)
编码的大体思想是:
2个字符编码成一个字符,例如
- 字符“12”,需要两个byte存储,为0000 0001,0000 0010
- 可以用 0001 0010 表示,只占用一个byte
data := "123123123123143435464653524434425"
compressdata := make([]byte, len(data)/2+ len(data)%2)
// 12 -> 0001 0010 ->18
// 31 -> 0011 0001 ->49
fori := 0 ; i < len(data); i = i + 2{
d := byte(0)
d = (data[i]-'0')<<4| d
if i+ 1 < len(data) {
d = (data[i+1 ] - '0') | d
}
compressdata[i/2] = d}
fmt.Println(string(compressdata))
// 结果:1#1#45FFSRD4BP`
PS.这段代码临时写的,有点问题,只是表达一下思路
采用这个方式编码后,内存占用为原来的一半左右符合预期,上线后将原来一个超8G的核对任务,降低到了不到4.5G,效果符合预期。
转载自:https://juejin.cn/post/7212143399037534245