Golang GC

Golang GC 有两种,非增量式垃圾回收和增量式垃圾回收.

  • 非增量式垃圾回收需要STW,在STW期间完成所有垃圾对象的标记,STW结束后慢慢的执行垃圾对象的清理。

  • 增量式垃圾回收也需要STW,在STW期间完成部分垃圾对象的标记,然后结束STW继续执行用户线程,一段时间后再次执行STW再标记部分垃圾对象,这个过程会多次重复执行,直到所有垃圾对象标记完成。

GC算法有3大性能指标:吞吐量、最大暂停时间(最大的STW占时)、内存占用率。增量式垃圾回收不能提高吞吐量,但和非增量式垃圾回收相比,每次STW的时间更短,能够降低最大暂停时间,就是Go每个版本Release Note中提到的GC延迟、GC暂停时间。

然而Golang GC STW的时候减少最大暂停时间还有一种思路:并发垃圾回收,注意不是并行垃圾回收。

并行垃圾回收是每个核上都跑垃圾回收的线程,同时进行垃圾回收,这期间为STW,会暂停用户线程的执行。

并发垃圾回收是先STW找到所有的Root对象,然后结束STW,让垃圾标记线程和用户线程并发执行,垃圾标记完成后,再次开启STW,再次扫描和标记,以免释放使用中的内存。

并发垃圾回收和并行垃圾回收的重要区别就是不会持续暂停用户线程,并发垃圾回收也降低了STW的时间,达到了减少最大暂停时间的目的。

  • 但是什么时候会触发 GC?

在堆上分配大于 32K byte 对象的时候进行检测此时是否满足垃圾回收条件,如果满足则进行垃圾回收。

  1. func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
  2. ...
  3. shouldhelpgc := false
  4. // 分配的对象小于 32K byte
  5. if size <= maxSmallSize {
  6. ...
  7. } else {
  8. shouldhelpgc = true
  9. ...
  10. }
  11. ...
  12. // gcShouldStart() 函数进行触发条件检测
  13. if shouldhelpgc && gcShouldStart(false) {
  14. // gcStart() 函数进行垃圾回收
  15. gcStart(gcBackgroundMode, false)
  16. }
  17. }

上面是自动垃圾回收,还有一种是主动垃圾回收,通过调用 runtime.GC(),这是阻塞式的。

  1. // GC runs a garbage collection and blocks the caller until the
  2. // garbage collection is complete. It may also block the entire
  3. // program.
  4. func GC() {
  5. gcStart(gcForceBlockMode, false)
  6. }
  • GC 触发条件

GC有3种触发方式:

  1. 辅助GC

在分配内存时,会判断当前的Heap内存分配量是否达到了触发一轮GC的阈值(每轮GC完成后,该阈值会被动态设置),如果超过阈值,则启动一轮GC。

  1. 调用runtime.GC()强制启动一轮GC。

  2. sysmon是运行时的守护进程,当超过 forcegcperiod (2分钟)没有运行GC会启动一轮GC。

forceTrigger 是 forceGC 的标志,后面意思是当前堆上的活跃对象大于我们初始化时候设置的 GC 触发阈值。在 malloc 以及 free 的时候 heap_live 会一直进行更新,这里就不再展开了。

  1. // gcShouldStart returns true if the exit condition for the _GCoff
  2. // phase has been met. The exit condition should be tested when
  3. // allocating.
  4. //
  5. // If forceTrigger is true, it ignores the current heap size, but
  6. // checks all other conditions. In general this should be false.
  7. func gcShouldStart(forceTrigger bool) bool {
  8. return gcphase == _GCoff && (forceTrigger || memstats.heap_live >= memstats.gc_trigger) && memstats.enablegc && panicking == 0 && gcpercent >= 0
  9. }
  10. //初始化的时候设置 GC 的触发阈值
  11. func gcinit() {
  12. _ = setGCPercent(readgogc())
  13. memstats.gc_trigger = heapminimum
  14. ...
  15. }
  16. // 启动的时候通过 GOGC 传递百分比 x
  17. // 触发阈值等于 x * defaultHeapMinimum (defaultHeapMinimum 默认是 4M)
  18. func readgogc() int32 {
  19. p := gogetenv("GOGC")
  20. if p == "off" {
  21. return -1
  22. }
  23. if n, ok := atoi32(p); ok {
  24. return n
  25. }
  26. return 100
  27. }
  • forcegc

