Note

Go 的垃圾回收机制

从三色标记、写屏障和并发标记清扫出发,解释 Go GC 的完整周期、触发条件、成本模型与调优方法。

省流

Go 标准运行时采用 无分代、无整理、并发式三色标记 清扫 GC。它从全局变量、各个 Goroutine 栈和运行时对象等根节点出发,沿指针找到所有可达对象;可达对象保留,不可达对象在清扫阶段变成可重新分配的空间。原因在于:

  1. 对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 TCMalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。
  2. 分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。

标记过程使用三色抽象:白色表示还没有发现,灰色表示已经发现但子引用还没扫描,黑色表示自身和子引用都扫描完成。业务 Goroutine 在并发标记期间还会修改指针,所以运行时启用写屏障,防止一个白色对象被藏到已经扫描过的黑色对象后面而被误回收。Go 还会让分配速度过快的 Goroutine 承担一部分标记工作,也就是 Mutator Assist,避免 GC 永远追不上分配速度。

哪些内存由 GC 管?

GC 只管 Go 堆上的对象,注意,Goroutine 栈会被扫描,只不过 GC 只关注里面的堆对象:

位置回收方式GC 是否管理
Goroutine 栈函数返回或栈收缩后整片复用否
堆(heap)标记清扫:不可达才变成可再分配空间是

逃逸分析

仅当编译器无法证明对象仅存活于当前栈帧时,该对象才会分配到堆并由 GC 管理;否则分配在栈上,随栈帧回收。

逃逸分析在编译期判定对象的生命周期是否超出当前函数。可证明不超出则栈分配;无法证明则堆分配。因此,分代 GC 所依赖的大量短命堆对象,在 Go 中常因逃逸分析而根本不进入堆。

对象
  ├─ 生命周期可证明限于当前栈帧 -> 栈分配(非 GC)
  └─ 生命周期无法证明限于当前栈帧 -> 堆分配(GC)

典型导致堆分配的情形:返回局部变量地址、被生命周期更长的闭包捕获、写入生命周期更长的数据结构(如全局变量、结构体字段、经 channel 传递的值)、以及分配大小在编译期不确定。具体规则随编译器版本变化,应以 go build -gcflags=-m 与运行时 profile 核实。

非 GC 管辖范围的对象

文件描述符、网络连接、操作系统锁、mmap 映射等资源不由 GC 释放,须由程序显式关闭。Finalizer 不提供确定时刻的资源回收保证。

三色标记解决什么问题?

三色标记并没有在实际的 GC 实现中采用,而是在逻辑上的设计,之所以抽象为三色标记法也是为了方便我们理解。在 GC 的标记过程中,如果我们将对象的不同状态用颜色区分,那么三种颜色的对象分别表示:

颜色含义
白色本轮尚未证明可达,标记结束后仍为白色就可以回收
灰色已经可达,但它指向的对象还没有扫描完
黑色已经可达,而且它指向的对象也扫描完成

初始时对象都视为白色,根节点进入灰色集合,因此灰色节点也可以视为 波面边界,因为它是当前正在遍历的集合。GC 反复取出灰色对象,扫描它的指针,把发现的白色对象染灰,再把当前对象染黑。灰色集合清空后,剩余白色对象就是不可达对象。

Roots -> 灰色队列 -> 扫描子引用 -> 黑色
                         |
                         v
                    新发现的灰色对象

如果业务线程完全停止,这个算法很直接;难点在于 Go 需要一边标记,一边继续运行和修改对象图。

为什么需要写屏障?

并发标记期间 mutator(改写堆上指针关系的业务 goroutine)仍在运行。若不在堆指针写入时插入屏障,本仍可达的对象可能不再被标记工作队列找到,从而被误回收。写屏障在 *slot = ptr 生效前,将相关对象标灰,以维持三色不变量。

并发标记必须防止下列经典错误:GC 已将对象 A 染黑;mutator 把尚未扫描的白色对象 B 挂到 A 上,同时删掉原先通往 B 的唯一路径。若无屏障,GC 不会再扫描 A,B 保持白色并在清扫阶段被错误回收。

为此,指针写入必须在发布(publication)之前通知 GC。Go 编译器对可能写入堆(及全局)指针槽的赋值插入写屏障调用;栈帧内对当前帧的本地写入通常省略屏障。

强 / 弱三色不变量

不变量内容典型屏障
强三色不变量黑色对象不得直接指向白色对象Dijkstra 插入写屏障
弱三色不变量黑色可指向白色,但该白色必须仍能从某个灰色对象经白色指针链到达(grey-protected)Yuasa 删除写屏障;Go 混合写屏障

强不变量更严:写入时先 shade(ptr) 即可在堆上落实。局限在于栈:

  1. 当前栈帧的本地写入若也加屏障,开销过高,实践中通常不加。
  2. 栈扫完后 mutator 继续运行,栈上可能再次出现指向未标记对象的指针,因而不能假定栈保持黑色。
  3. 标记结束为维持强不变量,须在 STW 下重扫这些栈;goroutine 多、栈深时,暂停会明显变长。

