背景

为什么计算机程序需要垃圾回收这里不再赘述了,但是作为一个开发为什么需要了解垃圾回收算法呢?要知道垃圾回收机制再设计之初就是为了让程序员可以专心写业务,不用关心太多内存的问题。所以答案显然易见,如果你不满足于调用api写业务接口,想要做一些性能优化,甚至只是想写一份优雅的业务代码,你就需要关心垃圾回收机制。当然,最重要的,面试的时候可以用来装逼。

那么,关于Golang垃圾回收程序员应该知道些什么?你要深入每一行代码去了解每一个细节,我当然佩服你的勇气。但我认为,主要是知道下面几点:

  1. 垃圾回收几种思路其各自的优缺点;
  2. Golang语言的垃圾回收有什么特点,是如何做到的;
  3. 垃圾回收的特性带来的程序性能的问题排查;
  4. 我们可以从Golang的垃圾回收的设计里学习到什么?

垃圾回收的几种思路

主流上一种是引用计数算法,一种是标记清除算法。但是前者会有循环引用的问题,并且对每一个对象进行引用计数也是一笔不小的开支。本文主要是讲标记清除算法。

1. 跟踪收集法

垃圾回收要解决的问题无非是,把不再要用的对象占用的内存销毁掉,让别的对象可以继续使用。跟踪收集法的原理就是不断地去扫描所有对象,然后进行标记(是否需要清除),然后去回收需要清除的。而我们判断是否需要清除的标准是,该对象是否有合理的路径去访问,比如被一个全局变量引用,或者被函数栈里使用。如果可达,则不能被清除。不可达了则可以被清除。具体精确的定义可以参考

2. 三色标记法

那么具体是如何实现跟踪收集的呢?其中一种比较主流的算法就是三色标记法,黑白灰实际上对应着内存对象的三种状态,白色集合里放待回收的对象和其候选人(最开始GC会把所有的内存对象都置为白色),灰色表示该对象是可达的,但是还需要扫描其各个引用。而黑色则表示该对象没有任何引用了,并且可达不能被清除。

Three sets are created – white, black and gray:

  • The white set, or condemned set, is the set of objects that are candidates for having their memory recycled.
  • The black set is the set of objects that can be shown to have no outgoing references to objects in the white set, and to be reachable from the roots. Objects in the black set are not candidates for collection.
  • The gray set contains all objects reachable from the roots but yet to be scanned for references to "white" objects. Since they are known to be reachable from the roots, they cannot be garbage-collected and will end up in the black set after being scanned.

大概步骤就是:

  1. 首先将所有对象都置为白色;
  2. 然后将所有全局变量和main函数栈和堆里的对象都放入灰色集合;
  3. 开始扫描灰色集合里的对象是否有引用,有的话将其引用放入灰色集合,扫描完后的对象都放入黑色集合。
  4. 清除掉白色集合里的对象。

上述是一个很简单的标记清除逻辑,看起来一切都很简单。标记,然后清除。但是Golang的垃圾回收器和业务程序是并发执行的,也就是说,在GC标记和清除的过程中,程序是一直是动态地在创建对象的,对象的引用也可能在变化。那么新创建的对象应该放入什么颜色的集合呢?或者说对象的引用变化了怎么办呢?不难知道会出现多写和漏写的问题(可参考底层原理:垃圾回收算法是如何设计的?读写屏障部分)。

多写的现象是本该删除的白色对象在这一轮没有删除,但是如果下一轮该白色对象依旧保持原有的引用关系的话,下一轮的GC会将其删除,所以这个问题不伤害准确性。但是漏写问题则是将本不该删除的对象删除了,因此一定要避免。漏写情况的出现需要满足一个条件:灰色对象(在扫描前)删除了对白色对象的引用,但是黑色对象又对其进行了引用。

那么只需要破坏这个条件,就可以避免这个问题。

3. STW与读/写屏障

解决方法有很多种。最简单的做法就是,把程序停止,让其不要继续创建新对象和修改引用了。也就是早期的串形GC,很明显,这样会影响业务程序的运行,而且因为GC的执行导致整个程序宕机的时机不可控。

另一种做法就是读/写屏障。对某对象的引用改变或者创建新的对象都会涉及左值对象的写和右值对象的读,因此,只需要对读或者对写作一层封装处理,去破坏掉上述条件,这个概念叫做读/写屏障,它不是一种特定的算法,而是一种触发机制。垃圾回收算法可以选择对被读对象进行操作,或者是对被写对象进行操作。

一种思路叫做增量更新,就是将在GC标记过程中变化的对象,无论之前是在什么集合,都立即放回灰色集合,等待下一轮GC的扫描。

另一种思路叫SATB(Snapshot At The Begining),当灰色对象删除对白色对象的引用时,它将白色对象置为灰色;当黑色对象新引用一个白色对象时,将白色对象置为黑色。这样会造成浮动垃圾,也就是本该在这轮回收的对象没有回收,但是again,这不影响其正确性,只是影响效率,所以也是可以接受的。

4. Golang GC

终于说到本文的主角了。Golang里采用的GC是并发增量GC,其中并发和增量的目的都是为了减少STW的时间。抽象的逻辑上面已经说完了,谈到具体的实现,有几个点可以说一下。

  1. 首先是内存分配会分有指针和无指针的对象分配,这就是为GC扫描做的优化。分配在无指针内存上的对象在扫描时可以直接变成黑色而不是灰色。
  2. GC的标记完成后,并不是立马就把不在需要的内存释放掉而是等待下次分配的时候才释放。因此GC完毕后也不是立马就可以看到程序占用的内存变小。
  3. 其次是GC具体的触发时机,默认是堆内存达到原来的两倍或者每隔2分钟开始一次GC。分配的资源限制是启用CPU的1/4来做GC。这里有一个问题就是一般业务都是用的k8s的pod部署业务,pod request里的分配的内核数一般都很小,而golang创建的process数和物理内核数是一致的。
  4. 如果扫描的速度小于业务请求内存分配的速度,需要分配多的goroutine执行Mark Assist逻辑,来减缓分配速度。
  5. 然后就是要清除虽然GC做了很多优化减少STW的时间,但并不是没有STW,在并发标记和清除时不需要STW,但是在GC开始时需要STW打开写屏障,结束时需要STW关闭写屏障,GC结束的STW需要计算下一次GC的时间和一些临时变量的清理。
  6. 最后就是要学会查看GC的一些调试数据,以及可以解释出该现象的原因。可以看一下参考1和参考2两篇博文。

5. 结论

GC对应用程序的影响在两个时段:

  1. marking的时候,会占用一个物理线程,从而导致Go业务代码无法跑慢CPU;
  2. STW的时候会暂停所有的业务goroutine。

GC调优的宗旨在于在有限的堆内存上获得最大的吞吐量,也就是在两次GC之间尽可能地做多一些业务操作,老生常谈的思路就是:

  1. 减少堆的内存申请,尽量复用代码,或者尽量分配在栈中。
  2. 找到一个合适的GC执行节奏,也就是决定下一次GC什么时候开始的算法,比如调整GC percentage option。

参考