自动检测和用户主动调用, 除此之外 Golang 本身还会对运行状态进行监控,如果超过两分钟没有 GC,则触发 GC。监控函数是 sysmon(),在主 goroutine 中启动。

  1. // The main goroutine
  2. func main() {
  3. ...
  4. systemstack(func() {
  5. newm(sysmon, nil)
  6. })
  7. }
  8. // Always runs without a P, so write barriers are not allowed.
  9. func sysmon() {
  10. ...
  11. for {
  12. now := nanotime()
  13. unixnow := unixnanotime()
  14. lastgc := int64(atomic.Load64(&memstats.last_gc))
  15. if gcphase == _GCoff && lastgc != 0 && unixnow-lastgc > forcegcperiod && atomic.Load(&forcegc.idle) != 0 {
  16. lock(&forcegc.lock)
  17. forcegc.idle = 0
  18. forcegc.g.schedlink = 0
  19. injectglist(forcegc.g) // 将 forcegc goroutine 加入 runnable queue
  20. unlock(&forcegc.lock)
  21. }
  22. }
  23. }
  24. var forcegcperiod int64 = 2 * 60 *1e9 //两分钟
  • 垃圾回收的主要流程

为什么需要三色标记?

三色标记的目的,主要是利用Tracing GC(Tracing GC 是垃圾回收的一个大类,另外一个大类是引用计数)做增量式垃圾回收,降低最大暂停时间。原生Tracing GC只有黑色和白色,没有中间的状态,这就要求GC扫描过程必须一次性完成,得到最后的黑色和白色对象。在前面增量式GC中介绍到了,这种方式会存在较大的暂停时间。

三色标记增加了中间状态灰色,增量式GC运行过程中,应用线程的运行可能改变了对象引用树,只要让黑色对象直接引用白色对象,GC就可以增量式的运行,减少停顿时间。

什么是三色标记?

三色标记,通过字面意思我们就可以知道它由3种颜色组成:

  1. 黑色 Black:表示对象是可达的,即使用中的对象,黑色是已经被扫描的对象。

  2. 灰色 Gary:表示被黑色对象直接引用的对象,但还没对它进行扫描。

  3. 白色 White:白色是对象的初始颜色,如果扫描完成后,对象依然还是白色的,说明此对象是垃圾对象。

三色标记规则:黑色不能指向白色对象。即黑色可以指向灰色,灰色可以指向白色。

三色标记法,主要流程如下:

  1. 初始所有对象被标记为白色。

  2. 从 root 开始找到所有可达对象,标记为灰色,放入待处理队列。

  3. 遍历灰色对象队列,将其引用对象标记为灰色放入待处理队列,自身标记为黑色。

  4. 处理完灰色对象队列,直到没有灰色对象。

  5. 剩余白色对象为垃圾对象,执行清扫工作。

详细的过程如下图所示:

Golang GC - 图1

这里需要解释下:

  1. 首先从 root 开始遍历,root 包括全局指针和 goroutine 栈上的指针。

  2. mark 有两个过程。第一是从 root 开始遍历,标记为灰色。遍历灰色队列。第二re-scan 全局指针和栈。因为 mark 和用户程序是并行的,所以在过程 1 的时候可能会有新的对象分配,这个时候就需要通过写屏障(write barrier)记录下来。re-scan 再完成检查一下。

  3. Stop The World 有两个过程。第一个是 GC 将要开始的时候,这个时候主要是一些准备工作,比如 enable write barrier。第二个过程就是上面提到的 re-scan 过程。如果这个时候没有 stw,那么 mark 将无休止。

另外针对上图各个阶段对应 GCPhase 如下:

  • Off: _GCoff
  • Stack scan - Mark: _GCmark
  • Mark termination: _GCmarktermination