本文章为译文,原作者 Jesús Espino。在这里查看他关于 Go Runtime 的系列文章:Understanding the Go Runtime

首图

上一篇文章 中,我们探讨了 Go 调度器——goroutine 如何被多路复用到操作系统线程上、GMP 模型,以及运行时用来保持 CPU 忙碌的所有技巧。但我们还没有解决一个根本问题:所有这些 goroutine 都会分配内存,而必须有人来清理它们。这就是垃圾回收器的工作,也是我们今天要探讨的内容。

在本文中,我们将研究在 Go 1.26 中运行的垃圾回收器,该版本引入了 GreenTea GC。如果您使用的是更早版本的 Go,不用担心——整体结构是相同的。主要区别在于标记阶段,当我们讲到那里时,会简要说明旧方法的差异。如果您想了解更多关于 GreenTea 的信息,Michael Knyszek 在 GopherCon 2025 上做了一个关于它的精彩演讲

Go 的垃圾回收器是一个非移动、并发、三色、标记-清除的回收器。形容词很多——但它们都意味着什么呢?让我们逐一分解。

非移动意味着 GC 从不重新定位内存中的对象。一旦一个对象被分配到一个特定的地址,它将在其整个生命周期中保持不变。这是一件大事——它意味着指针保持有效,这使得像 unsafe.Pointer 和 cgo 互操作这类事情简单得多。一些垃圾回收器(如 Java 的 G1 或 ZGC)确实会移动对象以压缩内存并减少碎片,但 Go 采取了不同的方法:它依赖其 基于大小类的分配器 来最小化碎片,而无需移动任何对象。

并发意味着 GC 的大部分工作是在程序持续运行的同时完成的。它不会在整个回收周期中停止世界(stop the world)(暂停所有 goroutine 以便只运行 GC)——只有两次非常短暂的暂停。其余时间,GC goroutine 与你的应用程序 goroutine 一起运行,共享 CPU 时间。

三色指的是标记阶段使用的算法。每个对象在概念上被标记为白色、灰色或黑色——我们稍后会详细介绍。

标记-清除描述了两种主要操作:首先,标记所有仍然可达(存活)的对象;然后,清除堆并回收任何未被标记的对象(垃圾)。概念上简单,但在实践中很棘手——尤其是当你的程序在 GC 试图追踪指针的同时还在修改指针时。

注意:本文引用了 Go 内存分配器的概念——特别是 span、size classes 以及 mcache/mcentral/mheap 层次结构。虽然并非严格要求的阅读材料,但我鼓励你先阅读 内存分配器 这篇文章。

现在我们知道了 Go 使用哪种回收器,让我们看看它实际是如何工作的。整个过程被组织成一个包含四个阶段的循环。

垃圾回收的四个阶段

垃圾回收器分四个阶段运行。让我们像实时观看一样,完整地走一遍 GC 周期。

一切始于清除终止。正如我们稍后将看到的,清除是惰性完成的——对象不会一次性全部释放,而是在分配器需要内存时按需回收。这意味着当新的 GC 周期被触发时,可能还有来自上一个周期未完成清除的工作。因此,运行时短暂地停止世界,以完成任何剩余的清除工作,并确保堆处于干净状态。一旦完成,它会为新的标记阶段准备数据结构,并启用写屏障——这样 GC 就可以在其工作期间追踪任何发生的指针变化。

接着是标记阶段,这是真正工作的部分。世界再次启动,GC 与你的程序并发运行。它扫描根——goroutine 栈、全局变量和其他起点——并从那里追踪整个对象图。在此阶段,GC 旨在使用大约 25% 的可用 CPU,运行专用的 GC goroutine 与你的应用程序并行。

当所有可达对象都被追踪后,运行时再次停止世界,进行标记终止和清除准备。这个短暂的暂停会禁用写屏障,交换标记位图,并为清除做好一切准备。

最后,清除阶段开始——同样是并发的。GC 遍历堆,释放任何未被标记为可达的对象。此阶段实际上与正常程序执行的开始重叠,甚至可以与下一个GC 周期的开始重叠。

我们提到 GC 停止世界后做的第一件事就是启用写屏障。但写屏障究竟是什么,为什么 GC 需要它?

写屏障 (Write Barrier)

