likes
comments
collection
share

[Golang]垃圾回收:三色标记法 & 内存优化实操

作者站长头像
站长
· 阅读数 63

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、 最开始的时候,所有对象都是白色的。

[Golang]垃圾回收:三色标记法 & 内存优化实操

2、从根节点开始遍历(全局对象和当前函数栈对象),将遍历到的对象变成灰色对象

[Golang]垃圾回收:三色标记法 & 内存优化实操

3、遍历所有灰色对象,将遍历到的对象变为灰色对象;然后将遍历过的灰色对象变为黑色对象

[Golang]垃圾回收:三色标记法 & 内存优化实操

4、循环步骤3,直到有灰色对象变成灰色。

[Golang]垃圾回收:三色标记法 & 内存优化实操

5、回收白色对象的内存,回收结束后又把所有对象变回白色对象

以上就是三色标记法的基本流程,但在回收的过程当中,有可能又创建了新的对象,或者取消了对某个对象的引用,导致引用关系的变化。

所以在最开始的时候,Golang的GC采用的是STW(stop the world的方式),中断你的程序逻辑,确定引用关系,等到回收结束再恢复。

后面Golang团队又提出了插入写屏障和删除写屏障机制,减少STW时间来优化。

插入写屏障

规则 : 当一个对象引用另外一个对象时,将另外一个对象标记为灰色。

PS.插入屏障仅在堆内存对象中生效,不对栈内存生效(这是因为go在并发运行时,大部分的操作都发生在栈上,函数调用会非常频繁。数十万goroutine的栈都进行屏障保护自然会有性能问题),需要对栈内存进行STW,然后进行一次rescan重新扫描

当堆中对象引用另外一个对象时,将另一个对象标记为黑色;而当栈中对象引用另一个对象时,则不触发屏障,标记为白色。

图中,对象5引用了对象3,将其标记为灰色,对象1引用了对象9,将其标记为白色(图网上找的,标记集合有点小问题) [Golang]垃圾回收:三色标记法 & 内存优化实操

[Golang]垃圾回收:三色标记法 & 内存优化实操

如果直接这么回收,会导致对象9被清除,所以在回收结束后,会对栈区进行STW,栈区元素恢复成白色,重新扫描,保证可用对象不被清除。

删除写屏障

在删除引用时,如果被删除引用的对象自身为灰色或者白色,那么被标记为灰色。

[Golang]垃圾回收:三色标记法 & 内存优化实操

此时,对象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等命令看到这个数据) [Golang]垃圾回收:三色标记法 & 内存优化实操

然后用PProf对代码内存进行了简单的分析(pprof之前写过篇文章介绍使用方式,比较简略:juejin.cn/post/714770…)

[Golang]垃圾回收:三色标记法 & 内存优化实操 可以看出来这个核对任务执行期间,总共占用了2个多G的内存,符合实际情况。

map优化

[Golang]垃圾回收:三色标记法 & 内存优化实操 从图上看到,我们保存数据的map实际上是进行多次扩容过的,每次扩容会申请一个2倍的内存,旧内存不会立刻删除(Map应该是使用到了才会进行数据迁移)

因此 优化1就是提取为map创造一个合适的大小,减小扩容的次数(make(map[string]struct{},1024*1024))

字符串编码

[Golang]垃圾回收:三色标记法 & 内存优化实操 第二部分的内存占用出现在上图的部分,右侧是文件下载的逻辑,暂时不考虑进行优化。而左侧是调用上游的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{}

[Golang]垃圾回收:三色标记法 & 内存优化实操 从优化结果看出,我的猜测应该是正确的,内存也被释放掉了。

但优化结果还是不够满意,这部分字符串占用的空间仍然太大了。因此希望对字符串进行压缩。

尝试了几种方式:

  • 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.这段代码临时写的,有点问题,只是表达一下思路

[Golang]垃圾回收:三色标记法 & 内存优化实操

采用这个方式编码后,内存占用为原来的一半左右符合预期,上线后将原来一个超8G的核对任务,降低到了不到4.5G,效果符合预期。

转载自:https://juejin.cn/post/7212143399037534245
评论
请登录