弱不变量允许黑色对象暂时指向白色对象,前提是该白色对象仍能从某个灰色对象到达,因而最终仍会被标记。代价是:写屏障必须覆盖所有会导致漏标的指针改写;实现不完整就会误回收。弱不变量本身不缩短 STW——还需要“栈扫描一次后保持黑色”等策略,才能去掉标记结束时的栈重扫。

小结:强不变量易在堆上落实,但栈难保持黑色,结束阶段常需 STW 重扫栈;弱不变量对屏障与栈策略要求更高,若配合得当则可避免该重扫。Go 的混合写屏障采用弱不变量。

插入写屏障(Dijkstra)

对赋值 *slot = ptr:

writePointer(slot, ptr):
    shade(ptr)      // 先将新指针指向的对象标灰
    *slot = ptr

含义:写入前保证新指针 ptr 不是白色,从而维护强三色不变量(黑色对象不会直接指向白色对象)。

对 STW:

  • 标记开始即可并发扫描,无需为整堆做起始快照。
  • 当前栈的本地写入通常不加屏障(代价过高)。栈扫完后 mutator 继续执行,栈上可能再次出现未标记对象的指针,因此不能假定栈保持黑色。
  • 标记结束必须在 STW 下重扫这类栈。Go 1.5–1.7 使用该类屏障;活跃 goroutine 多时,重扫可达数十至数百毫秒。

删除写屏障(Yuasa)

writePointer(slot, ptr):
    shade(*slot)    // 先将即将被覆盖的旧指针指向的对象标灰
    *slot = ptr

含义:覆盖堆上旧引用前先将其标灰。本轮开始时可达的对象,即使引用随后被改掉,仍倾向于被标记(可能留下本轮已不可达但仍被标上的对象,即 floating garbage)。维护弱三色不变量。

对 STW:

  • 标记开始通常需要建立根/栈的起始快照或等价扫描,停顿偏在周期前端。
  • 标记结束不必为栈再做 STW 重扫:旧引用在被覆盖时已被保护。

混合写屏障(Go ≥ 1.8)

Go 1.8 起采用混合屏障(见 runtime/mbarrier.go 与 proposal 17503):

writePointer(slot, ptr):
    shade(*slot)                 // 删除侧:保护旧引用
    if current stack is grey:    // 本 goroutine 栈尚未扫描完
        shade(ptr)               // 插入侧:保护新引用
    *slot = ptr

配套规则:新分配对象标为黑色;每个 goroutine 的栈在标记阶段扫描一次后视为黑色,并保持黑色(当前帧本地写入不加屏障)。

两个 shade 分别防止两类漏标:

  1. shade(*slot):从堆上覆盖掉指向某对象的引用时,先将该对象标灰,避免唯一引用只剩在已扫描栈上、而堆侧已断开导致漏标。
  2. shade(ptr)(仅当本栈仍为灰色):向堆写入新指针时,先将新目标标灰,避免把栈上尚未处理完的引用写进已扫描完的堆对象后漏标。
  3. 栈变为黑色之后:其上指针均已处理过;再配合 shade(*slot),标记结束无需 STW 重扫栈。

混合屏障维护弱三色不变量:允许暂时出现黑→白,但该白色对象仍可通过灰色路径被找到。

插入(Dijkstra)删除(Yuasa)混合(Go 现行)
标记开始可并发扫栈;栈随后可能再变灰常需起始 STW 或快照栈可并发扫描一次后保持黑色
标记结束STW 重扫栈(可能很长)无需栈重扫无需栈重扫;仅保留启停屏障等短 STW
栈写屏障不加则必须重扫依赖起始快照当前帧本地写可省略
典型 STW重扫主导,可达 ms~百 ms成本偏在周期前端Go 1.8 后多为微秒级短暂停

混合屏障并不消灭 STW,而是去掉与 goroutine 数量、栈深度相关、且可能很长的标记终止栈重扫,使暂停主要来自启停屏障与少量收尾。

编译器如何插入写屏障

写屏障只在并发标记阶段开启(runtime.writeBarrier.enabled != 0)。编译器对可能写入堆或全局的指针赋值插入检查:屏障关闭则直接写;开启则先记录相关指针,再执行真正的 *slot = ptr(屏障在发布之前,即 pre-publication)。

amd64 上一次典型的堆指针写入大致如下(摘自运行时注释中的模式):

    CMPL    $0, runtime.writeBarrier(SB)
    JEQ     dowrite
    CALL    runtime.gcWriteBarrier2(SB)   // 预留缓冲,供写入旧指针与新指针
    MOVQ    AX, (R11)                     // 记录新指针 ptr
    MOVQ    off(CX), DX
    MOVQ    DX, 8(R11)                    // 记录旧指针 *slot(须在覆盖前读取)
