垃圾回收

Go 的垃圾回收(GC)器主要使用并发三色标记算法,结合写屏障(write barrier)保证并发阶段的一致性,最终释放无效对象。

什么是 GC

GC,全称 GarbageCollection,即垃圾回收,是一种自动内存管理的机制。

当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而负责垃圾回收的程序组件,即为垃圾回收器。

垃圾回收器的执行过程被划分为两个半独立的组件:

  • 赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上进行操作
  • 回收器(Collector):负责执行垃圾回收的代码。

常见的 GC 实现方式

所有的 GC 算法其存在形式可以归结为追踪(Tracing)和引用计数(Reference Counting)这两种形式的混合运用。

追踪式 GC

从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。Go、 Java、V8 对 JavaScript 的实现等均为追踪式 GC。

引用计数式 GC

每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式 GC。

标记清除

标记清除(Mark-Sweep)算法是最常见的垃圾收集算法,标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:

  1. 标记阶段:从根对象出发查找并标记堆中所有存活的对象;
  2. 清除阶段:遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表;
go-mark

从根对象出发依次遍历对象的子对象并将从根节点可达的对象都标记成存活状态,即 A、C 和 D 三个对象,剩余的 B、E 和 F 三个对象因为从根节点不可达,所以会被当做垃圾。

标记阶段结束后会进入清除阶段,在该阶段中收集器会依次遍历堆中的所有对象,释放其中没有被标记的 B、E 和 F 三个对象并将新的空闲内存空间以链表的结构串联起来:

go-sweep

这是最传统的标记清除算法,整个过程需要标记对象的存活状态,用户程序在垃圾收集的过程中也不能执行(STW,Stop the world)。

三色抽象

为了解决原始标记清除算法带来的长时间 STW,现代的追踪式垃圾收集器会实现三色标记算法的变种以缩短 STW 的时间。

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

  • 白色对象:潜在的垃圾,其内存可能会被垃圾收集器回收;
  • 黑色对象:活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
  • 灰色对象:活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;

在垃圾收集器开始工作时,程序中不存在任何的黑色对象,根对象会被标记成灰色,其他所有对象都是白色。

标记过程:

  1. 从灰色对象的集合中选择一个灰色对象并将其标记成黑色。
  2. 从黑色对象出发,扫描所有可达对象并标记为灰色,保证该对象和被该对象引用的对象都不会被回收。
  3. 重复 1,2,直到不存在灰色对象。

当三色的标记清除的标记阶段结束之后,应用程序的堆中就不存在任何的灰色对象,我们只能看到黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾

因为用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或者增量执行的,它仍然需要 STW

例如下图的三色标记过程中,用户程序建立了从 A 对象到 D 对象的引用,但是因为程序中已经不存在灰色对象指向 D 了,所以 D 对象会被垃圾收集器错误地回收。

color-mark

本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误(悬挂指针、野指针)。想要并发或者增量地标记对象还是需要使用屏障技术

并发垃圾收集

并发(Concurrent)的垃圾收集不仅能够减少程序的最长暂停时间,还能减少整个垃圾收集阶段的时间,通过开启写屏障、利用多核优势与用户程序并行执行

Go 在 v1.5 中引入了并发的垃圾收集器,该垃圾收集器使用了三色抽象和写屏障技术保证垃圾收集器执行的正确性。

屏障技术

内存屏障技术是一种屏障指令,它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束

要在并发或者增量的标记算法中保证正确性,需要达成以下两种三色不变性(Tri-color invariant)中的一种:

  • 强三色不变性:黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性:黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径。

垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码

屏障技术可以分为读屏障(Read barrier)和写屏障(Write barrier)因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性。

Go 使用了两种写屏障技术,分别是插入写屏障删除写屏障

插入写屏障

写屏障可以保证用户程序和垃圾收集器可以在交替工作的情况下程序执行的正确性。

例如,对象新增了引用:

p.child = q

问题: p 已被标记为黑色,q 是新发现的白色对象,没有进入标记队列。如果不处理,GC 会认为 q 是垃圾,导致黑 → 白指针存在,违反强三色不变性。

插入写屏障做的事:当黑对象赋值引用指向白对象时,立刻将白对象灰化(加入标记队列)。

if GC is marking && p is black && q is white {
    shade(q) // 把 q 放入灰色队列中,等待标记
}
p.child = q

插入写屏障将有存活可能的对象都标记成灰色以满足强三色不变性。可能导致有些对象不再存活了,但是垃圾收集器仍然认为对象是存活的,只有在下一个循环才会被回收。

删除写屏障

例如,对象删除了引用:

p.child = nil

假设 p 是黑色,原来 child 是白色对象 q,现在被删除。

问题:如果不处理,q 在标记期间,还没来得及灰化,就失去了从根对象的可达路径,GC 将误以为它是垃圾,黑 → 白引用断裂,发生漏标

删除写屏障做的事:在引用断开前,检查旧值是否是白对象,如果是就把它重新加入标记队列

old := p.child
if GC is marking && p is black && old is white {
    shade(old) // 保护旧值,把 q 放入灰色队列中,防止它被误回收
}
p.child = nil

