简介
本指南旨在帮助高级 Go 用户更好地理解其应用程序的成本,通过提供对 Go 垃圾回收器的深入见解。 它还就 Go 用户如何利用这些见解来改善其应用程序的资源利用率提供了指导。 它不假设读者具备垃圾回收方面的知识,但假设读者熟悉 Go 编程语言。
Go 语言负责安排 Go 值的存储;在大多数情况下,Go 开发者无需关心这些值存储在哪里,或者为什么存储在那里。 然而在实践中,这些值通常需要存储在计算机的物理内存中,而物理内存是一种有限的资源。 由于其有限性,内存必须被仔细管理和回收,以避免在执行 Go 程序时耗尽内存。 根据需要分配和回收内存是 Go 实现的工作。
自动回收内存的另一个术语是垃圾回收。 从高层次上看,垃圾回收器(或简称 GC)是一个代表应用程序回收内存的系统,它通过识别内存的哪些部分不再被需要来实现这一点。 Go 标准工具链提供了一个随每个应用程序一起提供的运行时库,这个运行时库中就包含一个垃圾回收器。
请注意,本指南所描述的垃圾回收器的存在并非由 Go 语言规范所保证,规范只保证 Go 值的底层存储由语言本身管理。 这种省略是有意为之的,它允许使用截然不同的内存管理技术。
因此,本指南是关于 Go 编程语言的一个特定实现,可能不适用于其他实现。
具体来说,以下指南适用于标准工具链(gc Go 编译器及工具)。
Gccgo 和 Gollvm 都使用非常相似的 GC 实现,因此许多相同的概念适用,但细节可能有所不同。
此外,这是一个动态文档,会随着时间的推移而变化,以最佳地反映 Go 的最新版本。 本文档当前描述的是 Go 1.19 版本的垃圾回收器。
Go 值的存放位置
在我们深入探讨 GC 之前,让我们先讨论一下不需要 GC 管理的内存。
例如,存储在局部变量中的非指针 Go 值很可能根本不会被 Go GC 管理,Go 会转而安排分配一块内存,该内存与创建它的词法作用域绑定。 通常,这比依赖 GC 更高效,因为 Go 编译器能够预先确定该内存何时可以被释放,并生成执行清理工作的机器指令。 通常,我们把以这种方式为 Go 值分配内存称为“栈分配”,因为这些空间存储在协程的栈上。
其内存无法以这种方式分配的 Go 值(因为 Go 编译器无法确定其生命周期)被称为逃逸到堆上。 “堆”可以被视为内存分配的万能容器,用于当 Go 值需要被放置在某个地方时。 在堆上分配内存的行为通常被称为“动态内存分配”,因为编译器和运行时对此内存如何使用以及何时可以被清理能做的假设很少。 这就是 GC 的用武之地:它是一个专门识别并清理动态内存分配的系统。
一个 Go 值可能需要逃逸到堆上的原因有很多。 一个原因可能是其大小是动态确定的。 例如,考虑一个 slice 的后备数组,其初始大小由一个变量而非常量决定。 请注意,逃逸到堆上也必须是传递性的:如果一个对 Go 值的引用被写入另一个已被确定为逃逸的 Go 值中,那么该值也必须逃逸。
一个 Go 值是否逃逸取决于其使用的上下文以及 Go 编译器的逃逸分析算法。 试图精确地列举值何时逃逸是脆弱且困难的:该算法本身相当复杂,并且在 Go 的各个版本之间会发生变化。 关于如何识别哪些值逃逸、哪些值不逃逸的更多细节,请参阅消除堆分配部分。
跟踪式垃圾回收
```垃圾回收可能指代多种不同的自动内存回收方法,例如引用计数。在本文档的上下文中,垃圾回收特指跟踪式垃圾回收,它通过传递性地追踪指针来识别正在使用的(即所谓的活跃)对象。
让我们更严谨地定义这些术语。
-
对象—对象是一块动态分配的内存,其中包含一个或多个Go值。
-
指针—一个内存地址,它引用对象内的任何值。这自然包括
*T形式的Go值,但也包含内置Go值的部分。字符串、切片、通道、映射和接口值都包含GC必须追踪的内存地址。
对象和指向其他对象的指针共同构成了对象图。为了识别活跃内存,GC从程序的根(即那些明确标识程序正在使用的对象的指针)开始遍历对象图。局部变量和全局变量是两个根的示例。遍历对象图的过程称为扫描。你在Go文档中可能看到的另一个短语是对象是否“可达”,这意味着对象可以通过扫描过程被发现。另外请注意,除一种特殊情况外,一旦内存变得不可达,它将保持不可达状态。
这种基本算法是所有跟踪式GC共有的。跟踪式GC之间的差异在于它们发现内存活跃后所采取的措施。Go的GC使用标记-清扫技术,这意味着为了跟踪其进度,GC还会将它遇到的值标记为活跃。扫描完成后,GC会遍历堆中的所有内存,并将所有未标记的内存置为可分配状态。这个过程称为清扫。
你可能熟悉的另一种替代技术是将对象实际移动到内存的新位置,并留下一个转发指针,该指针随后用于更新应用程序的所有指针。我们称这种移动对象的GC为移动式GC;Go使用的是非移动式GC。
GC周期
因为Go的GC是标记-清扫式GC,它大致分为两个阶段运行:标记阶段和清扫阶段。虽然这个说法可能显得同义反复,但它包含一个重要的洞见:在所有内存都被扫描之前,无法释放内存以供分配,因为可能仍有一个未被扫描的指针在维持对象的生命。因此,清扫行为必须与标记行为完全分离。此外,当没有GC相关工作要做时,GC可能完全不活跃。GC在清扫、关闭和标记这三个阶段之间持续循环,这被称为GC周期。就本文档而言,我们假定GC周期以清扫开始,然后关闭,接着进行标记。
接下来的几节将着重于建立对GC成本的直觉理解,以帮助用户根据自身需求调整GC参数。
理解成本
GC本身就是一个复杂的软件,它构建在更复杂的系统之上。在试图理解和调整GC行为时,很容易陷入细节。本节旨在提供一个框架,用于推断Go GC的成本及其调优参数。
首先,考虑这个基于三个简单公理的GC成本模型。
-
GC只涉及两种资源:物理内存和CPU时间。
-
GC的内存成本包括活跃堆内存、标记阶段之前分配的新堆内存,以及元数据的空间(即使与前述成本成比例,也相对较小)。
第N次GC周期的内存成本 = 第N-1周期的活跃堆内存 + 新堆内存
活跃堆内存是由前一个GC周期确定的活跃内存,而新堆内存是当前周期分配的任何内存,其在周期结束时可能是活跃的,也可能不是。在任何给定时间点有多少内存活跃,这是程序的属性,不是GC可以直接控制的。
-
GC的CPU成本被建模为每周期的固定成本,加上一个与活跃堆大小成比例的边际成本。
第N次GC周期的CPU时间 = 每周期的固定CPU时间成本 + 每字节的平均CPU时间成本 * 第N周期发现的活跃堆内存
每周期的固定CPU时间成本包括每个周期发生固定次数的事情,例如为下一个GC周期初始化数据结构。这个成本通常很小,仅为了完整性而列出。
GC的大部分CPU成本是标记和扫描,这由边际成本体现。标记和扫描的平均成本取决于GC实现,但也取决于程序的行为。例如,更多的指针意味着更多的GC工作,因为GC至少需要访问程序中的所有指针。链表和树等结构也更难让GC并行遍历,从而增加了每字节的平均成本。
该模型忽略了清扫成本,该成本与总堆内存(包括已失效的内存,它必须被置为可分配状态)成比例。对于Go当前的GC实现,清扫比标记和扫描快得多,因此相比之下可以忽略不计。
该模型简单而有效:它准确地分类了GC的主要成本。 它还告诉我们,垃圾回收器的总CPU成本取决于给定时间范围内GC周期的总数。 最后,该模型中隐含了GC的一个根本的时间/空间权衡。
要理解其中的原因,让我们探讨一个受限但有用的场景:稳态。 从GC的角度来看,应用程序的稳态由以下属性定义:
-
应用程序分配新内存的速率(以每秒字节数为单位)是恒定的。
这意味着,从GC的角度来看,应用程序的工作负载随时间推移看起来大致相同。 例如,对于一个Web服务,这将是一个恒定的请求速率,平均而言,发出的请求类型相同,每个请求的平均生命周期大致保持恒定。
-
GC的边际成本是恒定的。
这意味着对象图的统计数据,例如对象大小的分布、指针的数量和数据结构的平均深度,从一个周期到下一个周期保持不变。
让我们通过一个例子来说明。 假设某个应用程序处于稳态,分配速率为10 MiB/秒,而GC能够以每CPU秒100 MiB的速度扫描内存(这是假设的)。 稳态不对存活堆的大小做任何假设,但为简单起见,假设此应用程序的存活堆始终为10 MiB。 同样,为简单起见,我们假设GC的固定成本为零。 让我们调整一下GC周期的间隔。
假设每个GC周期恰好在1个CPU秒后发生。 那么,到每个GC周期结束时,我们的示例应用程序将已分配10 MiB的额外内存,导致总堆大小为20 MiB。 并且在每个GC周期中,GC将花费0.1 CPU秒来扫描10 MiB的存活堆,从而产生10%的CPU开销。 请记住,GC只需要遍历存活堆,而不是整个堆。 (注意:存活堆恒定并不意味着所有新分配的内存都是死亡的。 这意味着,GC运行后,新旧堆内存的某种组合会死亡,并且每个周期结束时只有10 MiB被发现存活。)
现在假设每个GC周期发生频率降低,每2个CPU秒一次。 那么,我们的示例应用程序在稳态下,每个GC周期时的总堆大小将为30 MiB,因为它在那段时间内将分配20 MiB。 但GC在每个周期仍然只需要0.1 CPU秒来扫描10 MiB的存活内存。 再次强调,我们假设存活堆大小保持不变,无论分配了多少内存。 因此,这意味着我们的GC开销降低了,从10%降至5%,代价是内存使用量增加了50%。
这种开销的变化正是前面提到的根本的时间/空间权衡。 而GC频率是这个权衡的核心: 如果我们更频繁地执行GC,那么我们使用的内存就会更少,反之亦然。 但GC实际上多久执行一次呢? 在Go中,决定GC何时开始是用户能够控制的主要参数。
GOGC
在较高层面上,GOGC决定了GC CPU使用率和内存使用之间的权衡。
它通过确定每个GC周期之后的目标堆大小来工作,这个目标值是下一个周期的总堆大小目标。 GC的目标是在总堆大小超过目标堆大小之前完成一个收集周期。 总堆大小定义为上一个周期结束时的存活堆大小,加上应用程序自上一个周期以来分配的任何新堆内存。 同时,目标堆内存定义为:
目标堆内存 = 存活堆 + (存活堆 + GC根) * GOGC / 100
举个例子,考虑一个存活堆大小为8 MiB、goroutine栈占用1 MiB、全局变量中的指针占用1 MiB的Go程序。 那么,当GOGC值为100时,下一次GC运行前将分配的新内存量为10 MiB,即10 MiB工作量的100%,总堆占用为18 MiB。 当GOGC值为50时,将为50%,即5 MiB。 当GOGC值为200时,将为200%,即20 MiB。
注意:直到Go 1.18,GOGC才包含根集合。在此之前,它只计算存活堆。 通常,goroutine栈中的内存量相当小,存活堆大小主导了所有其他GC工作来源,但在程序拥有数十万个goroutine的情况下,GC会做出较差的判断。
堆目标控制着GC频率:目标越大,GC可以等待更长时间才开始另一个标记阶段,反之亦然。 虽然精确的公式对于估算很有用,但最好从其根本目的来理解GOGC:一个在GC CPU和内存权衡中选择一个点的参数。 关键要点是,GOGC翻倍将使堆内存开销翻倍,并大致将GC CPU成本减半,反之亦然。 (要查看完整解释,请参见附录。)
注意:目标堆大小只是一个目标,有几个原因可能导致GC周期没有恰好在该目标处完成。 例如,一次足够大的堆分配可能直接超过目标。 然而,其他原因出现在超越本指南迄今使用的GC模型的GC实现中。 更多细节请参见延迟部分,但完整细节可在附加资源中找到。
GOGC 可通过以下任一方式配置:所有 Go 程序均识别的 GOGC 环境变量,或通过 runtime/debug 包中的
SetGCPercent API。
请注意,若未应用内存限制,GOGC 也可用于完全关闭垃圾回收器(GC),方法是设置 GOGC=off 或调用 SetGCPercent(-1)。
从概念上讲,此设置等同于将 GOGC 设为无穷大,因为触发 GC 前新内存量不受限制。
为更好地理解我们此前讨论的所有内容,请尝试下方基于GC 开销模型构建的交互式可视化图表。该图表描述了某程序的执行情况,其非 GC 工作耗时 10 CPU 秒完成。在最初 1 秒内,程序执行初始化步骤(增长其存活堆)后进入稳态。 该应用总共分配 200 MiB 内存,任何时候存活堆大小为 20 MiB。图表假设需完成的唯一相关 GC 工作来自存活堆,且(不切实际地)应用未使用额外内存。
使用滑动条调整 GOGC 值,观察应用在总耗时和 GC 开销方面的响应。每次 GC 周期结束时,新堆大小降至零。新堆降至零所耗费的时间是第 N 次周期的标记阶段与第 N+1 次周期的清扫阶段共同作用的结果。 请注意,此可视化图表(及本指南中所有可视化图表)假设应用在 GC 执行期间暂停,因此 GC 的 CPU 开销完全由新堆内存降至零所耗费的时间表示。这仅为简化可视化;相同的直觉理解仍适用。 X 轴平移以始终显示程序的完整 CPU 时间耗时。请注意,GC 额外占用的 CPU 时间增加了总体耗时。
请注意,GC 始终会产生一定的 CPU 开销和峰值内存开销。随着 GOGC 增大,CPU 开销降低,但峰值内存按存活堆大小的比例增加。随着 GOGC 减小,峰值内存需求降低,代价是 CPU 开销增加。
注意:图表显示的是 CPU 时间,而非程序完成所需的挂钟时间。若程序在单 CPU 上运行并充分利用其资源,则二者等价。 现实世界的程序可能运行在多核系统上,并非始终 100% 利用 CPU。在此类情况下,GC 的挂钟时间影响会更低。
注意:Go GC 的最低总堆大小为 4 MiB,因此若 GOGC 设置的目标低于此值,将向上取整。可视化图表反映了此细节。
以下是另一个更具动态性和现实性的示例。同样地,无 GC 时应用需 10 CPU 秒完成,但稳态分配速率在中途急剧上升,且第一阶段中存活堆大小有所波动。 此示例展示了当存活堆大小实际变化时的稳态表现,以及更高的分配速率如何导致更频繁的 GC 周期。
内存限制
在 Go 1.19 之前,GOGC 是唯一可用于修改 GC 行为的参数。虽然它作为设置权衡的手段效果很好,但它未考虑可用内存是有限的这一事实。考虑当存活堆大小出现瞬时峰值时会发生什么:因为 GC 将选择与该存活堆大小成比例的总堆大小,所以必须根据峰值存活堆大小配置 GOGC,即使在通常情况下更高的 GOGC 值能提供更好的权衡。
下方的可视化图表展示了这种瞬时堆峰值情况。
如果这个示例负载运行在一个内存略多于60 MiB的容器中,那么即使在其他GC周期内有可用内存可以利用,GOGC也无法设置为超过100的值。此外,在某些应用中,这些瞬时峰值可能很少发生且难以预测,导致偶尔出现不可避免且代价高昂的内存不足状况。
正因如此,在1.19版本中,Go增加了对设置运行时内存限制的支持。
内存限制可以通过所有Go程序都识别的GOMEMLIMIT环境变量来配置,也可以通过runtime/debug包中的SetMemoryLimit函数来设置。
此内存限制设定了Go运行时可使用的总内存量的上限。
所包含的具体内存集合定义为基于
runtime.MemStats 的表达式:
Sys - HeapReleased
或者等价地,基于
runtime/metrics 包的表达式:
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
因为Go的GC可以明确控制它使用的堆内存量,所以它会根据此内存限制以及Go运行时使用的其他内存量来设定总堆大小。
下方的可视化图表展示了与GOGC部分相同的单阶段稳态负载,但这次增加了来自Go运行时的10 MiB开销,并且具有可调整的内存限制。 请尝试调整GOGC和内存限制,观察会发生什么。
请注意,当内存限制降低到由GOGC决定的峰值内存(对于GOGC为100时是42 MiB)以下时,GC会更频繁地运行,以将峰值内存保持在限制范围内。
回到我们之前关于瞬时堆峰值的例子,通过设置内存限制并调高GOGC,我们可以兼得两者的好处:既不会突破内存限制,又能获得更好的资源经济性。 请尝试下方的交互式可视化图表。
请注意,在某些GOGC和内存限制的值下,峰值内存使用会停在内存限制的值上,但程序其余部分的执行仍然遵循由GOGC设定的总堆大小规则。
这一观察引出了另一个有趣的细节:即使将GOGC设置为off,内存限制仍然会被遵守! 实际上,这种特定配置代表了资源经济性的最大化,因为它设置了维持某个内存限制所需的最小GC频率。 在这种情况下,程序所有执行期间的堆大小都会上升到与内存限制相匹配。
现在,尽管内存限制显然是一个强大的工具,但使用内存限制并非没有代价,并且肯定不会使GOGC失去作用。
当活跃的堆增长到足够大,使得总内存使用接近内存限制时,会发生什么情况呢?在上面的稳态可视化中,尝试关闭GOGC,然后逐步降低内存限制,观察会发生什么。请注意,随着GC持续执行以维持一个无法达到的内存限制,应用程序所花费的总时间将开始无限制地增长。
这种由于GC循环不断进行而导致程序无法取得合理进展的情况,被称为内存抖动。 它尤其危险,因为它实际上会使程序停滞不前。 更糟糕的是,这恰恰可能发生在我们试图通过GOGC避免的相同场景下:一个足够大的瞬时堆峰值可能会导致程序无限期地停滞!尝试降低内存限制(约30 MiB或更低),观察在瞬时堆峰值可视化中,最糟糕的行为是如何专门由堆峰值引发的。
在许多情况下,无限期停滞比内存不足(OOM)情况更糟糕,后者往往会导致更快速的失败。
因此,内存限制被定义为软限制。 Go运行时不保证在所有情况下都会维持这个内存限制;它只承诺会做出一些合理的努力。 这种对内存限制的放宽对于避免内存抖动行为至关重要,因为它为GC提供了一条出路:允许内存使用超过限制,以避免在GC上花费过多时间。
其内部工作原理是,GC设置了一个在特定时间窗口内可使用的CPU时间上限(对非常短暂的CPU使用峰值带有一些滞后处理)。
目前,该上限大致设置为50%,时间窗口为2 * GOMAXPROCS个CPU秒。
限制GC CPU时间的后果是GC的工作会被延迟,与此同时,Go程序可能会继续分配新的堆内存,甚至可能超过内存限制。
50% GC CPU限制背后的直觉基于其对拥有充足可用内存的程序的最坏情况影响。 在内存限制配置错误(例如被错误地设置得过低)的情况下,程序最多只会变慢2倍,因为GC不能占用超过其50%的CPU时间。
注意:本页的可视化模拟并未模拟GC CPU限制。
建议的使用场景
虽然内存限制是一个强大的工具,并且Go运行时会采取措施来减轻误用带来的最坏行为,但谨慎使用它仍然很重要。 以下是关于内存限制在何处最有用且适用,以及在何处可能弊大于利的一系列建议。
-
当你的Go程序的执行环境完全在你控制之下,并且该Go程序是唯一有权访问某些资源(例如某种形式的内存预留,如容器内存限制)的程序时,应该利用内存限制。
一个很好的例子是将Web服务部署到具有固定可用内存量的容器中。
在这种情况下,一个好的经验法则是额外预留5-10%的余量,以应对Go运行时未知的内存来源。
-
应该随时调整内存限制以适应不断变化的条件。
一个很好的例子是cgo程序,其中C库临时需要显著更多的内存。
-
如果Go程序可能与其他程序共享其部分受限内存,并且这些程序通常与Go程序解耦,不要在设置内存限制的同时将GOGC设为off。 相反,保留内存限制,因为它可能有助于抑制不良的瞬时行为,但应将GOGC设置为针对一般情况的某个更小、更合理的值。
虽然可能很诱人去尝试为共驻程序“预留”内存,但除非程序是完全同步的(例如,Go程序调用某个子进程并在其被调用者执行时阻塞),否则结果将不太可靠,因为不可避免地两个程序都会需要更多内存。 允许Go程序在不需要时使用更少的内存,总体上会产生更可靠的结果。 此建议也适用于过量提交(overcommit)的情况,即一台机器上运行的容器的内存限制总和可能超过该机器实际可用的物理内存。
-
不要在部署到你无法控制的执行环境时使用内存限制,特别是当程序的内存使用与其输入成比例时。
一个很好的例子是CLI工具或桌面应用程序。 在程序可能接收何种输入或系统上有多少可用内存尚不清楚的情况下,将内存限制硬编码到程序中,可能导致令人困惑的崩溃和糟糕的性能。 此外,高级最终用户如果愿意,总是可以自行设置内存限制。
-
当程序已经接近其环境的内存限制时,不要设置内存限制来避免内存不足(OOM)情况。
这实际上是用应用程序严重变慢的风险取代了内存不足的风险,这通常不是一个有利的权衡,即使Go为减轻内存抖动做出了努力。 在这种情况下,更有效的方法是:要么增加环境的内存限制(然后再考虑设置内存限制),要么降低GOGC(这比内存抖动缓解提供了更清晰的权衡)。
延迟
本文档中的可视化图表将应用程序建模为在GC执行期间暂停。 确实存在行为如此的GC实现,它们被称为“全局暂停”GC。
然而,Go的GC并非完全全局暂停,其大部分工作与应用程序并发执行。 这主要是为了减少应用程序的延迟。 具体来说,是单个计算单元(例如一个Web请求)的端到端持续时间。 迄今为止,本文档主要考虑了应用程序的吞吐量(例如每秒处理的Web请求数)。 请注意,GC周期部分中的每个示例都关注了执行程序的总CPU持续时间。 然而,对于例如Web服务来说,这样的持续时间意义要小得多。 虽然吞吐量对Web服务仍然很重要(即每秒查询数),但通常每个单独请求的延迟更为重要。
就延迟而言,全局暂停的GC可能需要相当长的时间来执行其标记和清除阶段,在此期间,应用程序(在Web服务的上下文中,即任何正在处理的请求)无法继续前进。 相反,Go的GC避免使任何全局应用程序暂停的长度与堆的大小成比例,并且核心追踪算法是在应用程序活跃执行时完成的。 (从算法上讲,暂停时间与GOMAXPROCS有更强的相关性,但最常见的是受停止运行goroutine所需时间的主导。) 并发执行并非没有代价:在实践中,它通常导致比等效的全局暂停垃圾回收器更低吞吐量的设计。 然而,重要的是要注意,较低的延迟并不固有地意味着较低的吞吐量,并且Go垃圾回收器的性能随着时间的推移在延迟和吞吐量两方面都得到了稳步改善。
Go当前GC的并发性质并不会使本文档迄今为止讨论的任何内容失效:没有任何陈述依赖于这个设计选择。 GC频率仍然是GC在CPU时间和内存之间权衡以提升吞吐量的主要方式,实际上,它也对延迟承担着同样的角色。 这是因为GC的大部分成本发生在标记阶段活跃期间。
因此,关键要点是,降低GC频率也可能导致延迟改善。 这不仅适用于通过修改调优参数(如增加GOGC和/或内存限制)来降低GC频率,也适用于优化指南中描述的优化。
然而,延迟通常比吞吐量更难理解,因为它是程序瞬时执行的产物,而不仅仅是成本的聚合。 因此,延迟和GC频率之间的联系不那么直接。 下面列出了可能的延迟来源,供那些希望深入探究的人参考。
- 当GC在标记和清除阶段之间转换时,会经历短暂的全局暂停,
- 由于GC在标记阶段占用25%的CPU资源而导致的调度延迟,
- 用户goroutine为响应高分配速率而辅助GC,
- 当GC处于标记阶段时,指针写入需要额外的工作,以及
- 正在运行的goroutine必须被暂停以便扫描其根。
这些延迟来源在执行跟踪中是可见的,除了需要额外工作的指针写入。
终结器、清理函数和弱指针
垃圾回收利用有限的内存提供了无限内存的假象。 内存被分配但从未显式释放,与基础的手动内存管理相比,这使得API和并发算法更加简单。 (一些手动管理内存的语言使用诸如“智能指针”和编译期所有权跟踪等替代方法来确保对象被释放,但这些特性深度嵌入到这些语言的API设计约定中。)
只有活动对象——那些可以从全局变量或某个goroutine中的计算可达的对象——才能影响程序的行为。 在对象变得不可达(“死亡”)之后的任何时间,它都可以被GC安全地回收。 这允许了各种各样的GC设计,例如Go今天使用的追踪设计。 在语言层面上,对象的死亡不是一个可观察的事件。
然而,Go的运行时库提供了三个打破这种假象的特性:清理函数、弱指针和终结器。 这些特性中的每一个都提供了一些观察和响应对象死亡的方式,而在终结器的情况下,甚至可以逆转它。 这当然使Go程序复杂化,并给GC实现增加了额外的负担。 尽管如此,这些特性之所以存在,是因为它们在各种情况下都很有用,Go程序一直在使用它们并从中受益。
有关每个特性的详细信息,请参考其包文档(runtime.AddCleanup、weak.Pointer、runtime.SetFinalizer)。 下面是一些关于使用这些特性的通用建议、你可能遇到的每个特性的常见问题概述,以及测试这些特性使用的建议。
通用建议
-
编写单元测试。
清理函数、弱指针和终结器的精确执行时机难以预测,即使经过多次连续执行,也很容易让人误以为一切工作正常。但同时也容易犯下不易察觉的错误。为它们编写测试可能比较棘手,但鉴于这些特性的使用本身就如此微妙,测试就显得比通常情况更为重要。
-
避免在典型的 Go 代码中直接使用这些特性。
这些都是底层特性,有着微妙的限制和行为。例如,清理函数或终结器无法保证在程序退出时执行,甚至可能根本不会执行。其 API 文档中的长篇注释应被视为一种警告。绝大多数 Go 代码不会从直接使用这些特性中受益,而只是间接受益。
-
将这些机制的使用封装在一个包内。
尽可能不要让这些机制的使用泄漏到你的包的公共 API 中;提供接口,使得用户难以或无法误用它们。例如,与其要求用户为某个 C 分配的内存设置清理函数以释放它,不如编写一个包装器包,并将该细节隐藏其中。
-
将拥有终结器、清理函数和弱指针的对象的访问权限,限制在创建并应用它们的包内。
这与上一点相关,但值得特别强调,因为这是一种非常强大的模式,能以一种更不易出错的方式使用这些特性。例如,unique 包在底层使用了弱指针,但完全封装了那些被弱引用的对象。这些值永远不能被应用程序的其余部分修改,只能通过Value 方法复制,从而为包用户维持了内存无限的幻觉。
-
尽可能优先采用确定性方式清理非内存资源,将终结器和清理函数作为后备方案。
清理函数和终结器非常适用于内存资源,例如从 C 等外部来源分配的内存,或指向某个
mmap映射的引用。由 C 的 malloc 分配的内存最终必须由 C 的 free 释放。将调用free的终结器附加到包装 C 内存的对象上,是一种确保 C 内存最终能因垃圾回收而被回收的合理方式。然而,非内存资源(如文件描述符)往往受到系统限制,Go 运行时通常无法感知这些限制。此外,在给定的 Go 程序中,垃圾回收器的执行时机通常是包作者无法控制的(例如,GC 的运行频率由 GOGC 控制,运维人员在实践中可以将其设置为多种不同的值)。这两个因素共同作用,使得清理函数和终结器非常不适合作为释放非内存资源的唯一机制。
如果你是一个包作者,提供了包装某种非内存资源的 API,请考虑提供一个显式的 API 用于确定性地释放资源(通过
Close方法或类似方式),而不是依赖垃圾回收器通过清理函数或终结器来操作。相反,应优先将清理函数和终结器用作程序员错误的最佳努力处理机制,要么像 os.File 那样无论如何都清理资源,要么向用户报告未能确定性清理的失败。 -
优先使用清理函数而非终结器。
历史上,引入终结器是为了简化 Go 代码与 C 代码之间的接口以及清理非内存资源。其预期用途是将它们应用于拥有 C 内存或其他非内存资源的包装对象,以便在 Go 代码使用完毕后释放该资源。这些原因至少部分解释了为什么终结器的作用域很窄,为什么任何给定对象只能有一个终结器,以及为什么该终结器只能附加到对象的首字节。这种限制已经扼杀了一些用例。例如,任何希望在内部缓存传入对象的某些信息的包,都无法在对象消失后清理该信息。
但更糟糕的是,终结器由于会复活它们所附加的对象(以便将该对象传递给终结器函数,甚至可以让对象在之后继续存活),从而导致效率低下且容易出错。这个简单的事实意味着,如果该对象是引用循环的一部分,它就永远不会被释放,并且支持该对象的内存至少要到下一个垃圾回收周期才能被重用。
不过,正因为终结器会复活对象,它们确实比清理函数有更明确的执行顺序。因此,对于清理具有复杂销毁顺序要求的结构体,终结器仍然(但很少)可能有用。
但在 Go 1.24 及更高版本中,对于所有其他用途,我们建议您使用清理函数,因为它们比终结器更灵活、更不易出错且更高效。
常见清理函数问题
-
附有清理函数的对象不能从清理函数内部被访问(例如,通过捕获的局部变量)。这会阻止该对象被回收,并且该清理函数永远不会执行。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(fd int) {
syscall.Close(f.fd) // 错误:我们引用了 f,所以这个清理函数不会运行!
}, f.fd)
附有清理函数的对象不能从清理函数的参数中被访问。这会阻止该对象被回收,并且该清理函数永远不会执行。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.AddCleanup(f, func(f *myFile) {
syscall.Close(f.fd)
}, f) // 错误:我们引用了 f,所以这个清理函数永远不会运行。这个特定情况也会引发 panic。
终结器有明确的执行顺序,但清理函数没有。清理函数也可以彼此并发运行。
运行时间长的清理函数应该创建一个 goroutine,以避免阻塞其他清理函数的执行。
runtime.GC 不会等待不可达对象的清理函数执行完毕,只会等待它们全部入队。
常见的弱指针问题
-
弱指针可能在预期之外的时间开始从其
Value方法返回nil。务必在调用Value时进行nil检查,并准备好后备方案。 -
当弱指针用作映射键时,它们不影响映射值的可达性。因此,如果一个弱指针映射键指向的对象也能从映射值中访问到,那么该对象仍将被视为可达。
常见的终结器问题
-
附有终结器的对象不能通过任何路径从其自身被访问(换句话说,它们不能处于引用循环中)。这会阻止该对象被回收,并且该终结器永远不会执行。
f := new(myCycle)
f.self = f // 错误:f 可以从 f 访问到,所以这个终结器永远不会运行。
runtime.SetFinalizer(f, func(f *myCycle) {
...
})
附有终结器的对象不能从终结器函数内部被访问(例如,通过捕获的局部变量)。这会阻止该对象被回收,并且该终结器永远不会执行。
f := new(myFile)
f.fd = syscall.Open(...)
runtime.SetFinalizer(f, func(_ *myFile) {
syscall.Close(f.fd) // 错误:我们引用了外部的 f,所以这个清理函数不会运行!
})
附有终结器的对象的引用链(例如,在链表中)至少需要与链中对象数量相同的 GC 周期才能全部清理完毕。请保持终结器的使用层级简单!
// 错误:回收这个链表至少需要 10 个 GC 周期。
node := new(linkedListNode)
for range 10 {
tmp := new(linkedListNode)
tmp.next = node
node = tmp
runtime.SetFinalizer(node, func(node *linkedListNode) {
...
})
}
避免在包边界返回的对象上设置终结器。这使得你的包的用户可以调用 runtime.SetFinalizer 来修改你返回的对象的终结器,这可能会成为你的包的用户最终依赖的意外行为。
运行时间长的终结器应该创建一个新的 goroutine,以避免阻塞其他终结器的执行。
runtime.GC 不会等待不可达对象的终结器执行完毕,只会等待它们全部入队。
测试对象回收
在使用这些特性时,为使用它们的代码编写测试有时会比较棘手。以下是为使用这些特性的代码编写健壮测试的一些技巧。
- 避免与其他测试并行运行此类测试。这有助于尽可能提高确定性,并能在任何给定时间点很好地掌握全局状态。
-
在进入测试时使用
runtime.GC来建立基线。使用runtime.GC来强制将弱指针置为nil,并使清理函数和终结器排队等待运行。 -
runtime.GC不会等待清理函数和终结器运行,它只是将它们排队。要编写尽可能健壮的测试,可以从你的测试中注入一种方式来阻塞等待清理函数或终结器执行完毕(例如,从测试向清理函数和/或终结器传递一个可选的 channel,并在执行完毕后写入该 channel)。如果这太难或不可能,另一种方法是循环检查某个清理后的特定状态是否为真。例如,
os包的测试会在一个循环中调用runtime.Gosched,该循环检查文件在变为不可达后是否已被关闭。 -
如果为使用终结器的代码编写测试,并且你有一条使用终结器的对象链,那么你至少需要与测试能创建的最深层链长度相同的
runtime.GC调用次数,以确保所有终结器都能运行。 -
在竞态检测模式下进行测试,以发现并发清理函数之间,以及清理函数、终结器代码与代码库其余部分之间的竞态条件。
额外资源
虽然上述信息是准确的,但缺乏足够细节来全面理解Go垃圾回收器设计中的成本与权衡。 如需更多信息,请参阅以下额外资源。
- 《垃圾回收手册》——关于垃圾回收器设计的优秀通用资源和参考文献。
- TCMalloc——C/C++内存分配器TCMalloc的设计文档,Go的内存分配器正是基于此构建。
- Go 1.5 垃圾回收公告——宣布Go 1.5并发垃圾回收器的博客文章,更详细地描述了相关算法。
- 深入理解Go——关于Go垃圾回收器设计演进的深度演示(截至2018年)。
- Go 1.5 并发垃圾回收节拍——关于确定何时启动并发标记阶段的设计文档。
- 更智能的内存清理——关于改进Go运行时将内存归还操作系统方式的设计文档。
- 可扩展的页面分配器——关于改进Go运行时管理从操作系统获取内存的方式的设计文档。
- 垃圾回收节拍器重新设计 (Go 1.18)——关于改进确定何时启动并发标记阶段算法的设计文档。
- 软性内存限制 (Go 1.19)——关于软性内存限制的设计文档。
关于虚拟内存的说明
本指南主要关注垃圾回收器的物理内存使用,但一个经常被问及的问题是:这具体意味着什么?以及它与虚拟内存(通常在类似 top 的程序中显示为“VSS”)相比如何?
物理内存是大多数计算机中实际物理RAM芯片上的内存。 虚拟内存是操作系统在物理内存之上提供的一种抽象,用于隔离程序。 通常,程序预留那些完全不映射到任何物理地址的虚拟地址空间也是可以接受的。
由于虚拟内存只是由操作系统维护的一种映射,因此预留大量不映射到物理内存的虚拟内存通常成本极低。
Go运行时在几个方面依赖于这种关于虚拟内存成本的观点:
-
Go运行时从不删除它所映射的虚拟内存。 相反,它使用大多数操作系统提供的特殊操作,来显式释放与某个虚拟内存范围相关联的所有物理内存资源。
该技术被显式用于管理内存限制,并将Go运行时不再需要的内存归还给操作系统。 Go运行时也会在后台持续释放其不再需要的内存。 更多信息请参阅额外资源。
-
在32位平台上,Go运行时会预先为堆预留128 MiB到512 MiB的地址空间,以减少碎片化问题。
-
Go运行时在若干内部数据结构的实现中使用了大的虚拟内存地址空间预留。 在64位平台上,这些数据结构通常至少占用约700 MiB的虚拟内存。 在32位平台上,其占用量则可以忽略不计。
因此,像 top 中的“VSS”这样的虚拟内存指标,通常对于理解Go程序的内存占用情况用处不大。
相反,应关注“RSS”及类似指标,它们能更直接地反映物理内存的使用情况。
优化指南
识别成本
在尝试优化你的Go应用程序与垃圾回收器的交互方式之前,首先确认垃圾回收器是否确实是主要成本来源,这一点很重要。
Go生态系统提供了多种工具来识别成本和优化Go应用程序。 关于这些工具的简要概述,请参阅诊断指南。 在此,我们将重点关注这些工具的一个子集,以及为理解垃圾回收影响和行为而应用它们的合理顺序。
-
CPU 分析
一个不错的起点是进行 CPU 分析。 CPU分析提供了CPU时间花费在哪里的概览,尽管对于未经训练的人来说,可能难以识别垃圾回收在特定应用中扮演角色的比重。 幸运的是,理解垃圾回收如何融入其中,主要归结为理解 `runtime` 包中不同函数的含义。 以下是解读CPU分析时有用的一些函数子集。
请注意,下面列出的函数并非叶子函数,因此它们可能不会显示在
pprof工具通过top命令提供的默认视图中。 请改用top -cum命令,或直接在这些函数上使用list命令,并重点关注累计百分比列。
-
runtime.gcBgMarkWorker:后台标记工作者协程的入口点。此处的时间开销与GC频率及对象图的复杂度和大小成正比。它代表了应用程序在标记和扫描上所花费的基准时间。请注意,在这些协程内部,你会发现对
runtime.gcDrainMarkWorkerDedicated、runtime.gcDrainMarkWorkerFractional和runtime.gcDrainMarkWorkerIdle的调用,这些调用指示了工作者的类型。在一个大部分时间处于空闲状态的Go应用程序中,Go的垃圾回收器会利用额外的(空闲)CPU资源来更快地完成其工作,这通过runtime.gcDrainMarkWorkerIdle符号来表示。因此,此处的时间可能在CPU采样中占据很大比例,而垃圾回收器认为这些CPU资源是空闲的。如果应用程序变得活跃,空闲工作者中的CPU时间将会减少。发生这种情况的一个常见原因是应用程序完全在一个协程中运行,但GOMAXPROCS设定大于1。 -
runtime.mallocgc:堆内存分配器的入口点。此处累积时间过长(>15%)通常表明有大量内存正在被分配。 -
runtime.gcAssistAlloc:协程进入此函数以让出部分时间,协助垃圾回收器进行扫描和标记。此处累积时间过长(>5%)表明应用程序的内存分配速度很可能超过了垃圾回收器的处理速度。这表明应用程序受到了垃圾回收器的显著影响,同时也代表了应用程序在标记和扫描上花费的时间。请注意,这包含在runtime.mallocgc的调用树中,因此也会使后者的统计时间增加。
执行追踪
虽然CPU分析文件非常适合识别总体时间消耗,但对于更微妙、罕见或与延迟直接相关的性能成本,它们的作用有限。而执行追踪则提供了对Go程序执行的一个短暂窗口内丰富而深入的观察。它们包含了与Go GC相关的各种事件,可以观察到具体的执行路径,以及应用程序可能如何与Go GC交互。所有跟踪的GC事件在追踪查看器中都被方便地标注出来。
关于如何开始使用执行追踪,请参阅 runtime/trace 包的文档。
GC追踪
当其他方法都失败时,Go的垃圾回收器提供了一些特定的追踪,能够更深入地洞察GC行为。这些追踪始终直接打印到标准错误输出(STDERR),每个GC周期一行,并通过所有Go程序都识别的 GODEBUG 环境变量进行配置。它们主要用于调试Go GC本身,因为需要熟悉GC实现的具体细节,但偶尔也能用于更好地理解GC行为。
核心的GC追踪通过设置 GODEBUG=gctrace=1 来启用。该追踪产生的输出在 runtime 包文档的环境变量部分中有说明。
一个名为“pacer追踪”(节奏控制器追踪)的补充GC追踪能提供更深入的见解,通过设置 GODEBUG=gcpacertrace=1 来启用。理解其输出需要了解GC的“节奏控制器”(参见附加资源),这超出了本指南的范围。
消除堆内存分配
减少GC成本的一种方法是让GC一开始就管理更少的数值。下面描述的技术可以带来一些最大的性能提升,因为正如GOGC部分所展示的,Go程序的分配速率是GC频率(本指南使用的关键成本指标)的一个主要因素。
堆内存分析
在确定GC是显著成本来源之后,消除堆内存分配的下一步是找出大部分分配来自何处。为此,内存分析文件(实际上是堆内存分析文件)非常有用。请查阅文档以了解如何开始使用它们。
内存分析文件描述了程序中堆内存分配的来源,通过分配时的堆栈跟踪来识别它们。每个内存分析文件可以从四个方面分解内存使用情况。
inuse_objects— 分解当前存活的对象数量。inuse_space— 按内存使用字节数分解存活的对象。alloc_objects— 分解自Go程序开始执行以来已分配的对象数量。alloc_space— 分解自Go程序开始执行以来分配的总内存量。
可以通过 pprof 工具的 -sample_index 标志,或在交互式使用该工具时通过 sample_index 选项来切换这些不同的堆内存视图。
注意:内存分析默认只采样部分堆对象,因此不会包含关于每一次堆分配的信息。但这足以发现热点问题。要更改采样率,请参考 runtime.MemProfileRate。
就降低垃圾回收成本而言,alloc_space 通常是最有用的视图,因为它直接对应分配速率。这个视图会指出能带来最大收益的分配热点。
逃逸分析
在借助 堆分析 识别出潜在的堆分配位置后,如何消除这些分配呢?关键在于利用 Go 编译器的逃逸分析,让编译器为这些内存寻找替代且更高效的存储方式,例如存储在 goroutine(协程)的栈上。幸运的是,Go 编译器能够描述它为何决定将某个 Go 值逃逸到堆上。掌握了这一信息,剩下的就是重组你的源代码,以改变分析的结果(这通常是最困难的部分,但超出了本指南的范围)。
至于如何获取 Go 编译器逃逸分析的信息,最简单的方法是通过 Go 编译器支持的一个调试标志,它能以文本格式描述应用于某个包的所有优化操作(或未应用的操作)。这包括值是否逃逸。尝试以下命令,其中 [package] 是某个 Go 包路径。
$ go build -gcflags=-m=3 [package]
这些信息也可以在支持 LSP 的编辑器中作为叠加层进行可视化;它通过代码操作暴露出来。 例如,在 VS Code 中,调用 “Source Action... > Show compiler optimization details”(源操作... > 显示编译器优化详情)命令,即可为当前包启用诊断信息。(你也可以运行 "Go: Toggle compiler optimization details"(Go:切换编译器优化详情)命令。) 使用以下配置设置来控制显示哪些注解:
-
通过 将
ui.diagnostic.annotations设置为包含escape来启用逃逸分析的叠加层。
最后,Go 编译器以机器可读的(JSON)格式提供此信息,可用于构建额外的自定义工具。有关更多信息,请参阅 Go 源代码中的文档。
特定实现的优化
Go 的垃圾回收器对存活内存的组成非常敏感,因为复杂的对象和指针图会限制并行性,并为垃圾回收器产生更多的工作。因此,垃圾回收器针对一些常见的特定结构包含了几项优化。以下是与性能优化最直接相关的几项。
注意:应用以下优化可能会因掩盖意图而降低代码的可读性,并且可能无法在后续的 Go 版本中持续有效。建议仅在最重要的场景下应用这些优化。这些场景可通过使用 识别成本 一节中列出的工具来识别。
-
指针自由值与其他值是分开存放的。
因此,从数据结构中移除那些并非严格需要的指针可能是有利的,因为这可以降低垃圾回收器对程序造成的缓存压力。结果就是,依赖索引而非指针值的数据结构,虽然类型安全性稍差,但可能性能更好。只有当对象图明显复杂,且垃圾回收器花费大量时间在标记和扫描上时,才值得这样做。
-
垃圾回收器会在值中的最后一个指针处停止扫描。
因此,在结构体类型的值中,将指针字段分组到值的开头可能是有利的。只有当应用程序明显花费大量时间在标记和扫描上时,才值得这样做。(理论上编译器可以自动完成此操作,但目前尚未实现,结构体字段的排列顺序与源代码中书写的顺序一致。)
此外,垃圾回收器必须与其看到的几乎每一个指针进行交互,因此,例如使用指向 slice(切片)的索引而非指针,有助于降低垃圾回收成本。
Linux 透明大页(THP)
当程序访问内存时,CPU 需要将其使用的 虚拟内存 地址转换为物理内存地址,以引用它试图访问的数据。为此,CPU 会查询"页表",这是一个表示虚拟内存到物理内存映射的数据结构,由操作系统管理。页表中的每个条目代表一个不可分割的物理内存块,称为页,因此得名。
透明大页(THP)是 Linux 的一项功能,它透明地将后备连续虚拟内存区域的物理内存页替换为称为大页的更大内存块。通过使用更大的块,表示相同的内存区域所需的页表条目更少,从而提高了页表查找时间。然而,如果系统仅使用大页的一小部分,则更大的块意味着更多的浪费。
在生产环境中运行 Go 程序时,在 Linux 上启用透明大页可以通过增加内存使用来提升吞吐量并降低延迟。堆内存较小的应用往往无法从 THP 中获益,反而可能消耗大量额外内存(高达 50%)。然而,堆内存较大的应用(1 GiB 或以上)通常能获得显著收益(吞吐量提升高达 10%),且额外内存开销很小(1-2% 或更低)。了解 THP 的配置情况对两种场景都很有帮助,始终建议进行实际测试验证。
可以通过修改 /sys/kernel/mm/transparent_hugepage/enabled 来在 Linux 环境中启用或禁用透明大页。详情请参阅
官方 Linux 管理指南。如果你选择在 Linux 生产环境中启用透明大页,我们建议为 Go 程序配置以下附加设置。
-
将
/sys/kernel/mm/transparent_hugepage/defrag设置为defer或defer+madvise。
此设置控制 Linux 内核将普通页合并为大页的积极程度。defer告诉内核在后台惰性合并大页。更积极的设置可能导致内存受限系统出现停顿,并常常损害应用延迟性能。defer+madvise与defer类似,但对系统中明确请求大页并依赖其提升性能的其他应用更为友好。 -
将
/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none设置为0。
此设置控制 Linux 内核守护进程在尝试分配大页时可以额外分配的页数。默认设置极为激进,常常会抵消 Go 运行时将内存归还操作系统所做的努力。在 Go 1.21 之前,Go 运行时曾尝试缓解默认设置的负面影响,但这会带来 CPU 开销。从 Go 1.21+ 和 Linux 6.2+ 起,Go 运行时不再修改大页状态。
如果你在升级到 Go 1.21.1 或更高版本后发现内存使用量增加,请尝试应用此设置;它很可能解决你的问题。作为替代方案,你可以调用Prctl函数 并使用PR_SET_THP_DISABLE在进程级别禁用大页,或者设置GODEBUG=disablethp=1(将在 Go 1.21.6 和 Go 1.22 中添加)来禁用堆内存的大页。请注意,GODEBUG设置可能在未来的版本中被移除。
附录
关于 GOGC 的补充说明
GOGC 章节 声称将 GOGC 翻倍会使堆内存开销翻倍,同时将 GC 的 CPU 成本减半。为理解其原因,我们来进行数学推导。
首先,堆目标为总堆大小设定了一个目标。然而,这个目标主要影响新分配的堆内存,因为存活堆是应用程序固有的。
目标堆内存 = 存活堆 + (存活堆 + GC 根) * GOGC / 100
总堆内存 = 存活堆 + 新堆内存
⇒
新堆内存 = (存活堆 + GC 根) * GOGC / 100
由此可知,将 GOGC 翻倍也会使应用程序每个周期分配的新堆内存量翻倍,这体现了堆内存开销。注意 存活堆 + GC 根 是 GC 需要扫描的内存量的近似值。
接下来,我们看 GC 的 CPU 成本。总成本可以分解为每个周期的成本乘以一段时间 T 内的 GC 频率。
总 GC CPU 成本 = (每个周期的 GC CPU 成本) * (GC 频率) * T
每个周期的 GC CPU 成本可以从GC 模型推导得出:
每个周期的 GC CPU 成本 = (存活堆 + GC 根) * (每字节成本) + 固定成本
注意,此处忽略了清扫阶段的成本,因为标记和扫描成本占主导。
稳态由恒定的分配速率和每字节成本定义,因此在稳态下,我们可以根据这个新堆内存推导出 GC 频率:
GC 频率 = (分配速率) / (新堆内存) = (分配速率) / ((存活堆 + GC 根) * GOGC / 100)
将这些公式组合起来,我们得到总成本的完整公式:
总 GC CPU 成本 = (分配速率) / ((存活堆 + GC 根) * GOGC / 100) * ((存活堆 + GC 根) * (每字节成本) + 固定成本) * T
对于足够大的堆(代表大多数情况),GC 周期的边际成本主导固定成本。这使得总 GC CPU 成本公式可以显著简化。
总 GC CPU 成本 = (分配速率) / (GOGC / 100) * (每字节成本) * T
根据这个简化公式,我们可以看到:如果将 GOGC 加倍,总 GC CPU 成本将减半。(请注意,本指南中的可视化工具确实模拟了固定成本,因此当 GOGC 加倍时,它们报告的 GC CPU 开销不会精确地减半。) 此外,GC CPU 成本主要由分配速率和扫描内存的每字节成本决定。 有关如何具体降低这些成本的更多信息,请参阅优化指南。
注意:存活堆的大小与 GC 实际需要扫描的该内存量之间存在差异:相同大小的存活堆但结构不同,会导致不同的 CPU 成本,但内存成本相同,从而产生不同的权衡。 这就是为什么堆结构是稳态定义的一部分。 堆目标或许应该只包含可扫描存活堆,作为 GC 需要扫描的内存的更近似值,但这在可扫描存活堆非常少而存活堆本身又很大的情况下会导致退化行为。