dowrite:
    MOVQ    AX, off(CX)                   // *slot = ptr

要点:

  1. 混合屏障对单次指针赋值通常记录两个指针:旧值(删除侧)与 新值(插入侧)。批量复制或清零(如 typedmemmove)在真正改写内存前对涉及的指针槽做同样处理。
  2. 记录先进入每个 P 上的写屏障缓冲区,批量刷入 GC 工作队列后再 shade,以摊销开销。热路径因此接近“条件判断 + 少量入队”,而非每次完整走一遍标记逻辑。
  3. 当前帧的栈本地写入通常不插屏障;经指针间接写入、以及 全局变量 中的堆指针写入仍会插屏障,以免标记结束时再扫全局。

写屏障把一部分原本可能落在 STW 里的工作,转化为 标记期间每个堆指针写上的固定开销。低暂停不等于 GC 免费:成本转为并发标记 CPU、写屏障与 mutator assist。

一次 GC 周期

可以把一次周期概括为四步:

  1. Mark Setup:短暂 STW,开启写屏障和标记工作。
  2. Concurrent Mark:恢复业务 Goroutine,GC Worker 并行扫描对象图;分配过快的 Goroutine 执行 Mutator Assist。
  3. Mark Termination:再次短暂 STW,完成标记收尾并关闭写屏障。
  4. Sweep:并发或在分配路径上按需清扫未标记对象,把空闲槽位和 Span 交还给分配器复用。
STW           并发运行                         STW          并发/按需
Mark Setup -> Concurrent Mark + Assist -> Mark Termination -> Sweep

Go 1.26 起默认启用 Green Tea GC。它优化了小对象标记扫描的内存局部性和多核扩展性,但核心语义仍是并发、非移动的标记清扫,面试时不应把它说成分代或整理式 GC。

GC 什么时候触发?

  1. 堆增长目标:默认 GOGC=100,以上一轮 GC 后的存活堆为基准,允许再增长约 100% 后触发下一轮。GOGC 越小越频繁、峰值内存通常越低;越大则 GC CPU 通常越低、堆更大。
  2. GOMEMLIMIT:软内存上限,用于贴近容器限额;非硬隔离,非 Go 内存与仍存活对象仍可能导致超限。
  3. 运行时周期性回收,以及显式 runtime.GC()(仅适合阶段性任务或测试)。

GC 成本由什么决定?

主要由 存活对象与指针数量、分配速率、根集合大小(goroutine 栈与全局指针)、以及 内存目标松紧(如 GOGC / GOMEMLIMIT)决定,而非单纯“堆字节数越大越慢”。大而稳定、少指针的堆,GC CPU 往往低于高频分配大量短命指针对象的程序。

如何排查和优化?

  1. 用 go tool pprof 看 alloc_space 找累计分配热点,看 inuse_space 找当前存活对象和疑似泄漏。
  2. 结合 runtime/metrics、执行追踪和 GODEBUG=gctrace=1 观察 GC 频率、堆目标、暂停和 CPU 占比。
  3. 优先减少算法层面的分配,例如预估 Slice/Map 容量、复用缓冲区、避免热路径上的字符串拼接和装箱。
  4. 减少不必要的指针和超量 Goroutine,降低扫描工作,而不只是减少字节数。
  5. 最后才根据延迟目标和容器内存调整 GOGC、GOMEMLIMIT,并在真实流量下验证。

sync.Pool 适合跨请求复用临时对象,但其中元素可在任意 GC 周期被清除,不能当普通缓存或资源池使用。过度池化还会增加存活堆、保留大容量底层数组,反而使内存更差。

扩展问题

Go GC 是不是没有 STW?

不是。它追求的是短暂停,不是零暂停。标记开始、标记结束以及某些运行时操作仍需要 STW,大部分标记与业务并发执行。

GC 能解决内存泄漏吗?

在一个具有 GC 的语言中,我们常说的内存泄漏,用严谨的话来说应该是:预期的能很快被释放的内存由于附着在了长期存活的内存上、或生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。在 Go 中,由于 goroutine 的存在,所谓的内存泄漏除了附着在长期对象上之外,还存在多种不同的形式:

  1. 预期能被快速释放的内存因被根对象引用而没有得到迅速释放 当有一个全局对象时,可能不经意间将某个变量附着在其上且忽略将其进行释放,则该内存永远不会得到释放:
    var cache = map[interface{}]interface{}{}
    
    func keepalloc() {
      for i := 0; i < 10000; i++ {
        m := make([]byte, 1<<10)
        cache[i] = m
      }
    }
  2. goroutine 泄漏 Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象:
    func keepalloc2() {
      for i := 0; i < 100000; i++ {
        go func() {
          select {}
        }()
      }
    }

参考资料