删除写屏障在老对象的引用被删除时,将白色的老对象涂成灰色,这样删除写屏障就可以保证弱三色不变性。

混合写屏障

只有写屏障的问题:

在 Go v1.7 之前,运行时使用插入写屏障保证强三色不变性,但是运行时并没有在所有的垃圾收集根对象上开启插入写屏障。因为应用程序可能包含成百上千的 goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个 goroutine 的栈上都开启写屏障,会带来巨大的额外开销,所以 Go 在实现上选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描,在活跃 goroutine 非常多的程序中,重新扫描的过程需要占用 10~100ms 的时间

Go 在 v1.8 引入混合写屏障,移除了栈的重扫描过程。在垃圾收集的标记阶段,还需要将创建的所有新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。

许多现代 GC 使用混合写屏障,同时考虑旧值和新值,处理引用变更的所有情况。

它包含:

  • 插入写屏障逻辑(关注新引用是否为白)
  • 删除写屏障逻辑(关注旧引用是否为白)
wb(dst, oldValue, newValue) {
    if GC is marking {
        if oldValue is white {
            shade(oldValue) // 删除写屏障
        }
        if newValue is white && dst object is black {
            shade(newValue) // 插入写屏障
        }
    }
}

根对象到底是什么

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

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

对象图是什么

对象图是指:在 GC 扫描阶段,由程序中的一组根对象出发,通过对象间的指针引用关系,所形成的“对象之间可达性图”。

Go 并不会真的在运行时构建一个图结构,而是:

  • 编译器在生成代码时会为每个对象生成类型信息(如哪些字段是指针)
  • GC 在标记阶段通过栈变量、全局变量扫描到指针后,根据类型描述递归访问其他对象;
  • 最终形成一棵“由引用关系连接的”逻辑上的图。

有了 GC,为什么还会发生内存泄露

全局对象

当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放。例如:


var cache = map[interface{}]interface{}{}

func keepalloc() {
  for i := 0; i < 10000; i++ {
    m := make([]byte, 1<<10)
    cache[i] = m
  }
}

goroutine 泄漏

Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象。

func keepalloc2() {
  for i := 0; i < 100000; i++ {
    go func() {
      select {}
    }()
  }
}

验证

package main

import (
  "os"
  "runtime/trace"
)

func main() {
  f, _ := os.Create("trace.out")
  defer f.Close()
  trace.Start(f)
  defer trace.Stop()
  keepalloc()
  keepalloc2()
}

go-heap-test

Heap 在持续增长,没有内存被回收,产生了内存泄漏的现象。

goroutine 泄漏还可能由 channel 泄漏导致。而 channel 的泄漏本质上与 goroutine 泄漏存在直接联系。Channel 作为一种同步原语,会连接两个不同的 goroutine,如果一个 goroutine 尝试向一个没有接收方的无缓冲 channel 发送消息,则该 goroutine 会被永久的休眠,整个 goroutine 及其执行栈都得不到释放。

GC 实现

GC 的流程

以 STW 为界限,可以将 GC 划分为五个阶段:

阶段说明赋值器状态
GCMark标记准备阶段,为并发标记做准备工作,启动写屏障STW
GCMark扫描标记阶段,与赋值器并发执行,写屏障开启状态并发
GCMarkTermination标记终止阶段,保证一个周期内标记任务完成,停止写屏障STW
GCoff内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭状态并发
GCoff内存归还阶段,将过多的内存归还给操作系统,写屏障关闭状态并发

触发 GC 的时机是什么

Go 语言中对 GC 的触发时机存在两种形式:

  • 主动触发,通过调用 runtime.GC() 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕
  • 被动触发,分为两种方式:
    • 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。
    • 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。

优化

GC 的调优是在特定场景下产生的,并非所有程序都需要针对 GC 进行调优。只有那些对执行延迟非常敏感、 当 GC 的开销成为程序性能瓶颈的程序,才需要针对 GC 进行性能调优。

总的来说,可以在现在的开发中处理的有以下几种情况:

  1. 对停顿敏感:GC 过程中产生的长时间停顿、或由于需要执行 GC 而没有执行用户代码,导致需要立即执行的用户代码执行滞后。
  2. 对资源消耗敏感:对于频繁分配内存的应用而言,频繁分配内存增加 GC 的工作量,原本可以充分利用 CPU 的应用不得不频繁地执行垃圾回收,影响用户代码对 CPU 的利用率,进而影响用户代码的执行效率。

从这两点来看,所谓 GC 调优的核心思想:优化内存的申请速度,尽可能的少申请内存,复用已申请的内存。控制、减少、复用

降低并复用已经申请的内存

// 使用 sync.Pool 复用需要的 buf
var bufPool = sync.Pool{
	New: func() interface{} {
		return make([]byte, 10<<20)
	},
}

b := bufPool.Get().([]byte)

// ...

bufPool.Put(b)

调整 GOGC

GC 的触发原则是由步调算法来控制的,其关键在于估计下一次需要触发 GC 时,堆的大小。如果在遇到海量请求的时,为了避免 GC 频繁触发,可以通过将 GOGC 的值设置得更大,让 GC 触发的时间变得更晚,从而减少其触发频率,进而增加用户代码对机器的使用率。

GOGC=1000 ./main
最后更新于