Background

Golang

首先交代一下问题发生的场景:

MapMap

垃圾回收机制

在C和C 中,我们是需要通过malloc和free来手动管理内存,而在Java和Go中,我们不需要感知内存的操作,内存回收机制(GC)会帮自动帮我对内存进行扫描和回收,让开发者更专注于业务。

常用的垃圾回收有两种策略

引用计数法

  1-10
shared_ptr

这种回收方式易于实现,响应快,但时存在循环引用的情况,如果对象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{}

整个任务使用了超4个G内存,从Pod的内存监控来看,大概可以看出,有2.24G的内存为RSS占用,即加载到内存中的共享库等数据占用,这部分的内存暂时无法减少(没有监控也可以通过top等命令看到这个数据)

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

可以看出来这个核对任务执行期间,总共占用了2个多G的内存,符合实际情况。

map优化

从图上看到,我们保存数据的map实际上是进行多次扩容过的,每次扩容会申请一个2倍的内存,旧内存不会立刻删除(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,效果符合预期。

本文出至:学新通