Go 垃圾收集器指南
引言
本指南旨在帮助高级 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 值分配内存的方式为“栈分配”,因为空间存储在 Goroutine 栈上。
Go 编译器无法确定其生命周期,因此无法以这种方式分配内存的 Go 值被称为“逃逸到堆”。“堆”可以被认为是内存分配的包罗万象之处,用于当 Go 值需要放置在某个地方时。在堆上分配内存的行为通常被称为“动态内存分配”,因为编译器和运行时都无法对这些内存的使用方式以及何时可以清理做出很少的假设。这就是 GC 的作用:它是一个专门识别和清理动态内存分配的系统。
Go 值可能需要逃逸到堆的原因有很多。一个原因可能是其大小是动态确定的。例如,考虑一个切片的底层数组,其初始大小由变量而不是常量确定。请注意,逃逸到堆也必须是传递性的:如果对 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 可以以 100 MiB/cpu-秒的速度扫描(这是假设),并且固定 GC 成本为零。稳态不假设活跃堆的大小,但为简单起见,我们假设该应用程序的活跃堆始终为 10 MiB。(注意:恒定的活跃堆并不意味着所有新分配的内存都是死的。这意味着,在 GC 运行后,旧堆和新堆内存的某种混合是活跃的。)如果每个 GC 周期恰好每 1 cpu-秒发生一次,那么我们的示例应用程序在稳态下,每个 GC 周期将具有 20 MiB 的总堆大小。并且在每个 GC 周期中,GC 将需要 0.1 cpu-秒来完成其工作,导致 10% 的开销。
现在假设每个 GC 周期发生频率较低,每 2 cpu-秒发生一次。那么,我们的示例应用程序在稳态下,每个 GC 周期将具有 30 MiB 的总堆大小。但每次 GC 周期,GC 仍然只需要 0.1 cpu-秒来完成其工作。因此这意味着我们的 GC 开销刚刚从 10% 下降到 5%,代价是使用了 50% 更多的内存。
这种开销的变化就是前面提到的基本时间/空间权衡。而GC 频率是这种权衡的核心:如果我们更频繁地执行 GC,那么我们使用的内存就更少,反之亦然。但是 GC 实际执行的频率是多少呢?在 Go 中,决定何时启动 GC 是用户可以控制的主要参数。
GOGC
从高层次来看,GOGC 决定了 GC CPU 和内存之间的权衡。
它通过在每个 GC 周期后确定目标堆大小来工作,该目标值是下一个周期的总堆大小。GC 的目标是在总堆大小超过目标堆大小之前完成一个收集周期。总堆大小定义为上一个周期结束时的活跃堆大小,加上自上一个周期以来应用程序分配的任何新堆内存。同时,目标堆内存定义为:
目标堆内存 = 活跃堆 + (活跃堆 + GC 根) * GOGC / 100
例如,考虑一个 Go 程序,其活跃堆大小为 8 MiB,goroutine 栈为 1 MiB,全局变量中包含 1 MiB 的指针。然后,当 GOGC 值为 100 时,在下一次 GC 运行之前将分配 10 MiB 的新内存,即 10 MiB 工作的 100%,总堆占用空间为 18 MiB。当 GOGC 值为 50 时,将是 50%,即 5 MiB。当 GOGC 值为 200 时,将是 200%,即 20 MiB。
注意:GOGC 仅从 Go 1.18 开始包含根集。之前,它只计算活跃堆。通常,goroutine 栈中的内存量很小,活跃堆大小主导了所有其他 GC 工作来源,但在程序有数十万个 goroutine 的情况下,GC 会做出糟糕的判断。
堆目标控制 GC 频率:目标越大,GC 等待启动下一个标记阶段的时间就越长,反之亦然。虽然精确的公式对于估算很有用,但最好从其基本目的来考虑 GOGC:一个在 GC CPU 和内存权衡中选择一个点的参数。关键在于:将 GOGC 加倍将使堆内存开销加倍,并使 GC CPU 成本大致减半,反之亦然。(要了解为什么的完整解释,请参阅附录。)
注意:目标堆大小只是一个目标,GC 周期可能无法正好在该目标处完成有几个原因。首先,足够大的堆分配可能简单地超过目标。然而,其他原因出现在 GC 实现中,超出了本指南迄今为止一直使用的GC 模型。有关更多详细信息,请参阅延迟部分,但完整详细信息可以在附加资源中找到。
GOGC 可以通过 GOGC
环境变量(所有 Go 程序都识别)或通过 runtime/debug
包中的 SetGCPercent
API 进行配置。
请注意,GOGC 也可以通过设置 GOGC=off
或调用 SetGCPercent(-1)
来完全关闭 GC(前提是内存限制不适用)。从概念上讲,此设置等效于将 GOGC 设置为无穷大,因为在触发 GC 之前的新内存量是无界的。
为了更好地理解我们目前讨论的所有内容,请尝试下面的交互式可视化,它基于前面讨论的GC 成本模型构建。此可视化描绘了某个程序的执行,其非 GC 工作需要 10 秒的 CPU 时间才能完成。在第一秒,它执行一些初始化步骤(增加其活跃堆),然后进入稳态。应用程序总共分配 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 时间,而不是完成程序的实际时间。如果程序在 1 个 CPU 上运行并充分利用其资源,那么两者是等效的。实际程序很可能在多核系统上运行,并且并非始终 100% 利用 CPU。在这些情况下,GC 的实际时间影响会更低。
注意:Go GC 的最小总堆大小为 4 MiB,因此如果 GOGC 设置的目标低于此值,它将被向上取整。可视化反映了这一细节。
这是另一个例子,它更具动态性和真实感。再次,该应用程序在没有 GC 的情况下需要 10 个 CPU 秒才能完成,但稳态分配速率在中途急剧增加,并且活跃堆大小在第一阶段略有变化。此示例演示了当活跃堆大小实际发生变化时稳态可能是什么样子,以及更高的分配速率如何导致更频繁的 GC 周期。
内存限制
在 Go 1.19 之前,GOGC 是唯一可以用来修改 GC 行为的参数。虽然它作为一种设置权衡的方式效果很好,但它没有考虑到可用内存是有限的。考虑当活跃堆大小暂时飙升时会发生什么:因为 GC 将选择一个与该活跃堆大小成比例的总堆大小,GOGC 必须针对峰值活跃堆大小进行配置,即使在通常情况下,更高的 GOGC 值提供了更好的权衡。
下面的可视化演示了这种瞬态堆峰值情况。
如果示例工作负载在可用内存略超过 60 MiB 的容器中运行,那么 GOGC 就不能增加超过 100,即使其余的 GC 周期有可用内存可以利用额外的内存。此外,在某些应用程序中,这些瞬时峰值可能很少见且难以预测,导致偶尔、不可避免且可能代价高昂的内存不足情况。
这就是为什么在 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 被设置为关闭,内存限制仍然受到尊重!事实上,这种特殊的配置代表了资源利用的最大化,因为它设置了维持某个内存限制所需的最小 GC 频率。在这种情况下,程序所有的执行都将堆大小增加到满足内存限制。
现在,尽管内存限制显然是一个强大的工具,但使用内存限制并非没有代价,并且当然不会使 GOGC 的实用性失效。
考虑当活跃堆增长到足以使总内存使用量接近内存限制时会发生什么。在上面的稳态可视化中,尝试关闭 GOGC,然后慢慢降低内存限制,看看会发生什么。请注意,随着 GC 不断执行以维持不可能的内存限制,应用程序的总时间将开始无限增长。
由于 GC 周期持续不断,导致程序无法合理地进行,这种情况被称为抖动。它特别危险,因为它实际上会使程序停滞。更糟糕的是,它可能发生的原因与我们试图用 GOGC 避免的情况完全相同:足够大的瞬态堆峰值可能导致程序无限期停滞!尝试在瞬态堆峰值可视化中降低内存限制(大约 30 MiB 或更低),注意最糟糕的行为是如何从堆峰值开始的。
在许多情况下,无限期停顿比内存不足情况更糟糕,内存不足通常会导致更快的故障。
因此,内存限制被定义为软性。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 设置为关闭并使用内存限制。相反,保留内存限制,因为它可能有助于抑制不良的瞬态行为,但将 GOGC 设置为在平均情况下较小且合理的值。
虽然尝试为共享程序“预留”内存可能很诱人,但除非程序完全同步(例如 Go 程序调用某个子进程并在其被调用者执行时阻塞),否则结果将不可靠,因为不可避免地两个程序都需要更多内存。让 Go 程序在不需要时使用更少内存将总体上产生更可靠的结果。此建议也适用于超额承诺情况,即在一台机器上运行的容器的内存限制总和可能超过机器实际可用的物理内存。
-
在部署到您无法控制的执行环境时,请勿使用内存限制,尤其是当程序的内存使用量与其输入成比例时。
一个很好的例子是 CLI 工具或桌面应用程序。在不清楚程序可能被输入何种数据,或者系统上有多少内存可用时,将内存限制硬编码到程序中可能会导致令人困惑的崩溃和糟糕的性能。此外,高级终端用户如果愿意,总是可以设置内存限制。
-
当程序已经接近其环境的内存限制时,请勿设置内存限制以避免内存不足情况。
这实际上是将内存不足的风险替换为严重的应用程序性能下降的风险,这通常不是一个有利的权衡,即使 Go 努力缓解抖动。在这种情况下,更有效的方法是增加环境的内存限制(然后可能设置内存限制)或降低 GOGC(这提供了比抖动缓解更清晰的权衡)。
延迟
本文档中的可视化模型将应用程序建模为在 GC 执行时暂停。确实存在以这种方式行为的 GC 实现,它们被称为“全局暂停(stop-the-world)”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 释放。附加到 C 内存的包装对象的调用free
的终结器是确保 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) // Mistake: We reference f, so this cleanup won't run! }, f.fd)
附带清理函数的对象不得从清理函数参数中可达。这将阻止对象被回收,并阻止清理函数运行。
f := new(myFile) f.fd = syscall.Open(...) runtime.AddCleanup(f, func(f *myFile) { syscall.Close(f.fd) }, f) // Mistake: We reference f, so this cleanup wouldn't ever run. This specific case also panics.
终结器具有明确的执行顺序,但清理函数没有。清理函数也可以彼此并发运行。
长时间运行的清理程序应创建 goroutine 以避免阻塞其他清理程序的执行。
runtime.GC
不会等待不可达对象的清理函数执行完毕,只会等待它们全部排队。
常见的弱指针问题
-
弱指针的
Value
方法可能在意外的时间开始返回nil
。始终使用nil
检查保护对Value
的调用,并制定备用计划。 -
当弱指针用作映射键时,它们不影响映射值的可达性。因此,如果弱指针映射键指向一个也从映射值可达的对象,则该对象仍将被视为可达。
常见的终结器问题
-
附带终结器的对象不得通过任何路径从自身可达(换句话说,它们不能处于引用循环中)。这将阻止对象被回收,并阻止终结器运行。
f := new(myCycle) f.self = f // Mistake: f is reachable from f, so this finalizer would never run. runtime.SetFinalizer(f, func(f *myCycle) { ... })
附带终结器的对象不得从终结器函数中可达(例如,通过捕获的局部变量)。这将阻止对象被回收,并阻止终结器运行。
f := new(myFile) f.fd = syscall.Open(...) runtime.SetFinalizer(f, func(_ *myFile) { syscall.Close(f.fd) // Mistake: We reference the outer f, so this cleanup won't run! })
带终结器对象的引用链(例如,在链表中)至少需要与链中对象数量一样多的 GC 周期才能完全清理。保持终结器链的深度!
// Mistake: reclaiming this linked list will take at least 10 GC cycles. 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
不会等待清理和终结器运行,它只会将它们排队。为了编写最健壮的测试,请注入一种从测试中阻塞清理或终结器的方法(例如,将可选通道传递给测试中的清理和/或终结器,并在执行完成后写入通道)。如果这太困难或不可能,替代方法是在特定的清理后状态上旋转以使其为真。例如,
os
测试在循环中调用runtime.Gosched
,该循环检查文件在变得不可达后是否已关闭。 -
如果编写使用终结器的测试,并且您有一个使用终结器的对象链,那么您将需要至少等于测试可以创建的最深链的
runtime.GC
调用次数,以确保所有终结器都运行。 -
在竞态模式下测试,以发现并发清理之间以及清理和终结器代码与代码库其余部分之间的竞态。
其他资源
虽然上面介绍的信息是准确的,但它缺乏详细信息,无法完全理解 Go GC 设计中的成本和权衡。有关更多信息,请参阅以下附加资源。
- GC 手册——关于垃圾收集器设计的优秀通用资源和参考资料。
- TCMalloc——C/C++ 内存分配器 TCMalloc 的设计文档,Go 内存分配器基于它。
- Go 1.5 GC 公告——宣布 Go 1.5 并发 GC 的博客文章,其中详细描述了该算法。
- 深入 Go——深入介绍了 Go GC 设计演变到 2018 年的演示文稿。
- Go 1.5 并发 GC 步调——确定何时启动并发标记阶段的设计文档。
- 更智能的回收——修订 Go 运行时将内存返回给操作系统方式的设计文档。
- 可伸缩的页面分配器——修订 Go 运行时管理从操作系统获取的内存方式的设计文档。
- GC 步调器重新设计(Go 1.18)——修订确定何时启动并发标记阶段的算法设计文档。
- 软内存限制(Go 1.19)——软内存限制的设计文档。
关于虚拟内存的说明
本指南主要关注 GC 的物理内存使用,但一个经常出现的问题是这到底意味着什么,以及它与虚拟内存(通常在 top
等程序中显示为“VSS”)相比如何。
物理内存是大多数计算机中实际物理 RAM 芯片中存放的内存。虚拟内存是操作系统提供的一种物理内存抽象,用于将程序相互隔离。通常也允许程序保留不映射到任何物理地址的虚拟地址空间。
由于虚拟内存只是操作系统维护的一个映射,因此进行不映射到物理内存的大量虚拟内存预留通常非常便宜。
Go 运行时通常以几种方式依赖这种虚拟内存成本视图:
-
Go 运行时从不删除它映射的虚拟内存。相反,它使用大多数操作系统提供的特殊操作来明确释放与某些虚拟内存范围关联的任何物理内存资源。
此技术明确用于管理内存限制并将 Go 运行时不再需要的内存返回给操作系统。Go 运行时还会持续在后台释放它不再需要的内存。有关更多信息,请参阅附加资源。
-
在 32 位平台上,Go 运行时会预留 128 MiB 到 512 MiB 的地址空间作为堆,以限制碎片问题。
-
Go 运行时在几个内部数据结构的实现中使用了大量的虚拟内存地址空间预留。在 64 位平台上,这些通常具有大约 700 MiB 的最小虚拟内存占用空间。在 32 位平台上,它们的占用空间可以忽略不计。
因此,虚拟内存指标(例如 top
中的“VSS”)通常在理解 Go 程序的内存占用方面作用不大。相反,请关注“RSS”和类似度量,它们更直接地反映了物理内存使用情况。
优化指南
识别成本
在尝试优化 Go 应用程序与 GC 交互的方式之前,首先确定 GC 是一个主要成本是很重要的。
Go 生态系统提供了许多工具来识别成本和优化 Go 应用程序。有关这些工具的简要概述,请参阅诊断指南。在这里,我们将重点介绍这些工具的一个子集以及应用它们的合理顺序,以了解 GC 的影响和行为。-
CPU 配置文件
一个好的起点是CPU 性能分析。CPU 性能分析提供了 CPU 时间花费在哪里的概述,尽管对于未经训练的眼睛来说,可能难以识别 GC 在特定应用程序中扮演的角色有多大。幸运的是,理解 GC 如何融入其中,主要归结为知道 `runtime` 包中不同函数意味着什么。下面是用于解释 CPU 配置文件的这些函数的一个有用子集。
请注意,下面列出的函数不是叶子函数,因此它们可能不会出现在
pprof
工具使用top
命令提供的默认结果中。相反,请使用top -cum
命令或直接对这些函数使用list
命令,并重点关注累积百分比列。 -
runtime.gcBgMarkWorker
:后台标记工作 goroutine 的入口点。此处花费的时间与 GC 频率以及对象图的复杂性和大小成比例。它表示应用程序用于标记和扫描的基准时间。请注意,在这些 goroutine 中,您会发现对
runtime.gcDrainMarkWorkerDedicated
、runtime.gcDrainMarkWorkerFractional
和runtime.gcDrainMarkWorkerIdle
的调用,它们指示了 worker 类型。在一个大部分空闲的 Go 应用程序中,Go GC 将会使用额外的(空闲)CPU 资源来更快地完成其工作,这由runtime.gcDrainMarkWorkerIdle
符号指示。因此,此处的时间可能代表 CPU 样本的很大一部分,Go GC 认为这些样本是空闲的。如果应用程序变得更加活跃,空闲 worker 中的 CPU 时间将减少。发生这种情况的一个常见原因是,如果应用程序完全在一个 goroutine 中运行,但GOMAXPROCS
> 1。 -
runtime.mallocgc
:堆内存分配器的入口点。此处花费的累积时间过长(>15%)通常表明分配了大量内存。 -
runtime.gcAssistAlloc
:Goroutine 进入此函数,将部分时间让给 GC 进行扫描和标记。此处花费的累积时间过长(>5%)表示应用程序的分配速度可能超过了 GC。这表明 GC 的影响特别大,也代表了应用程序用于标记和扫描的时间。请注意,这包含在runtime.mallocgc
调用树中,因此它也会使其膨胀。 -
执行跟踪
虽然 CPU 配置文件对于识别聚合时间花费在哪里非常有用,但它们对于指示更微妙、罕见或与延迟相关的性能成本不太有用。另一方面,执行跟踪提供了 Go 程序执行短窗口的丰富而深入的视图。它们包含各种与 Go GC 相关的事件,并且可以直接观察特定的执行路径,以及应用程序可能如何与 Go GC 交互。所有跟踪的 GC 事件都在跟踪查看器中方便地标记为 GC 事件。
有关如何开始使用执行跟踪,请参阅
runtime/trace
包的文档。 -
GC 追踪
当所有其他方法都失败时,Go GC 提供了一些不同的特定跟踪,这些跟踪提供了对 GC 行为更深入的见解。这些跟踪总是直接打印到 STDERR,每个 GC 周期一行,并通过所有 Go 程序都识别的
GODEBUG
环境变量进行配置。它们主要用于调试 Go GC 本身,因为它们需要对 GC 实现的细节有一定的熟悉,但偶尔仍然可以用于更好地了解 GC 行为。核心 GC 追踪通过设置
GODEBUG=gctrace=1
启用。此追踪产生的输出在runtime
包文档的环境变量部分中进行了说明。一个补充的 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
。
为了降低 GC 成本,alloc_space
通常是最有用的视图,因为它直接对应于分配速率。此视图将指示分配热点,从而提供最大的收益。
逃逸分析
一旦在堆分析的帮助下识别出候选的堆分配位置,如何消除它们呢?关键是利用 Go 编译器的逃逸分析,让 Go 编译器为这些内存找到替代的、更高效的存储方式,例如在 goroutine 栈中。幸运的是,Go 编译器能够描述它决定将 Go 值逃逸到堆的原因。有了这些知识,剩下的就是重组源代码以改变分析结果(这通常是最困难的部分,但超出了本指南的范围)。
至于如何从 Go 编译器的逃逸分析中获取信息,最简单的方法是使用 Go 编译器支持的调试标志,该标志以文本格式描述它对某个包应用或未应用的所有优化。这包括值是否逃逸。尝试以下命令,其中 [package]
是某个 Go 包路径。
$ go build -gcflags=-m=3 [package]
此信息也可以在支持 LSP 的编辑器中以叠加层形式可视化;它作为代码操作公开。例如,在 VS Code 中,调用“源操作... > 显示编译器优化详细信息”命令,以启用当前包的诊断。(您还可以运行“Go:切换编译器优化详细信息”命令。)使用此配置设置来控制显示哪些注释:
- 通过将
ui.diagnostic.annotations
设置为包含escape
来启用逃逸分析的叠加层。
最后,Go 编译器以机器可读(JSON)格式提供此信息,可用于构建额外的自定义工具。有关更多信息,请参阅Go 源代码中的文档。
特定于实现的优化
Go GC 对活跃内存的分布很敏感,因为复杂的对象和指针图既限制了并行性,又为 GC 产生了更多工作。因此,GC 包含一些针对特定常见结构的优化。下面列出了对性能优化最直接有用的那些。
注意:应用以下优化可能会通过模糊意图来降低代码的可读性,并且可能无法在 Go 版本之间保持不变。仅在最重要的地方应用这些优化。这些地方可以通过使用识别成本一节中列出的工具来识别。
-
无指针值与其他值隔离。
因此,从不需要指针的数据结构中消除指针可能是有利的,因为这减少了 GC 对程序施加的缓存压力。因此,依赖于指针值上的索引的数据结构,虽然类型不太严格,但可能表现更好。这只在对象图复杂且 GC 花费大量时间标记和扫描时才值得做。
-
GC 将在值的最后一个指针处停止扫描值。
因此,将指针字段在结构体类型值中分组在值的开头可能是有利的。这只在应用程序花费大量时间标记和扫描时才值得做。(理论上编译器可以自动执行此操作,但尚未实现,并且结构体字段按照源代码中的书写方式排列。)
此外,GC 必须与它看到的几乎所有指针交互,因此使用切片索引(例如)而不是指针,有助于降低 GC 成本。
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 成本将减半。(请注意,本指南中的可视化模拟了固定成本,因此它们报告的 GC CPU 开销在 GOGC 加倍时不会精确减半。)此外,GC CPU 成本主要由分配速率和扫描内存的每字节成本决定。有关如何具体降低这些成本的更多信息,请参阅优化指南。
注意:实时堆的大小与 GC 实际需要扫描的内存量之间存在差异:相同大小但结构不同的实时堆会导致不同的 CPU 成本,但内存成本相同,从而导致不同的权衡。这就是为什么堆结构是稳态定义的一部分。堆目标理论上应该只包含可扫描的实时堆,作为 GC 需要扫描内存的更接近的近似值,但这在可扫描实时堆非常小而实时堆本身很大的情况下会导致退化行为。