垃圾回收简称 GC,就是对程序中不再使用的内存资源进行自动回收释放的操作。
常见的垃圾回收算法
引用计数:每个对象维护一个 引用计数,当对象被创建或被其他对象引用时,计数 +1;如果引用该对象的对象被销毁时,计数 -1 ;当计数为 0 时,回收该对象。
- 优点:对象可以很快被回收,不会出现内存耗尽或到达阀值才回收。
- 缺点:不能很好的处理循环引用;需要实时维护计数引用。
标记-清除:从根变量开始遍历所有引用的对象,引用的对象会被标记,没有被标记的则进行回收。
- 优点:解决了引用计数的缺点;
- 缺点:需要 STW(stop the world),暂时停止程序运行。
分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。
- 优点:回收性能好;
- 缺点:算法复杂。
go1.3 版本之前使用的是 标记-清除 算法,但是执行该算法时,需要停掉用户程序(STW),即保证 GC 和 用户程序 是串行 的。这种算法简单,但是会严重影响用户程序的执行。
go1.5 版本开始后提出了 三色标记法,它优化了标记过程,同时结合 屏障技术 极大的缩短了 STW 的时间,让 用户程序 和 GC 在 标记和清除阶段 可以并行运行。
三色标记法
三色抽象
三色抽象定义了三种不同类型的对象,并用不同的颜色相称:
- 白色:未搜索的对象,在回收周期开始时所有对象都是白色,在回收周期结束时所有的白色都是垃圾对象
- 灰色:正在搜索的对象,该类对象可能还存在外部引用对象
- 黑色:已搜索完的对象,这类对象不再有外部引用对象
标记流程
1、初始状态下所有对象都是 白色 的;
2、从 根节点对象 开始遍历所有对象,把遍历到的对象变成 灰色,放入待处理队列;
3、遍历所有灰色对象,将遍历到的灰色对象变成 黑色,同时将它引用的对象变成灰色并加入到待处理队列;
4、循环步骤3,直到 待处理队列为空(所有灰色对象都变为黑色);
5、剩下没有被标记的 白色 对象就认为是垃圾对象。
其中,根节点对象 主要指执行 GC 时刻所有的 全局对象 和 栈上的对象(函数参数与内部变量)。
并发问题:
如果仅按以上的标记清除过程处理,在没有用户程序并发修改对象引用的情况下,回收可以正常结束。但如果用户态程序在标记阶段更新了对象引用关系,就可能会导致问题的出现:
用以上过程为例,在 A 对象已经被标记为 黑色 后,用户程序修改了引用关系,将 A 对象 引用到 白色对象 E,而此时 A对象 已经被标记为黑色,gc 扫描不会再对它进行处理,最后,E 对象就会被错误的清除。
怎么防止以上问题的出现呢?
首先,出现该问题是因为在 GC 执行的时候,用户程序并发的修改了对象引用关系,而且,修改的引用关系 同时满足 以下两个条件:
- 黑色对象引用了白色对象;
- 该白色对象没有被其他灰色对象引用 或者 灰色对象与它之间的可达关系遭到破坏。
解决方法是实现以下 三色不变式:
- 强三色不变式:黑色节点不允许引用白色节点,破坏了条件一。
- 弱三色不变式:黑色节点允许引用白色节点,但是该白色节点必须有其他灰色节点的引用或间接引用,破坏了条件二。
怎么实现呢?
一个直观的想法就是在用户程序修改对象引用关系的时候,在相关对象上做点手脚,以此来破坏以上 2 个条件同时产生,这其实就是 写屏障 干的事。
写屏障是指 编译器在编译期间生成一段代码,该代码可以拦截用户程序的内存读写操作,在用户程序操作之前执行一个 hook 函数,根据 hook 函数的不同,分为 Dijkstra 插入写屏障 和 Yuasa 删除写屏障。
需要注意的是,基于对栈空间实现写屏障产生的性能损耗和实现复杂度的考虑,go 没有对 栈空间对象 使用写屏障。
写屏障(Write Barrier)
Dijistra 插入写屏障
在 堆对象 增加引用对象的时候,先把该引用对象置为 灰色,这样就可以保证不会有黑色对象引用白色对象,满足 强三色不变式。
但是因为栈空间对象是没有写屏障的,因此,在标记过程中,可能出现 黑色的栈对象 引用到 白色对象 的情况,所以在一轮三色标记完成后 需要开启 STW,重新对 栈上的对象 进行三色标记。
图解:
在 A 、C 对象已经被标记为 黑色 后,用户程序执行 A.ref = E , C.ref = G ,
因为 A 属于栈对象, 因此不会对它新增的引用对象做处理, 因此,E 为 白色;
而 C 属于堆对象,按照屏障函数的处理,要将它引用的对象 G 着为 灰色。
本轮标记结束后,E 对象保持为白色,因此,还要开启 STW,对 栈区 重新做一次三色标记。
Yuasa 删除写屏障
在删除某对象的引用对象时,针对 被删除的对象,如果自身为灰色,则不用处理,如果为白色,那么将该对象标记为灰色,满足 弱三色不变式 。
图解:
1、A 、D 对象为 黑色,B、E 为 灰色 ,其他对象为白色;
2、用户程序执行 B.ref = nil , E.ref = C,按照屏障函数处理:删除对白色对象 的引用,要将白色对象着为灰色,因此执行 B.ref = nil 要将 C 对象被着为 灰色; F 同理;
3、继续三色标记;
4、最后 F 和 G 对象 也会被标记为黑色而保留至下一轮,虽然它们已然可以被清理。
可以看到,删除写屏障有两个缺点:
- 回收精度低:因为该方法保守的认为所有被删除的节点将来可能会被黑色节点引用,因此在删除的时候要将其置为灰色,而这个节点可能再也不会被其他节点引用,从而导致该节点以及它引用下的其他节点都在本轮 gc 后被保留下来,要待到下轮 gc 才可以被清除。
- 必须在 gc 开启时执行 STW,扫描根集合,保证所有堆上在用的对象要么为灰色,要么处于灰色保护下,即保证 根黑,下一级在堆上的全灰。
第 2 点 讲的是什么鬼,首先 举个反例:
A、B 属于两个不同的协程栈对象,假如现在不在 gc 开始前扫描整个栈区,可能出现:
1、扫描 g1 栈对象,执行三色标记,将 A、C 标记为黑色后;
2、扫描 g2 栈对象,将 B 标记为灰色,然后执行用户程序代码 B.C.ref = D, B.ref = nil;因为 栈区没有实现写屏障,因此,B 删除 对 D 的引用后 D仍然为白色;
3、继续三色标记,因为 C 已经为黑色对象,不会再被扫描,导致最后 D、G 始终保持为白色而被删除。
再来看下 正确的过程:
先扫描根集合,使 A、B 栈节点都变为黑色,C、D 堆节点变为灰色,堆区其他所有节点都处于灰色节点的可达链路上,这样就可以保证以后即使 B 删除了对 D 对象的引用后,G 对象也不会被清除。
正反例都举完了,你可能还存在一个疑问,在执行过程中,如果用户代码 new 了一个对象,并且被某个黑色节点引用,比如, A.ref = new(F), 那这个新构建的对象不是也会被误删。
其实,这个时候我们回过头来想想,这个 F 对象如果没有发生逃逸,它会是一个栈对象,那在 gc 开始时就会被扫黑,如果这个 F 对象发生了逃逸,是一个堆对象,那么它也会因为 A 被扫黑的过程中成为了一个灰色对象。
感觉有点废话,但是我曾经脑补过这场景,想了好久才想明白。
混合写屏障
回顾一下,单独的写屏障都有各自的缺陷:
插入写屏障 需要在一轮标记结束后执行 STW,重新对栈区执行一次扫描标记;
删除写屏障 需要在 gc 开始阶段扫描整个栈,生成起始快照。
在 go 1.8 版本引入了 混合写屏障技术,集两种写屏障各自的优点于一身。
针对 栈区 和 堆区 分别采用以下策略处理:
栈区:
栈上对象全部扫描标记为黑色(每个栈单独扫描,无需 STW 整个程序,停止单个扫描栈即可);
GC 期间,任何在栈上创建的新对象,均为黑色(不用再对栈重新扫描);
堆区:
被删除的对象标记为灰色(删除写屏障);
被添加的对象标记为灰色(插入写屏障)。
gc 触发时机
gcTriggerHeap:堆内存的分配达到控制器计算的触发堆大小,初始大小环境变量 GOGC,之后堆内存达到上一次垃圾收集的 2 倍时才会触发GC;
gcTriggerTime:距离上一次垃圾回收超过一定阈值时,该时间由 runtime.forcegcperiod 变量控制,默认为 2 分钟。
gcTriggerCycle: 要求启动新一轮的GC, 已启动则跳过, 手动触发GC的runtime.GC()会使用这个条件。