问题是这样的:GC 在你的程序仍在运行时追踪对象图。关键在于,GC 对每个对象只扫描一次——一旦它查看了一个对象内部并找到了它包含的所有指针,它就会将该对象标记为“已扫描”并继续前进。它永远不会再回头检查它。

现在想象你有一个 User 结构体,其中包含一个 Team 指针。GC 扫描了那个 User,看到它指向 Team A,将 Team A 标记为存活,并将 User 标记为已扫描。但随后你的程序运行并更改了 UserTeam 字段,使其指向 Team B——一个 GC 尚未访问的团队。由于 GC 已经扫描了 User 并且不会再看它,它不知道 Team B 现在可达。就 GC 而言,没有任何东西指向 Team B,因此它会将 Team B 作为垃圾回收——即使你的 User 正在积极地引用它。那将是灾难性的。

写屏障正是防止这种情况发生的机制。每次你的程序在 GC 周期期间向内存写入一个指针值时,写屏障都会拦截该写入,并确保被更改的指针与任何其他指针一样,经过标记过程——这样 GC 就不会错过它们。

Go 使用混合型 Yuasa-Dijkstra 写屏障(你可以在 src/runtime/mbarrier.go 中找到实现)。当发生指针写入时,屏障会标记旧目标(写入前指向的对象)和新目标(写入后指向的对象)为需要扫描。通过标记两者,GC 确保了无论突变和扫描的顺序如何,它都不会遗漏任何对象。这就是允许 GC 并发运行而不遗漏可达对象的关键。

写屏障在 GC 周期期间为每次指针写入增加了少量开销——但这是为并发回收付出的代价。没有它,我们将需要在整个标记阶段停止世界。

有了写屏障,GC 就可以安全地开始追踪对象图,同时你的程序继续运行。让我们看看它使用的算法。

三色标记算法 (Tri-Color Marking Algorithm)

GC 使用三色标记算法。堆中的每个对象在概念上都被分配了三种颜色之一:

  • 白色:尚未访问——可能是垃圾
  • 灰色:已知是存活的,但尚未扫描其指针——在工作队列中
  • 黑色:存活且已完全扫描——其所有引用都已被追踪

算法开始时,将标记为灰色——这些对象是肯定存活的,因为有一些东西直接引用着它们(我们稍后会看到这些根具体是什么以及 GC 如何找到它们)。然后它重复地取出一个灰色对象,扫描它指向其他对象的指针,将任何白色目标标记为灰色,并将已扫描的对象标记为黑色。当没有灰色对象剩余时,标记完成——任何仍为白色的对象都是垃圾。

关键的不变性是,没有黑色对象可以指向白色对象。我们刚刚讨论的写屏障正是用来维持这个不变性的,它允许你的程序与 GC 并发运行。

现在让我们看看标记阶段在实际中是如何进行的,从头开始。

标记阶段 (Mark Phase)

标记阶段是垃圾回收器中最复杂、最有趣的部分。它与你的程序并发运行,使用专用的 GC goroutine,目标是占用大约 25% 的可用 CPU。

标记阶段已经开始,GC 已准备好追踪对象图。但它从哪里开始呢?

查找根 (Finding the Roots)

在 GC 可以追踪对象图之前,它需要找到起点——那些肯定存活的对象。这些就是,它们来自几个地方:

  • Goroutine 栈:每个 goroutine 都有一个栈,栈上任何指向栈外的指针都是一个根。GC 遍历 allgs(我们在 调度器文章 中看到的所有 goroutine 的全局列表)的快照,并扫描每个存活的 goroutine 的栈以查找所有指针值。这可能需要短暂暂停每个 goroutine(一次一个,而不是全部)以获取其栈的一致快照——不过已经停止的 goroutine(例如,在 channel 上阻塞或等待 I/O)可以立即扫描而无需暂停它们。
  • 全局变量:包含指针的包级变量是根。链接器为二进制文件的 .data.bss 段生成位图——这些分别是已初始化和未初始化的全局变量所在的内存区域——告诉 GC 这些段中的哪些字是指针,从而无需猜测即可高效地扫描全局变量。如果你对 Go 二进制文件的结构感兴趣,可以看看 这个关于 Go 二进制文件底层形态的演讲
  • 终结器 (finalizers) 和清理器 (cleanups):Go 允许你为对象附加清理函数——通过 runtime.SetFinalizer 或更新的 runtime.AddCleanup(在 Go 1.24 中引入)。例如,当不再需要某个对象时,你可以使用这些来关闭文件描述符或释放 C 资源。GC 会跟踪这些注册,并将关联的对象视为根,使它们保持存活状态,直到其终结器或清理函数有机会运行。

