golang垃圾回收
golang的垃圾回收的发展主要有三个阶段
- golangv1.3之前的标记-清除法(Mark and Sweep)
- golangv1.5的三色并发标记法(强-弱三色不变式, 插入、删除屏障)
- golangv1.8的混合写屏障
标记-清除法(Mark and Sweep)
主要包含两个阶段
- 标记
- 清除
具体步骤
- STW(stop the world),暂停所有程序,分出可达类和不可达类
- 标记所有可达类节点
- 清除所有不可达类节点
- 停止暂停,让程序继续跑。然后循环重复这个过程,直到程序生命周期结束。
优势
非常简单,STW暂停所有的程序运行,清理所有不可达对象。
劣势
简单的代价就是在清理垃圾的过程中,所有程序暂停,程序出现卡顿 问题(STW带来的后果)
gov1.3再做了简单的优化,因为不可达对象都是程序不需要的对象,所以可以把STW提前,减少暂停的时间
如何提高垃圾回收的效率?很直观的想法就是降低STW的时间,后续就有了一系列优化方案。
三色并发标记法
golangV1.5推出了三色并发标记法
具体步骤
- 初始阶段都是白色对象
- 遍历白色对象,把可达对象变成灰色
- 遍历灰色对象,把可达对象变成黑色
- 剩下的白色对象就是不可达对象(垃圾),需要进行清除
染色思路是很清晰的 ,但是在gc的开始三色标记之前加上 STW,在扫描确定黑白对象之后再放开 STW。所以程序还是会有卡顿现象。
为什么三色并发标记法需要STW
假设在三色并发标记的过程中没有STW
理想情况下,扫描任务继续执行,对象2、3、5最终都变成黑色节点,把对象6清除掉。
但是!由于没有STW,所以任意的对象均可能发生读写操作。在本次扫描开始之前,对象2把对象3的引用删除了,对象4加上了对象3的引用
在扫描过程中,因为对象2删除了对象3的引用,所以对象3不会变成灰色;对象4已经变成黑色了,所以不会继续遍历黑色对象。最终,对象3变成了一个垃圾被清除了,程序2因为需要对象3,但是他被回收掉了,所以可能会引发程序崩溃等等现象。
屏障机制
其实在理想情况下,不启用STW是可行的,但一旦出现
- 黑色对象新增白色对象的引用
- 可达的白色对象被删除
上面两种情况,就有可能会出现对象丢失的情况
为了避免出现上面两种情况的发生,出现了强-弱三色不变式两种方式
强三色不变式
强三色不变式约定了,黑色对象不能引用白色对象
弱三色不变式
弱三色不变式约定了,黑色对象只可以引用可达路径上的白色对象
强弱三色不变式约定了新增和删除对象时,需要对对象状态进行判断,gc就进而演化出插入屏障和删除屏障。
插入屏障
具体操作
: 在 A 对象引用 B 对象的时候,B 对象被标记为灰色。(将 B 挂在 A 下游,B 必须被标记为灰色)
满足
: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)
但是。对象内存分配有可能在栈
或者堆
上,栈的特点是速度快,但是存储空间小。堆的特定是速度慢,但是存储空间大。因为栈上会操作频率很快,所以插入屏障都是作用于堆上的。堆上扫描完后,通过STW重新堆栈上对象扫描。
插入屏障的工作范围
- 堆上引用:触发插入屏障,被引用对象标记为灰色
- 栈上引用:不触发插入屏障,保持原有颜色
例子:
- 堆上新增一个白色对象引用,触发插入屏障,新引用的对象直接会被标记为灰色
- 栈上新增一个白色对象引用,不会触发插入屏障,还是白色对象。但是最终会重新对栈上的对象进行STW扫描,原本的白色对象在可达链路上,最终会变成黑色。
栈重扫描的必要性
插入屏障只作用于堆上对象,这会导致一个问题:
问题场景:
1 | 栈上白色对象A ← 堆上黑色对象B引用 |
- 堆上对象B引用栈上对象A,但不触发插入屏障
- 对象A保持白色状态
- GC扫描时,A会被误判为垃圾
解决方案:
插入屏障必须配合栈重扫描:
- 标记阶段结束后,STW开始
- 重新遍历所有goroutine的栈帧
- 检查栈上每个对象的引用状态
- 将被引用的白色对象重新标记为灰色
- 继续标记直到没有灰色对象
栈重扫描的局限性:
栈重扫描只能发现栈上变量直接引用的对象,对于”堆引用栈且栈上无其他引用”的场景仍存在漏检风险。实际实现中通过逃逸分析和保守扫描来解决。
优势
- 满足三色不变式
- 减少STW时间(相比传统标记清除)
劣势
- 结束时需要 STW 来重新扫描栈
- 栈重扫描增加了STW时间
- 存在栈-堆引用关系的复杂处理问题
删除屏障
具体操作
: 在 A 对象删除 B 对象引用的时候,如果B本身就是灰色或者白色对象,则直接标记为灰色
满足
: 弱三色不变式(保护可达路径上的白色对象不被删除)
比如删除对象1对对象5的引用,对象5会被标记为灰色,最终对象2、3、5都会变成黑色。虽然对象2、3、5都是黑色节点,但是如果没有任何对象引用对象2、3、5的话,这三个节点会在下一次gc的时候被清除。
优势
- 简单,维护成本低
劣势
- 精确性没那么高
混合写屏障机制
插入屏障精确,但是需要通过STW重新扫描栈上的对象,删除屏障性能高,但是没那么精确,部分对象引用需要下一次gc才能处理。所以golangV1.8使用了混合写屏障,在精确性和性能之间找到最佳平衡点。
具体步骤
- GC 开始将栈上的对象全部扫描并标记为黑色
- GC期间,栈上创建的新对象,均为黑色
- 堆上被删除的对象标记为灰色
- 堆上被添加的对象标记为灰色
场景一:对象被堆上删除,成为栈对象的下游
- 栈上对象1不启动写凭证,所以可以直接引用对象7
- 对象4删除对象7的引用,触发删除屏障,对象7会变成灰色
- 最终gc回收对象5、6、8
场景二:对象被一个栈对象删除引用,成为另一个栈对象的下游
- 栈上对象9引用对象3,栈上没有写屏障可以直接引用
- 栈上对象2删除对象3的引用,栈上没有写屏障可以直接删除
- 最终gc会回收对象5、6、8
场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游
- 堆上对象10引用堆上对象7,触发写屏障,对象7会变成灰色
- 对象4删除对象7的引用,对象7还是变成灰色
- 最终gc回收对象5、8、11
场景四:对象被栈上删除,被堆上对象引用
- 栈上对象1删除对象2的引用,不触发屏障,可以直接删除
- 堆上对象4引用栈上对象2,删除对象7的引用。删除对象7的引用触发屏障,对象7会变成灰色;
- 最终回收对象5、8、11;如果下一轮gc对象7还没有被引用,会继续被清除