Go垃圾回收与实现
为什么需要垃圾回收
- 减少错误和复杂性
像没有垃圾回收的语言,比如C、C++,需要手动分配,释放内存,容易出现内存泄露和野指针问题。具有垃圾回收功能的语言屏蔽了内存管理的复杂性。开发者可以更好的关注核心的业务逻辑,虽然垃圾回收不保证完全不产生内存泄露,但提供了重要的保障,即不在被引用的对象,终将被收集。
- 解耦
手动分配内存的问题在于难以在本地模块内做出全局的决定,具有垃圾回收功能的语言将垃圾收集工作托管给具有全局观的运行时代码,开发者编写的业务模块将真正实现解耦。
经典的垃圾回收算法
RootSet
标记清除
优点:实现简单
缺点:
- 碎片化, 会导致无数小分块散落在堆的各处
- 分配速度不理想,每次分配都需要遍历空闲列表找到足够大的分块
标记整理
标记阶段和标记清除算法相同,但是在完成标记工作后,会移动非垃圾数据,按照内存地址次序依次排列,然后将末端地址以后的内存全部回收
优点:解决了内存碎片的问题
缺点:需要一些额外的空间来标记前对象已经移动了其他地方,在压缩阶段,如果一个对象发生了转移,必须更新所有引用该对象的对象指针,增加了实现的复杂性
复制算法
优点:解决了内存碎片问题,整理的时间比标记整理算法更短,不分阶段,扫描到对象时可以直接移动,在扫描根对象时就可以直接压缩
缺点:
- 内存空间利用率不高
- 如果存活对象非常多,复制将会花费很多的时间
为了提高内存使用率,通常和其他算法搭配使用,例如分代垃圾回收
分代回收
分代回收的前提是:大部分对象都在年轻时死亡
新生代对象:新创键的对象
老年代对象:经过n次GC后依然存活的对象
基于此,把数据分为新生代和老年代,没有必要每次都扫描老年代对象,降低老年代执行垃圾回收的频率,明显提升垃圾回收效率
新生代和老年代还可以采用不同的回收策略
比如Java的JDK8的 PARNEW + CMS 垃圾回收器
新生代采用复制算法,将内存划分成8:1:1, 提升了复制算法的内存使用率
老年代采用标记整理算法
引用计数
引用计数是指:一个对象被引用的次数
程序执行过程中,会更新对象的引用计数,当引用计数更新为0时,表示这个对象不再有用,可以回收
引用计数法中,垃圾识别的任务已经分摊到每次对数据对象操作了
优点:可以及时回收内存
缺点:高频率更新引用计数造成不小开销,存在循环引用情况。
暂停用户程序,只专注于垃圾回收的前提下,也就是没有STW的情况
实际上用户程序是不能接受长时间的暂停的,缩短STW的时间,是衡量很多垃圾回收器优化的一个重要指标
Go垃圾回收演进
V1.3之前
V1.3版本之前采用标记清除算法
从上图可看,全部的GC时间都在STW时间范围之内,会让用户程序出现严重卡顿,暂停时间过长
V1.3
V1.3之前GC执行过程中,一直都在STW,V1.3做出的优化是将清除垃圾对象STW时间范围摘出来
V1.5 三色并发标记
面对1.3以及之前的版本,在进行GC的时候,会暂停整个程序
所谓的三色标记通过三个阶段的标记来确定清除的对象都有哪些,GC过程可以和其他用户goroutine并行
三色的含义
- 白色:尚未访问过。不在队列中。
- 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
- 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。
假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
- 初始时,所有对象都在 【白色集合】中
- 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;从灰色集合中获取对象
- 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
- 将本对象 挪到 【黑色集合】里面
重复步骤3,直至【灰色集合】为空时结束。
结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。
当进行三色标记时,如果STW, 可以完成标记,但是这样GC扫描的性能就太低了
Go如何解决标记-清除算法中STW问题?
假如在三色标记过程中,不启动STW, 那么此时用户goroutine和垃圾回收的goroutine是并发进行的,在GC扫描过程中,任意的对象都可能发生读写操作,可能会出现如下情况:
还没有扫描到F和G对象的时候,已经被标记为黑色的D对象,此时引用了G,与此同时,灰色对象E断开了对G对象的引用
然后按照正常三色标记逻辑,最后G会一直停留在白色集合中,最后被回收掉,所以就出现一个正常的对象,被无辜清楚掉了
这是不被接受的
可以看出,有两种情况,在三色标记法中,是不希望被发生的。
- 条件1: 一个白色对象被黑色对象引用, 也就是白色被挂在黑色下
- 条件2: 灰色对象与它之间的可达关系的白色对象遭到破坏, 灰色同时丢了该白色
如果当以上两个条件同时满足时,就会出现对象丢失现象
以上是在没有进行STW的情况下,会出现的情况,但是进行STW,对所有用户程序又有很大的影响,那么是否可以在保证对象不丢失的情况下合理的尽可能的提高GC效率,减少STW时间呢?答案是可以的,我们只要使用一种机制,尝试去破坏上面的两个必要条件就可以了。
屏障机制
只要满足下面两种情况之一,基于可以保证对象不丢失
- 强三色不变式
不存在黑色对象引用到白色对象指针,强制性的不允许黑色对象引用变色对象
- 弱三色不变式
所有被黑色对象引用的白色对象处于保护状态,黑色对象可以引用白色对象,但是白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在存在灰色对象
为了遵循上述两种方式,GC算法演进到两种方式:插入屏障和删除屏障
屏障的本质
内存屏障只是对应一段特殊的代码,这段代码在编译期间生成
内存屏障本质上在运行期间拦截内存写操作,相当于一个hook调用
屏障的作用:通过hook内存的写操作时机,做好一些标记工作,从而保证垃圾回收的正确性
func main() {
a := new(Tstruct)
go funcAlloc0(a)
}
func funcAlloc0 (a *Tstruct) {
a.base = new(BaseStruct) // new 一个BaseStruct结构体,赋值给 a.base 字段
}
Dump of assembler code for function main.funcAlloc0:
0x0000000000456b10 <+0>: mov %fs:0xfffffffffffffff8,%rcx
0x0000000000456b19 <+9>: cmp 0x10(%rcx),%rsp
0x0000000000456b1d <+13>: jbe 0x456b6f <main.funcAlloc0+95>
0x0000000000456b1f <+15>: sub $0x20,%rsp
0x0000000000456b23 <+19>: mov %rbp,0x18(%rsp)
0x0000000000456b28 <+24>: lea 0x18(%rsp),%rbp
0x0000000000456b2d <+29>: lea 0x1430c(%rip),%rax # 0x46ae40
0x0000000000456b34 <+36>: mov %rax,(%rsp)
0x0000000000456b38 <+40>: callq 0x40b060 <runtime.newobject>
# newobject的返回值在 0x8(%rsp) 里,golang 的参数和返回值都是通过栈传递的。这个跟 c 程序不同,c 程序是溢出才会用到栈,这里先把返回值放到寄存器 rax
0x0000000000456b3d <+45>: mov 0x8(%rsp),%rax
0x0000000000456b42 <+50>: mov %rax,0x10(%rsp)
# 0x28(%rsp) 就是 a 的地址:0xc0000840b0
=> 0x0000000000456b47 <+55>: mov 0x28(%rsp),%rdi
0x0000000000456b4c <+60>: test %al,(%rdi)
# 这里判断是否开启了屏障(垃圾回收的扫描并发过程,才会把这个标记打开,没有打开的情况,对于堆上的赋值只是多走一次判断开销)
0x0000000000456b4e <+62>: cmpl $0x0,0x960fb(%rip) # 0x4ecc50 <runtime.writeBarrier>
0x0000000000456b55 <+69>: je 0x456b59 <main.funcAlloc0+73><