通过根发现的每个堆对象都会被添加到 GC 的工作队列中,准备进行扫描。从这些起点开始,GC 将追踪整个可达对象图。

但这些发现的对象都去了哪里?GC 需要一个地方来跟踪它仍需完成的工作。

工作队列 (Work Queue)

当 GC 发现需要扫描的对象时——从根开始,然后是这些根指向的每个对象,依此类推——它需要某个地方来跟踪所有待处理的工作。把它想象成一个待办事项列表:每次 GC 发现一个新对象,它就会将“扫描这个对象”添加到列表中,GC 工作线程会一直从列表中取出项目,直到列表为空。

每个 P(处理器)都有自己的本地工作队列——gcWork 结构体。拥有每个 P 的队列意味着 GC 工作线程主要在其本地数据上操作而无需锁,这在多个线程并发标记时很重要。

队列中有两种类型的项。对于非常大或非常小的对象,GC 将单个对象指针排队——一个直接的“扫描这一个对象”的条目。

但对于大多数小对象(构成典型 Go 分配主体的 16-512 字节范围),GC 做了更聪明的事情:它不单独排队每个对象,而是将包含它们的整个 span(一块连续的内存,包含相同大小类的对象)排队。当 GC 处理那个 span 时,它会一次扫描该 span 中所有已标记的对象。由于这些对象在内存中彼此相邻,这比在堆中跳来跳去、这里扫描一个对象、那里扫描另一个对象对 CPU 缓存要友好得多。

span 队列使用 FIFO(先进先出) 策略,这是一个有意的选择。LIFO 栈会在 span 入队后立即处理它们,但 FIFO 让 span 在队列中等待一段时间。为什么这样好?因为当 span 在等待时,同一个 span 中更多的对象可能会被发现和标记。到 GC 处理它时,可能会有一整批对象准备好一起处理——更好的缓存局部性、更少的每个对象开销,以及有机会对非常密集的 span 使用 SIMD 指令。

1.26 之前的差异:在 Go 1.26 之前,GC 只有单个对象队列。基于 span 的队列是 1.26 中引入的 GreenTea 垃圾回收器的主要创新——它使得批量扫描以及随之而来的所有性能提升成为可能。

现在我们知道了工作是在哪里被追踪的,让我们看看 GC 如何决定一个对象是否被标记。

标记位如何存储 (How Mark Bits Are Stored)

GC 需要追踪关于每个对象的两件事:它是否已被发现(我们找到了指向它的指针),以及它是否已被扫描(我们查看了其内部以寻找更多指针)。它需要存储这些信息的地方——而存储位置对性能影响很大。

对于符合条件的小对象(大致在 16-512 字节范围内),GC 将这些元数据存储为内联标记位——一个小的结构,紧贴在 span 本身的末尾,紧挨着它所描述的对象。这对 CPU 缓存局部性非常有利:当 GC 扫描 span 中的对象时,标记元数据已经位于同一内存区域,因此很可能已经在缓存中。

1.26 之前的差异:在 Go 1.26 之前,所有对象都使用存储在 mspan 结构体中其他位置的独立 gcmarkBits 位图。这里描述的内联方法是 Go 1.26 引入的主要变化之一。

内联标记位结构体(在 src/runtime/mgcmark_greenteagc.go 中定义)包含两个位图——一个叫做 marks,另一个叫做 scans。为什么是两个?因为它们追踪不同的事物,保持它们分开是实现延迟扫描的关键。

当 GC 发现指向一个对象的指针时,它会在 marks 位图中设置该对象的位——“我们知道这个对象存在且存活。”但它不会立即扫描该对象。相反,它将 span 排队(正如我们在工作队列部分看到的)并继续前进。稍后,当 GC 处理那个 span 时,它会查看哪些对象被标记了但尚未扫描——这恰好是 marks 减去 scans。它扫描这些对象,然后更新 scans 位图以记录它们已被处理。

这种双位图方法意味着 GC 可以在扫描任何对象之前,在同一个 span 中累积许多标记的对象。而且,如果同一个 span 中的新对象在它排队等待期间被发现,它们将被包含在下一批中——无需重新将 span 入队。

