Go GC是如何回收堆内存的?

141 阅读5分钟

前言

前情回顾:

Go 堆内存结构是什么样的?

Go 如何分配堆内存?

终于来到GC这块了。Go的GC是在运行程序时才工作的,并且是异步的,类似一个后台进程,不会打扰到其它协程的工作。那GC是怎么实现的呢?

可达性分析

Go的GC是采用标记清除的方式,先把所有有用的对象标记了,然后再回收未标记的对象。

那从哪里开始找呢?

Root Set变量开始找,Root Set变量有以下特征:

  • 被栈上的指针引用
  • 被全局变量指针引用
  • 被寄存器指针引用

遍历方式是什么?

内存分布类似图或树,举例如下:

image.png

对于这类结构,可以采用深度dfs遍历或广度bfs遍历。Go的GC采用的是广度优先遍历方式。

首先从根节点出发,将引用的到节点标记上:

image.png

继续下一轮标记:

image.png

标记完成后,发现对象G和H未被标记,便将其内存回收。

以上方法称为可达性分析法,其步骤为:

  • 启动前要暂停其它协程
  • 通过可达性分析,找到无用的堆内存
  • 释放堆内存
  • 恢复所有协程

这种串行GC方法实现起来较为容易,但缺点也很明显,那就是需要暂停其它协程,对性能影响大

三色标记法

要解决GC时的性能问题,就得使用并发GC了,因此Go提出了三色标记法

三色标记法分别是黑色,灰色和白色,不同标记颜色意义不同:

  • 黑色,有用,已经分析扫描完了
  • 灰色,有用,暂未分析扫描
  • 白色,暂时无用

运行时也是采用可达性分析的思路,先标记再清除。其运行过程如下。

标记阶段

首先初始状态如下,用三个队列分别放置黑灰白对象:

image.png

从Root Set出发,发现用到了对象A和E,将自身标记为灰色,加入灰色队列。为什么是灰色呢?因为对象A和E是有用的,但还没有对其子对象进行分析扫描。

image.png

下一步从对象A和E出发,发现对象B,C,D,F,将这些对象都标记为灰色,加入灰色队列。而对象A和E扫描完了,标记为黑色,加入黑色队列。

image.png

最后扫描分析对象B,C,D,F完成后,都标记为黑色,加入黑色队列。同时广度搜索完毕。

image.png

清除阶段

将对象G,H堆内存回收。

image.png

再次标记时,所有对象置为白色。

image.png

以上即GC的运行过程,但在并发场景下,可能存在一些问题。

删除屏障

并发场景下,可能存在误清理情况。

首先,当一个白色对象更改为被黑色对象引用,就会被误清理。演示过程如下:

某时刻状态:

image.png

由于并发运行,其它业务可能取消对象B引用对象D,而更改为被对象E引用了。

image.png

最终GC结果如下:

image.png

因为对象E已经标记为黑色了,不会再对子对象扫描,那对象D就一直标记为白色了,最终会被GC误清理了。

怎么解决呢?该问题关键在于对象D始终为白色,无法被扫描到。

因此Go采用了删除屏障的方法,当一个对象被取消指针引用时,自身会被标记为灰色。也就是说,对象D被对象B取消引用时,自身会被标记为灰色。

image.png

之后对象E引用对象D,扫描到对象D时,D就会标记为黑色了,从而避免误清理情况出现。

插入屏障

还有个问题,如果插入一个新对象H,该对象被黑色标记的对象E引用了怎么办呢?如下图:

image.png

GC也不会扫描到对象H,使对象H始终为白色标记,从而误删除。GC结果如下图:

image.png

由于对象H为白色标记,导致误删除。

怎么解决?该问题关键也是在于对象H始终为白色,无法被扫描到。

因此Go采用了插入屏障的方法,当一个黑色标记的对象引用一个新对象时,新对象会被立即标记为灰色。如下图:

image.png

当黑色对象E指向新对象H时,H会被置灰,便不会被误清理了。

已经被引用的对象取消了指针引用,会触发删除屏障,将该对象置灰。

插入的新对象被黑色标记对象引用时,会触发插入屏障,将新对象置灰。

GC触发的时机

那GC在什么时候触发呢?有三个时机:

  • 系统定时触发
  • 用户主动调用
  • 申请新内存时触发

系统定时触发调用了sysmon定时检查,如果两分钟内没有GC,则触发。

用户通过调用runtime.GC()来主动触发GC。

申请新内存时调用了mallocgc方法,也会触发GC。

结语

以前就听过三色标记法,今天终于入门了。

至此,堆内存结构,分配和回收堆内存都已完毕!