摘自杰瑞 · 赫尔曼的《你好,多莉》歌曲: 一天不学就没手感了,三天不学就不爱学了,两礼拜不学之前学的全忘了. ——B站 Zhan

Go语言中的GC

内容目录

Go语言中的GC

1. 什么是GC

GC全称是Garbage Collection 垃圾回收,主要指的是堆内存中的垃圾回收(主要指堆上不再使用的内存,将其清理掉,让其重新可用),栈内存由编译器自动分配和释放。

2. 为什么使用GC

因为在实际业务场景中,程序员释放内存需要非常小心,因为要对当前的引用关系十分清楚,否侧误删会导致严重后果。但是业务场景中引用关系极其复杂,程序员特别注意这些问题会大大降低开发效率。

随意一个能够自动识别并回收的GC机制就会大大提高程序员的开发效率,减少不必要的错误。如今Java,Go,Python都有GC机制,用这些语言开发会比没有的C,C++效率要高。

3. Go语言中的GC

3.1 发展历程

go语言也不是一开始就是使用的并发三色标记法(GC程序与用户代码并发执行)加上屏障技术来实现,其主要经历以下三个过程:

  • V1.3及之前:标记清除算法
  • V1.5:三色并发标记法
  • V1.8:混合写屏障机制

3.2 三色标记法

简单的标记清除算法会带来长时间的STW,为了解决这个问题,Go语言在V1.5版本,使用三色并发标记法来优化这个问题。

三色标记法将程序中的对象分为三类:白色、灰色和黑色

  • 白色:未被垃圾收集器访问到的对象,也就是潜在的垃圾对象。在回收开始阶段,所有对象都标记为白色;在回收结束后,所有白色对象均不可达,其内存将被释放
  • 灰色:已被垃圾收集器访问到的对象,但是垃圾收集器需要继续扫描它们的子对象,因为其可能存在指向白色对象的外部指针
  • 黑色:已被垃圾收集器访问到的对象,且其引用都已被扫描到,黑色对象中任何一个指针都不可能直接指向白色对象

标记过程如下:

  1. 初始状态:所有对象都是白色的
  2. 扫描根对象:从根对象开始扫描,将所有可达对象标记为灰色,并放入待处理集合中
  3. 处理灰色对象:从待处理集合中取出灰色对象,将它们引用的对象标记为灰色,并将这些
  4. 新标记的对象加入待处理集合中,同时将自身标记为黑色。

重复扫描:重复第3步,直到待处理集合为空。此时,所有白色对象都是不可达的垃圾对象,可以进行回收。

明白了三色标记的过程,还需要搞清楚垃圾回收最开始扫描的根集合究竟包含哪些对象,通常情况下根节点包含以下几个部分:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量
  2. 执行栈上的对象或指针:每个 goroutine 都包含自己的执行栈,这些执行栈上的对象包含栈上的变量及指向分配的堆内存区块的指针
  3. 寄存器中的变量:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块

三色标记对应的程序细节

其实在go语言的GC程序里面是没有所谓的黑色,白色,灰色属性的,这三个颜色属性认为抽象出来的概念,帮助我们理解三色标记的过程。
在go语言GC程序内部,对这三种颜色的对象状态的描述,是通过一个队列 + 掩码位图 来实现的:

  • 白色对象:对象所在 span 的 gcmarkBits 中对应的 bit 为 0,并且该对象不在扫描队列中

  • 灰色对象:对象所在 span 的 gcmarkBits 中对应的 bit 为 1,并且该对象在扫描队列中

  • 黑色对象:对象所在 span 的 gcmarkBits 中对应的 bit 为 1,并且该对象已经从扫描队列中处理并剔除掉

3.3 GC完整过程

GO语言GC 相关的代码在 runtime/mgc.go 文件中,一共分为分为 清除终止(SweepTermination),标记(Mark),标记终止(MarkTermination),和清除(Sweep)四个不同阶段,它们分别完成了不同的工作:

  1. 清除终止(SweepTermination)
    这个阶段的主要任务是结束上一个GC周期的清除工作,并为新的GC周期做准备。

    • 会进行STW,所有处理器P在这时会进入安全点(Safe point)。
    • 会启动后台 MarkWorker 协程。
  2. 标记(Mark)
    • 将GC状态从_GCoff更改为GCmar ,并且开启写屏障(Write Barrier)和协助线程(mutator assists),紧接着将根对象入队
    • 恢复用户程序的正常执行,标记进程(mark workers)和协助线程(mutator assists)开始自发地标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会直接标记成黑色。
    • 遍历灰色对象集合,将灰色对象标记为黑色,并将该对象指向的对象标记为灰色
    • 使用分布式终止算法(distributed termination algorithm)来检测剩余工作,即何时不再有根标记作业或灰色对象,如果没有了则转为标记终止(MarkTermination)阶段。
  3. 标记终止(MarkTermination)
    • 执行STW
    • 将GC状态切换至 _GCmarktermination,关闭 GC 工作线程以及 mutator assists(协助线程)
    • 清理处理器P上的缓存(mcache)
  4. 清除(Sweep)
    • 将 GC 状态切换至_GCoff,初始化清理状态并关闭写屏障(Write Barrier)
    • 恢复用户程序的正常执行,从此时开始所有新创建的对象会标记成白色
    • 后台并发清理所有的内存管理单元,当应用程序goroutine尝试在堆内存中分配新内存时,会触发该操作

3.4 GC触发时机

GO语言中GC的触发分为手动触发和被动触发两种

  1. 手动触发,通过调用 runtime.GC()来触发 GC,此调用阻塞式地等待当前 GC 运行完毕
  2. 被动触发,分为两种方式:
    • go后台有一系统监控线程,当超过两分钟没有产生任何 GC时,强制触发GC
    • 使用步调算法,通过内存增长的比例来触发GC,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启用GC
      • 我们可以通过debug.SetGCPercent(500)来修改步调,这里表示,如果当前堆大小超过了上次标记的堆大小的500%,就会触发
      • 而第一次GC的触发的堆内存临界值是4MB

总结:
1、监控线程 runtime.sysmon 定时调用
2、手动调用 runtime.GC 函数进行垃圾收集
3、申请内存时 runtime.mallocgc 会根据堆大小判断是否调用

3.5 小结

GO语言GC总体上来说是采用的并行三色标记法+混合写屏障机制来实现的,内存写屏障是由插入写屏障向混合写屏障过渡的,go语言在Go 1.7 之前其实就使用的是插入写屏障(Dijkstra Write barrier),在Go V1.8版本引入了混合写屏障。

  1. 插入写屏障没有完全保证完整的强三色不变式,由于性能影响,栈上对象没有开启写屏障,所以三色标记完成之后,最后必须 STW 重新扫描栈
  2. 混合写屏障消除了屏障过程中所有的 STW,不用 STW 扫描栈,但由于引入了删除想屏障,所以损失了一定的回收精度,其回收精度和删除写屏障的一致,比插入写屏障要低
  3. 混合写屏障扫描栈的方式是逐个暂停扫描的,不需要STW

对于小结1中缺陷主要是由于下面问题导致:
性能影响:因为栈空间的特点是容量小,但要求响应速度快,在栈上的对象增加写屏障,会大幅度增加写入指针的额外开销,所以栈空间的对象操作中不使用,而仅仅使用在堆空间对象的操作中。

重新扫描:这是由于在栈上没有开启写屏障可能导致错误回收,所以在全部三色标记扫描之后,要对栈重新进行三色标记扫描,但这次为了对象不丢失,要对本次标记扫描启动STW,直到栈空间的三色标记结束

发表评论