不在 16-512 字节范围内的对象——非常小的对象和大的对象——不使用内联标记位。它们回退到存储在 mspan 结构体中的传统独立位图,这在工作原理上相同,但没有缓存局部性的好处。

我们知道 GC 如何追踪哪些对象被标记和扫描。但当它实际查看一个对象内部时,它如何知道哪些字节是指针,哪些只是数据呢?

GC 如何知道指针在哪里 (How the GC Knows Where Pointers Are)

当 GC 扫描一个对象时,它需要知道该对象内的哪些字是指针,哪些只是普通数据(整数、浮点数、字节字符串)。它不能将每个 8 字节的字都视为指针——那会导致误报并使死对象保持存活状态。

答案在于指定位图(也称为堆位)。编译器知道每个结构体的布局,并在编译时将哪些字段是指针的信息编码到类型的 GC 元数据中。在扫描时如何使用这些元数据取决于对象的大小:

对于小对象(16 到 512 字节),运行时会将该类型的指定位图复制到 span 的末尾(当对象被分配时)。这意味着位图在物理上紧邻它所描述的对象——对 CPU 缓存性能非常有利。当 GC 扫描这些对象之一时,它会直接从 span 读取位图。

对于较大的对象,根本不会存储位图。相反,GC 直接读取类型的 GC 元数据,并在飞行中将其“平铺”到对象上——根据需要重复类型的指针模式多次以覆盖整个对象。这避免了为可能具有非常重复布局的大型分配(想想一个大的结构体数组)浪费位图内存。

在这两种情况下,GC 都只遍历被标记为指针的字。对于它找到的每个指针,它会尝试将该目标推迟到基于 span 的扫描。

现在我们有了所有的部分——工作队列、标记位、指定位图。让我们把它们放在一起,看看当 GC 遇到一个指针时,端到端会发生什么。

标记和扫描一个 Span (Marking and Scanning a Span)

当 GC 发现一个指向对象的指针时,实际发生了什么?让我们一步步来看。关键函数是 tryDeferToSpanScan()

首先,GC 检查该 span 是否使用内联标记位——一次快速的位图查找。如果不使用(因为对象太大或太小),GC 会回退到单独扫描对象。如果使用,GC 会计算这个指针指向 span 中的哪个对象(简单的算术,因为一个 span 中的所有对象大小相同),并原子地设置它的标记位。

对于完全不包含指针的对象(比如一个 [256]byte 或一个只有整数的结构体),有一个很好的捷径。它们位于 noscan span 中——GC 只标记它们然后继续,因为里面没有东西需要扫描。

对于确实包含指针的对象,GC 需要将该 span 排队以便稍后扫描。但这里有一个并发挑战:多个 GC goroutine 可能同时发现同一个 span 中的不同对象。我们不希望同一个 span 被多次排队——那样会浪费资源。

GC 用一个简单的所有权协议来处理这个问题。每个 span 都跟踪自己是无主的、有一个标记还是有许多标记。第一个在无主 span 中标记对象的线程获得所有权并将其排队。任何后续在该 span 中标记另一个对象的线程只需设置标记位然后继续——span 已经在队列中了,当它被扫描时,所有累积的标记将被一起处理。一旦扫描完成,所有权被释放,如果发现新对象,循环可以重复。

这种所有权状态还启用了一个快速路径:如果只标记了一个对象,GC 就直接扫描那一个。如果累积了许多对象,它会比较 marksscans 位图,以精确找出哪些对象仍需要扫描。

1.26 之前的差异:在 Go 1.26 之前,发现指针会将单个对象指针排入 LIFO 工作缓冲区。基于 span 的方法将空间上接近的对象批量处理,改善了 CPU 缓存局部性并支持 SIMD 优化。

我们已经看到单个 span 是如何被标记和扫描的。但谁来协调这一切?GC 又如何决定接下来做什么工作?

工作循环和扫描策略 (The Work Loop and Scanning Strategy)

所有这些标记和扫描工作都由一个工作循环驱动——gcDrain() 函数——每个 GC goroutine 持续运行,直到没有工作剩余。

