在使用golang进行日常开发的时候一般不会关注gc性能,但如果出现内存泄露的话,不懂gc的话可能根本没有排查的头绪。因此去找些资料看了golang的gc策略,看完感觉与JVM的gc策略有很多相似之处,在这里简单做个总结归纳。

我这里看的golang版本都是1.9+的策略,老版本的策略不再分析。

什么是垃圾回收

在偏硬件的编程语言里面(c/c++),程序员都是直接管理内存的,在使用内存的时候需要程序员手动分配内存和释放内存,否则就容易发生内存泄露的问题。管理内存给程序员带来了不少的心智负担,所以很多语言都内置了一套内存管理机制,程序员只需要关注内存申请即可,内存的释放交给语言层处理,这种回收不用的内存空间的技术就是垃圾回收机制。

垃圾回收算法

垃圾回收其实是一个很古老的话题了,经过多年的发展,产生了很多种垃圾回收的算法,其中最为基础的是引用计数标记清除这两种,其他算法都是在这些基础上的优化。

引用计数

引用计数的策略非常简单,当一个对象被引用的时候,就将该对象的引用计数器加1,解除引用的时候就将计数器减1,当计数器为0的时候,就说明对象不被引用,这个对象再也不会被使用到了,在垃圾回收的时候就可以清理计数器为0的对象。

引用计数算法的优点比较明显:

  1. 渐进式gc,不同于其他算法在达到内存阈值的进行gc,引用计数可以分散在程序执行的各个阶段,不需要stw(stop the world)
  2. 算法简单,易于实现

但引用计数算法也有两个较为明显的缺点:

  1. 每次对象的引用和删除都需要变更计数器,频繁更新计数器会降低程序性能
  2. 无法处理循环引用的问题,即使循环引用的两个对象都不被其他对象引用了,由于计数器不为0,对象空间还是不能被回收。
    当然上面两个问题也并非无解,只是解决起来整个算法的复杂度会比较高。

标记清除

标记清除分为标记清除两步,其中标记是从跟根结点出发遍历所有节点的引用对象,遍历结束之后就可以定位到不被引用的对象,也就是可回收的对象;清除就是清理可回收对象。标记清除算法有几个缺点:

  1. 标记阶段需要STW:只有所有线程停止运行,保证内存中的对象引用关系不变,才可以得到准确的引用关系
  2. 清除阶段产生内存碎片:可回收对象散步在内存各处,直接清理的话会产生很多内存碎片,直接影响大对象的分配

复制收集

复制收集是对基础标记清除算法的一个改进,它是将内存一分为2,每次只使用一半的空间,gc的时候先标记对象,然后将存活的对象复制到另外一边,然后清理原空间即可。在复制的时候按照顺序分配的原则就可以保证没有内存碎片。这个算法的效率较高,但是缺点就是空了一半内存,内存使用率不高。

标记整理

标记整理也是对标记清除算法的改进,改善了内存碎片的状况,具体来说是将存活的对象往内存的一侧移动,保证内存的规整。

并发标记清除

并发标记清除CMS(concurrency mark sweep)主要的优化目标是减少程序停顿(stw)的时间,主要是将标记和清除这两个阶段都并发化提高效率,这里的并发指的是与用户程序一起并发。首先被标记为可回收的对象都是程序中再也不会用到的对象,所以清除阶段的并发不会产生问题;但是标记阶段如果并发的话,用户程序可能会更改已标记的对象,或者产生新的对象,所以需要将并发阶段更新的对象记录下来。整体分为4个步骤:

  1. 初始标记,这个阶段是标记根对象(全局对象和线程栈上的对象)。其中标记全局对象需要stw,标记线程栈只需要暂时目标线程即可。
  2. 并发标记,这个阶段是并发遍历根对象的引用对象
  3. 重新标记,这个阶段是重新标记在阶段2发生变更的对象,需要stw保证引用关系一致性
  4. 并发清除

CMS虽然已经极大地减少了stw的时间,但是它还存在几个问题:

  1. CMS使用并发的方式执行gc,会拉长整个gc的持续时间,另外gc期间会占用一定的cpu资源,导致响应变慢,吞吐量降低
  2. CMS是标注清除算法,也就意味着会有内存碎片的产生

分代处理

分代处理不是垃圾回收算法,它是一种将对象按照存活时间分为新生代和老年代、并为不同分代采取不同gc算法的策略。按照局部性原理,程序中大量的对象存活时间都非常短,而存活时间较久的对象也将继续存活下去。因此新生代所需的空间相对较小,而老年代的空间较大。根据这个特点,新生代一般采用复制收集算法,而老年代则搭配CMS算法。

golang的gc

golang的gc使用了三色标记的CMS算法。gc的触发时机有3种:

  1. 阈值:默认内存扩大一倍的时候触发一次gc
  2. 定时:默认两分钟执行一次gc
  3. 手动:手动调用runtime.GC()

三色标记指的是白灰黑三种颜色,含义如下:

  • 白色:对象未标记
  • 灰色:对象已经标记,但是子对象未标记
  • 黑色:对象已经标记,且子对象也已经标记

三色标记的大概流程:

  1. 所有对象最开始都是白色。
  2. 从根节点遍历所有可触达的对象,标记为灰色,放入灰色队列中
  3. 遍历灰色对象队列,将其引用对象标记为灰色,将自身标记为黑色
  4. 继续遍历直达灰色对象队列为空,此时所有对象只有白色和黑色两种,完成标记

详细的过程如下图所示:
gc phase
上图各步骤含义:

  1. 从根节点开始遍历,根结点包括全局指针和goroutine栈上的指针;遍历全局指针需要stw,遍历goroutine栈时则只堵塞目标goroutine;在遍历goroutine stack的前需要stw打开写屏障。
  2. 遍历灰色对象直到灰色对象队列为空,在此阶段中gc和用户程序是并发的,期间发生引用变更的对象都通过写屏障记录起来
  3. 重新扫描阶段2记录下来的对象,为保持引用关系一致性,需要stw
  4. 并发清除不可达对象(白色),如果此次gc持续到下次gc开始,那么跟下次gc一期执行,gc周期会有叠加的风险。

写屏障

因为并发标记过程中用户程序也在运行,期间会有新的对象产生、也会对已经标记的对象进行更改,打开了写屏障之后在发生对象内存修改的时候,会先判断对象的引用链,进行标注处理。在最新的runtime里面,采用了混合写屏障,已经可以去掉re-scan这一步了。

STW

上文频繁提到stw(stop the world),如何实现stw?
go的gmp模型里面,所有g都需要绑定到p上面才可以执行,而p存在空闲和运行中两种状态,那么第一步就是通知空闲状态的p不再接活。正在运行中的p里面可能有好几个g在运行,这时候就需要终止所有正在运行中的g。这里需要注意的是,go的抢占式调度并非如操作系统那样可以直接换出线程,go实际上用的是伪抢占式:runtime给g设置抢占标志,g在发生系统调用、io操作、channel操作等一系列堵塞操作及函数调用的时候将自己的控制权让出来。所以stw的实现归纳起来就是:

  1. 设置空闲p的状态为停止
  2. 设置正在运行的g的抢占标志位,等待g主动停止运行,g的停止时机是发生系统调用、堵塞操作及函数调用

参考资料

  1. 《深入理解Java虚拟机》周志明
  2. https://studygolang.com/articles/17432?fr=sidebar