垃圾回收


如果一种编程语言,使用者不需要考虑对象的释放,则需要一种机制自动进行对象的回收,这一过程称为垃圾回收(Garbage Collection,简称GC)。垃圾回收能够及时的将不使用的内存归还给操作系统,从而达到循环使用内存的效果。垃圾回收有2类主流的实现机制:

引用计数法:每个对象有一个引用计数器,当对象增加一个引用者,计数器加1,引用者释放应用,则计数器减1,当计数器减为0时,释放对象。

可达性分析法:从根对象开始,通过对象的引用关系遍历所有的对象,当对象可触达则说明对象正在被使用,否则对象处于无对象引用,则回收。

引用计数法无法解决循环依赖的问题,所以基本上所有的主流开发语言都使用可达性分析法实现GC。其中最常用的是三色标记法,包括Java,Go等语言都使用这种方式。

三色标记法

三色标记法是指用三种颜色标记不同状态的对象,通过遍历所有对象筛选出不在使用的对象。三种颜色定义如下:

  • 白色:初始值,所有对象初始颜色都是白色,如果一次遍历完,对象颜色还是白色,则该认为对象不可达。
  • 灰色:中间状态,该对象被其它对象引用,但该对象引用的对象尚未检测完。
  • 黑色:该对象被其它对象引用,且其引用的对象也完成检测。

image-20220603172437465

如上图所示,三色标记法的步骤如下:

  1. 创建白,灰,黑三个集合
  2. 将所有对象置于白色集合中
  3. 从根节点开始遍历,将引用的对象设置成灰色,并放置灰色集合中
  4. 遍历灰色集合中的节点,将节点设置成黑色,并放置黑色集合中,同时将其引用节点标记成灰色,并放置灰色集合中
  5. 重复4直到灰色集合中无节点
  6. 白色集合中剩余的节点则为未被引用节点,可回收

三色标记法看似完美,但仍存在缺陷导致多标记和漏标记。

多标记

image-20220603181247692

如上图所示,在扫描C对象前,C解除引用E对象但B对象引用E对象,按照三色标记法,E对象会被认为无对象引用,可以回收。但是实际上B引用E对象,不应该被回收,E对象被多标记。

漏标记

image-20220603181258691

如上图所示,当扫描C对象前,B解除引用C,按照三色标记法,接下来C,E对象会被设置成黑色,不会被回收。但是实际上C对象无对象引用,C,E对象都可以回收,则C,E对象被漏标记。

对于漏标场景,在下一个GC周期会继续将对象回收掉,是可以接受的。但是对于多标记,则会直接导致数据被误删,程序异常,是不可接受的。

为了避免多标记问题,一般采用写屏障机制来实现,写屏障即在操作数据前做些特殊操作避免标记异常。写屏障遵循以下两种原则之一:

  • 强三色不变式:强制性的不允许黑色对象引用白色对象
  • 弱三色不变式:黑色对象可以引用白色对象,但白色对象存在其它灰色对象对他的引用,或者可达它的链路上游存在灰色对象

对应于上述两种原则的写屏障分别称为插入写屏障和删除写屏障。插入写屏障是指对象A引用对象B时,如果对象A被设置成黑色,则设置B对象为灰色;删除写屏障是指对象A解除引用对象B时,设置对象B为灰色。两种方式都可以解决上述的多标记场景。

在Golang 1.8版本后,引入了混合写屏障机制解决多标记场景,其遵循如下原则:

  1. GC 开始则将栈上的对象全部标记为黑色
  2. GC期间,任何创建新对象均置为黑色;被删除指针对象标记为灰色;被添加指针对象标记为灰色

GC过程

Golang的垃圾回收实现过程如下:

  1. GC执行清扫终止(sweep termination)

    a. 停止世界(Stop The World, STW),这回让所有的P达到GC安全点

    b. 清扫所有未清扫过的span(将不使用对象释放),只有在强制GC的情况下才允许有未清扫span存在

  2. GC 执行标记阶段(mark)

    a. 执行标记准备:设置GC阶段为_GCmark,使能写屏障,使能存取辅助,将GC Root入队。在所有的P都使能写屏障后才启动扫描对象。

    b. 启动世界(Start The World)。至此,GC标记工作协程由调度器启动,申请内存时执行存储协助,写屏障同时标记被删除指针对象和新添加的指针对象为灰色。新申请对象则立即被置为黑色。

    c. GC执行根标记工作,扫描所有的栈,着色所有全局变量,着色所有堆指针,扫描栈将停止协程工作,并着色栈上所有指针,处理完后重新启动协程。

    d. GC依次取出灰色对象队列中的对象,并逐个置为黑色,并将对象中指向的指针置为灰色(将这些指针添加到灰色对象队列中)。

    e. 由于GC是多任务的,所以GC使用了一种分布式终止算法来检测所有的根标记任务和灰色兑现都扫描完成。如果都已经扫描完成,GC将进入标记终止状态。

  3. GC 执行标记终止(mark termination)

    a. 停止世界(Stop The World,STW)。

    b. 设置GC为_GCmarktermination阶段,停止GC标记工作协程和存取协助。

    c. 执行整理事务如清理mcache。

  4. GC 执行清扫阶段(sweep)

    a. 设置GC为_GCoff阶段,设置清扫状态,并且停止写屏障。

    b. 启动世界(Start The World),至此,新申请的对象将置为白色,同时,必要情况下,申请内存将触发先清扫span后使用内存。

    c. GC在后台或者在申请内存过程中并行(不影响正常的协程运行)清扫span(将不使用对象释放)。

GC过程如上所述,每一轮GC都依次执行上述4个步骤完成GC。仔细理解上述步骤,不免有会有如下疑问:

  1. 如何识别GC根对象?
  2. 如何识别对象中引用的指针对象?
  3. GC的触发条件是什么?

在解释上述问题前,希望我们能达成一个共识:GC的对象是堆(heap)上申请的内存,堆上内存都由span管理。

GC根对象

GC根对象包含栈上(stack)对象,赋初值(data)和未赋初值(bss)全局变量三种。

引用指针对象

每个对象类型定义中有一个gcdata属性,记录对象的指针引用情况,在申请对象时,会将对象的gcdata信息记录到对象地址对应的arena.bitmap上。当扫描到一个对象地址时,通过arena.bitmap即可获悉该对象内存块中引用的指针信息。

GC触发条件

GC触发存在以下三种触发条件:

  1. 时间触发GC:默认2分钟执行一次GC
  2. 内存申请触发GC:当申请内存达到前一轮GC后内存的一定比率则触发GC
  3. 外部触发GC:外部要求启动新一轮的GC,已启动则跳过,比如代码中主动调用runtime.GC()触发GC