循环以特定的优先级顺序检查工作:首先是本地对象,然后是本地 span(这两者都很快,因为不需要同步),然后是全局对象队列,最后是全局 span。如果所有这些都为空,它会刷新写屏障缓冲区(这可能会产生新的工作)并重试对象和 span。作为最后的手段,它会窃取工作——就像调度器为 goroutine 所做的那样。GC 以随机顺序遍历其他 P,并拿走受害者大约一半的可用 span,从而重新填充自己的本地队列。

当一个 span 最终从队列中取出时,GC 必须决定如何扫描它。如果只标记了一个对象,它就直接扫描那个对象——简单快速。如果累积了许多对象,GC 会根据密度选择策略:

  • 如果 span 是稀疏的(少于 12.5% 的对象被标记),GC 逐个遍历被标记的对象。当大多数槽位为空时,这避免了在空白内存上浪费时间。
  • 如果 span 是密集的(12.5% 或更多的对象被标记),GC 会切换到SIMD 优化的扫描,在 x86-64 上使用 AVX-512 指令。这可以并行处理多个对象——一次处理整个缓存行——比逐个扫描对象快 4-8 倍。在不支持 AVX-512 的平台上,密集路径不可用——GC 始终走稀疏路径,逐个扫描被标记的对象。

这正是 FIFO 队列和延迟扫描发挥作用的地方。通过让 span 在扫描前累积标记,GC 受益于更好的缓存局部性(即使在稀疏路径上,对象在内存中也彼此靠近),并且更有可能达到 SIMD 启动的密度阈值。对于扫描期间找到的每个指针,整个过程都会重复——标记目标,将 span 排队,继续——直到任何地方都不再有灰色对象。

专用的 GC goroutine 完成了大部分标记工作。但有时它们可能跟不上——那会发生什么?

标记辅助 (Mark Assist)

我们说 GC 的目标是将其专用标记 goroutine 的 CPU 使用率控制在 25% 左右。但是,如果你的程序分配内存的速度快于 GC 标记的速度,会发生什么?如果 GC 落后了,堆在周期完成之前就会无限制地增长。

这就是标记辅助发挥作用的地方。当一个 goroutine 在 GC 周期期间尝试分配内存时,运行时会检查 GC 是否跟得上。如果 GC 落后了,分配内存的 goroutine 会被征召来帮忙——在它获得内存之前,它必须先做一些标记工作。工作量与该 goroutine 分配的内存量成比例,因此分配多的 goroutine 贡献更多。

这创建了一个自然的背压机制:你的程序分配得越快,其 goroutine 就越可能被拉入标记辅助中,这会减慢分配速度,给 GC 时间赶上。从外部来看,这表现为 GC 期间分配操作的延迟增加——你的 goroutine 可能会在做一些标记工作后,才能获得它请求的内存。

你可以使用 Go 的执行跟踪器 (runtime/trace) 观察标记辅助的实际效果。如果你看到你的 goroutine 在 runtime.gcAssistAlloc 中花费时间,那就是标记辅助——GC 正在请求你的 goroutine 帮助,因为它们分配的速度比专用 GC 工作线程能跟上的速度更快。

细节很多——让我们缩小视野,看看所有这些部分是如何组合在一起的。

标记阶段回顾 (Mark Phase Recap)

让我们退后一步,将整个标记阶段视为一个流程:

  1. GC 识别——扫描 goroutine 栈(通过 allgs)、全局变量(使用链接器为 .data.bss 生成的位图)以及终结器/清理器注册。
  2. 每个根指针进入工作队列——要么作为单个对象,要么作为 span 条目。
  3. 工作循环从队列中拉取项目。对于 span,GC 在内联标记位图中设置标记位,并推迟扫描,直到同一 span 中累积了更多对象。
  4. 当一个 span 被出队进行扫描时,GC 使用指定位图来找出哪些字是指针,并选择一个扫描策略——稀疏 span 逐个扫描,密集 span 则使用 SIMD 加速。
  5. 扫描期间发现的任何新指针返回步骤 2,继续循环。
  6. 同时,写屏障拦截来自应用程序的指针修改,将更改后的指针反馈回标记过程,以免遗漏。
  7. 如果 GC 落后,标记辅助会征召分配内存的 goroutine 来执行标记工作,从而产生背压。
  8. 当任何 P 的队列中都没有工作剩余时——本地队列、全局队列,并且没有可窃取的工作——标记阶段完成。

一旦所有灰色对象都被处理完毕且没有剩余工作,标记阶段就结束了。运行时最后一次停止世界,以完成标记并为清除做准备。

标记终止和清除准备 (Mark Termination and Sweep Preparation)

这是一个连续的停止世界暂停。运行时首先确认没有标记工作剩余——它最后一次刷新写屏障缓冲区,并确认所有工作队列都确实为空。它还刷新每个 P 的 mcache 中的分配计数器,以便调节器(pacer)(决定何时触发下一个 GC 周期的组件——我们稍后会详细讨论)拥有下一周期的准确数字。然后禁用写屏障,因为标记已完全完成,指针写入不再需要被拦截。最后,运行时设置清除状态,以便清除器知道要处理哪些 span,并重新启动世界。

随着世界再次运行,是时候回收垃圾了。但清除阶段不会一次性急切地清除整个堆。

清除阶段 (Sweep Phase)

清除阶段与你的程序并发运行。它的工作是遍历堆,并回收任何未被标记为可达的对象。但在 span 层面,“回收”到底意味着什么?让我们深入了解一下。

每个 span 维护两个位图:allocBitsgcmarkBitsallocBits 位图跟踪 span 中哪些槽位当前保存着已分配的对象。gcmarkBits 位图——在标记阶段刚刚填充——跟踪这些对象中哪些仍然是可达的。

当清除器处理一个 span 时,它首先会处理标记阶段使用了内联标记位的 span 的关键步骤:将内联标记合并到传统的 gcmarkBits 位图中。清除阶段使用 gcmarkBits,而不是内联位,因此清除器调用 moveInlineMarks(),通过简单的 OR 操作将内联标记复制过来。在此合并过程中有一个很好的安全检查:GC 验证 marksscans 位图是否相同。如果任何对象被标记但从未被扫描,则出现了严重错误——GC 错过了一个可达对象,运行时将 panic,而不是静默地收集活动数据。

gcmarkBits 更新后,清除器比较两个位图。如果一个槽位的 allocBits 被设置(那里分配了东西),但其 gcmarkBits 被设置(GC 未发现它是可达的),则该对象是垃圾。然后清除器allocBits 替换为 gcmarkBits 的副本——实际上,“已分配”的集合变成了“存活”的集合。所有垃圾槽位简单地从 allocBits 中消失,这些槽位现在可以被分配器重用。

这就是为什么 Go 的 GC 是非移动的:幸存的对象不会去任何地方。它们就留在原处,而被释放的槽位则变得可用于同一 span 内的未来分配。如果一个 span 中的所有对象都是垃圾——gcmarkBits 全为零——那么整个 span 可以返回到页面分配器,以供不同的大小类重用,甚至返回给操作系统。

清除是惰性工作的——span 在分配器需要它们时按需清除。关键的保证是:span 在被用于分配之前总是被清除的。当一个 goroutine 请求内存时,分配器必须在分配槽位之前,清除它遇到的任何未清除的 span,确保 allocBits 首先与 gcmarkBits 正确协调。为了使这成为可能,在世界重启后,每个 P 的 mcache 都会被刷新——每个 P 在下次分配之前,将其缓存的 span 释放回 mcentral。这迫使每个 P 通过正常路径重新获取 span,这保证了它们首先被清除。最终效果是清除成本分散到正常的分配中,而不是一次性发生。

除了分配器驱动的清除外,运行时还有一个专用的后台清除器 goroutine (bgsweep),它在标记终止结束时被唤醒。它分批处理未清除的 span,即使没有分配发生时也会进行,并在完成时自行休眠。即使它没有处理完所有内容也没关系——分配内存的 goroutine 无论如何都会清除它们需要的内容。

即使有后台清除,到下一个 GC 周期触发时,一些 span 可能仍然未被清除——因此在每个周期开始时存在清除终止阶段,以完成任何剩余的工作,并确保在标记再次开始之前堆处于干净状态。

我们已经看到了从开始到结束的完整 GC 周期——但我们从未解决一个基本问题:是什么首先触发了一个新周期?

什么触发了 GC 周期?(What Triggers a GC Cycle?)

垃圾回收周期可以通过三种方式启动:

最常见的是自动触发,由你的程序使用多少内存驱动。

GC 调节器 (The GC Pacer)

GC 调节器是运行时内置的机制,用于决定何时启动下一个周期。它的目标是在堆增长过大之前足够早地开始回收,但又要足够晚,以免在不必要的周期上浪费 CPU。如果你想深入了解调节器的工作原理,Madhav Jivrajani 做了一个关于它的精彩演讲

调节器由两个旋钮控制:

  • GOGC(默认值:100):以百分比形式设置堆增长目标。值为 100 意味着当堆增长到大约上一周期存活数据大小的两倍时,GC 将触发。将其设置为 200 允许堆增长到三倍才进行回收,这降低了 GC 频率,但代价是使用更多内存。将其设置为 50 会更快触发回收,使用更少内存但花费更多 CPU 在 GC 上。
  • GOMEMLIMIT:为 Go 堆设置一个绝对内存限制。当堆接近此限制时,GC 会变得更加激进——更早触发并使用更多 CPU 以将内存保持在限制之下。这在你有硬性内存预算的容器化环境中非常有用。

调节器作为分配路径的一部分运行,但它不会在每个单独的对象分配时都进行检查——那样成本太高。相反,触发检查发生在分配器需要从 mcentral 获取一个新的 span 时(这意味着当前的 span 槽位已用完)。对于大对象,每个对象都有自己的 span,检查在每次分配时无条件发生。在实践中,这足够频繁,使得 GC 在需要时及时启动,同时避免了在每个微小分配上进行检查的开销。

但是,如果你的程序空闲下来并停止分配呢?调节器只在分配期间进行检查,所以它永远不会触发——即使堆里充满了上一次活动爆发留下的垃圾。

系统监控器 (The System Monitor)

这就是系统监控器 (sysmon) 发挥作用的地方。我们在 调度器文章 中看到,sysmon 是一个后台线程,定期检查运行时的健康状况。它的工作之一是在距离上次 GC 时间过长时强制进行 GC 周期——通常是在超过 2 分钟没有进行回收的情况下。这确保了即使是空闲的程序最终也会清理其堆。

还有另一种方式——你也可以自己掌控。

显式调用 (Explicit Calls)

你可以通过调用 runtime.GC() 手动触发 GC 周期。无论堆大小或时机如何,这都会强制进行一个完整的回收周期。这对于基准测试或当你已知一大批对象刚刚变得不可达并且你想立即回收内存时偶尔有用——但通常情况下,你应该相信调节器会做正确的事情。

这就涵盖了所有内容——从触发 GC 周期的原因到它如何标记、终止和清除。让我们来总结一下。

总结

Go 的垃圾回收器是一个非移动、并发、三色、标记-清除的回收器,它设法在不停止程序的情况下完成大部分工作。两次停止世界的暂停——一次用于设置标记阶段,一次用于最终确定——都非常短暂。

整个周期在 GC 调节器认为堆增长得足够大时开始(或者 sysmon 强制进行回收,或者有人调用 runtime.GC())。运行时短暂地停止世界以完成任何遗留的清除工作,启用写屏障,并启动并发的标记阶段。

在标记期间,GC 找到根——goroutine 栈、全局变量以及终结器/清理器注册——并从那里追踪整个对象图。它使用一个工作队列,其中每个 P 都有自己本地的要处理的 span 队列。GC 不逐个扫描对象,而是按 span 进行批处理:它在 span 的内联位图中设置标记位,并推迟扫描直到多个对象累积起来。当一个 span 最终被扫描时,GC 根据密度选择策略——稀疏 span 逐个扫描对象,或者在 x86-64 上使用 AVX-512 指令扫描密集 span。写屏障在整个阶段运行,捕获应用程序的任何指针修改,并将它们反馈到标记过程中。如果程序分配内存的速度比 GC 标记的速度快,标记辅助会征召分配内存的 goroutine 来帮助完成工作。

一旦标记完成,运行时最后一次停止世界,以确认没有剩余工作,为调节器刷新分配计数器,禁用写屏障,并设置清除状态。然后清除开始——惰性地、并发地进行。每个 P 在下次分配前刷新其 mcache,强制在重用 span 前进行清除。当每个 span 被清除时,其内联标记位被合并到传统的位图中。一个专用的后台清除器 goroutine 帮助在分配之间进行清理,而下一个周期开始时的清除终止确保任何剩余的滞后工作得到处理。

想深入了解?请查看: