垃圾回收的实际成本
摘要
一篇技术文章解释了垃圾回收的真实性能成本,对比了 Go、Java、Rust、Swift 和 Python 等语言中的跟踪式 GC、引用计数和编译期内存管理。
暂无内容
查看缓存全文
缓存时间: 2026/08/13 15:24
# 垃圾回收的实际代价
来源:https://shivanshuag.com/blog/what-garbage-collection-actually-costs/
每个计算机程序都需要内存。内存是有限的,因此一个长时间运行的程序必须在需要时从操作系统借用内存,并在不再需要时将其释放。
有趣的部分在于:谁来回收那些内存,以及何时回收。必须有某段代码弄清楚某块内存不再需要并释放它,而弄清楚这一点并不简单。一个值可能被传给另一个函数、被存储在生命周期更长的位置,或者在线程之间共享,只要还有任何东西引用它,它就仍然是需要的。如果过早回收,我们会得到内存损坏。如果回收得太晚,我们就会得到内存泄漏。
## 范式
内存管理有两种范式,各自针对不同的目标进行优化。
第一种范式是让语言运行时来做这件事。程序在需要时分配内存,在需要的时间内使用它,最终不再引用它。垃圾回收器找出哪些内容不再可达并回收它。Go、Java 以及许多广泛使用的语言都属于这一类。你放弃了决定内存何时释放的权力,但作为交换,你不会过早释放它、不会释放两次,也不会完全忘记释放它。对大多数软件来说,这是一个非常好的折衷。它降低了内存管理的认知负担,让你能专注于实际问题。因为忘记释放某些东西而产生的泄漏也完全消失了。
第二种范式——到这一步应该已经很显然了——是把决定权留给自己。在 C 语言中,你手动分配和释放,并承担由此产生的每一个 bug。在 Rust 中,你不手写释放代码,但也不会把决定权交给运行时。编译器在构建时计算出每个值生命周期的终点,在那里回收它,如果它无法证明这样做是安全的,就拒绝构建程序。所以你仍然拥有对内存的控制权和更紧凑的内存占用,但代价转移了。在 C 中,你通过调试内存损坏来付出代价。在 Rust 中,你通过以一种编译器可以验证的方式来组织程序而付出代价。
介于两者之间的是引用计数,Swift 和 Python 做的就是这件事。它实际上是第一种答案的变体,而不是第三种范式,而且它本身很少足够。引用计数无法看到循环引用,因此语言必须以其他方式处理它们。Python 额外加了一个追踪式收集器来寻找循环。Swift 没有这样做,而是通过 `weak` 和 `unowned` 注解把问题推回给你。引用计数也有其自身的运行成本,每当你复制一个指针或丢弃一个指针时都要付出。
你应该选择哪一种?如果垃圾回收是免费的,我们所有人都会选择由运行时自行管理内存的方案。但它并不是免费的,因此我们需要讨论 GC 的性能代价,以及它是否重要。
## 栈与堆
当程序需要内存时,内存来自两个地方之一:栈或堆。栈内存不会给收集器带来任何成本。它随着函数的调用和返回而增长和收缩,机器只是移动一个指针。当函数返回时,值就消失了,不需要回收任何东西。栈并非对收集器完全不可见,因为它必须扫描栈作为根,以找到存活对象从哪里开始,但它永远不需要在那里释放任何东西。
一个值最终落到堆上,原因有两个之一。要么它需要比创建它的函数活得更久,因为你返回了对它的引用,或者把它存储到了生命周期更长的位置。要么它的大小在事先是未知的,并且可以增长。例如,你不断追加的切片、根据用户输入确定大小的缓冲区。堆分配是 GC 监视和回收的那一类内存,也是 GC 成本的影响因素。我们将在下一节讨论这一点。
## 收集的成本
要计算收集让你付出了什么,需要回答两个问题:收集器多久运行一次,以及单次运行的成本是多少。
收集器多久运行一次,取决于你消耗字节的速度。内存被填满,会迫使收集器去重建其对存活对象的快照。
每次收集器运行时,它都必须回答一个问题:什么仍然是可达的?为了回答这个问题,它构建出程序存活对象以及它们之间引用的图,然后回收图中未包含的所有内容。整个过程——唤醒、构建图、回收剩余内容——就是一个 GC 周期,构建图的部分通常被称为标记。构建图意味着从根开始遍历,并跟踪它找到的每一个引用,而这就是决定一个周期成本的因素。
一个 GC 周期并不是真正按照已用内存来向你收费,而是按照对象和引用来收费。任何一个引用背后有多少内存,从来不在考量之内。收集一个由一百万个互相指向的小对象组成的 4 GB 图,比收集一个单独的 4 GB 缓冲区要昂贵好几个数量级。两个程序使用了同样多的内存,但它们要求的是完全不同的工作量。由此产生的一个令人惊讶的结果是,在回收步骤中,成本与存活的指针成正比,而不是与死指针或垃圾成正比。
与标记相关的还有第二个成本。当收集器正在构建它的图时,你的程序仍在运行,仍在改变指针,而它无法标记一个在其下方不断移动的图。为了防止图变陈旧,你的程序在遍历期间所做的每一次指针写入,都要做一点点额外的工作来报告这个变化。这项工作被记在你的程序头上,而不是收集器头上,因此它不会出现在你测量到的 GC 时间中。这是指针密集的代码在 GC 期间成本更高的另一个原因。
不同运行时在处理这一切的方式上差异很大,每一个背后都有数十年的精心工作。有些每隔一段时间就从头重建快照,有些则在程序运行过程中持续更新它,还有些针对年轻对象单独维护一份快照,理由是大多数对象都会很快死亡(Java 的分代 GC)。大多数运行时都在某个地方给你一个旋钮,让堆在收集之前可以长得更大,这样收集发生的频率更低,但同时会有更多死内存堆在那里。当你针对特定运行时调优特定程序时,这一切都非常重要。但它改变的是常数,以及它优化的分配配置文件的类型。它不会改变影响成本的那些因素。
把这一切与手动管理或编译器管理的世界相比。那里的分配也不是免费的,`malloc` 有自己的空闲列表和自己的锁竞争。但成本存在于分配一个指针然后释放它的指令中。没有任何系统需要扫描整个集合来弄清楚哪个指针可达、哪个不可达。
## 应该衡量什么
当我们说一个程序因为 GC 压力而变慢或 OOM 时,要解决的问题不是它使用了多少内存。对于任何程序、任何语言,有三个问题更值得问:
1. 它持有多少个存活对象?
2. 这些对象之间的关联有多密集?
3. 它消耗这些对象的速度有多快?
测量分三个阶段进行。第一个是分诊,它只告诉你这件事是否值得你花时间。第二个回答第三个问题,即你消耗的速度有多快。第三个回答前两个问题,即你持有多少以及关联有多密集。
首先要测量的是收集器是否根本就是一个问题。这里要看的是 CPU,你的处理器时间中有多少比例进入了收集而不是你的程序。GC 使用的每一个 CPU 周期,都是从你的程序中夺走的、原本可以用于执行实际工作的周期。Go 通过 `runtime/metrics` 包暴露了这一点,它报告收集所花费的 CPU 秒数与总可用 CPU 秒数的对比;Java 则通过其 GC 日志或分析器给出同样的图景。如果这个占比很小,你可以在这里停止,GC 对你的程序来说不是一个需要优化的问题。
第二是你的代码中哪些部分在产生垃圾。这是一个分配配置文件,你需要测量分配次数和每次分配的字节数,并归属到它们的来源位置。在 Go 中,这是通过 `alloc_objects` 和 `alloc_space` 读取的堆配置文件;如果你从基准测试出发,则是 `allocs/op` 和 `B/op`。在 Java 中,它是来自分析器的分配事件,归属到堆栈跟踪。在大多数情况下,你会发现少数几个调用点负责了大部分消耗,这些就是要优化的点。
第三是你的程序正在持有的是什么。大多数时候,只优化热分配路径就够了,你不需要这个。如果你确实需要更进一步,请记住,单个 GC 周期的成本与存活引用的数量成正比,而不是与死引用成正比。分配配置文件告诉你创建了什么,却不说明什么存活了下来,因此一个真正问题是内存中保留了大结构的程序,在分配配置文件中会看起来完全不起眼。为此,你需要的是存活视图,在 Go 中是 `inuse_objects` 和 `inuse_space`,在 Java 中是堆直方图。
指针密度是没有任何工具直接报告的指标,但我们可以推导出来。用存活字节数除以存活对象数,看看平均大小。数量大而体积小的对象很昂贵,因为这意味着有大量引用把它们联系在一起;数量少而体积大的对象则很便宜。这个信号是粗略的,而不是精确的测量,因为平均大小是指针数量的代理指标,即收集器必须跟踪多少个指针。如果平均值朝正确的方向移动,指针数量也随之移动,你通常就能得到想要的优化效果。
## 过早优化
对大多数软件来说,担心 GC 的开销是一个错误,试图优化会是过早优化。
但有一类程序,这个等式会翻转,分配行为可能成为决定它们成败的因素。这里有一个简短的清单值得思考一遍,这样你可以把决定融入软件设计之中,而不是把它当作事后的优化。问自己以下这些问题——
**热路径上有多少数据流过?**每次 Web 请求分配一次可能不算什么。同样的分配如果出现在一个循环一千万行的循环里,可能就很重要了。
**延迟的尾部分位数重要吗?**不是平均延迟,而是尾部分位数。当收集运行时,它会从你的程序中拿走一部分 CPU。而且在许多运行时中——Go 也在其中——在收集进行期间分配内存的线程会被拉去帮忙做标记,所以它正在做的工作要等一等,直到完成帮忙。如果偶尔一次缓慢的响应可以接受,那么你不需要担心这一点。
**你是否在压榨硬件?**大多数程序让机器大部分时间处于空闲,把时间花在等待其他东西上,比如网络、数据库或磁盘。在这些情况下,收集器的工作就消失在空闲余量中。只有当你试图让 CPU 饱和时,它才会浮现出来。
**进程是否长时间运行并持有大量数据?**想象一个在内存中保留大型缓存的服务,或者一个由数百万个互相指向的小对象构建的索引。进程存续期间,每一个这样的指针都必须被跟踪,而且是在每个周期中。即使服务完全空闲,这种情况也会发生。少分配在这里帮不了你,因为成本在于你保留了什么,而不是你创建了什么。
数据管道、数据库、游戏引擎或高吞吐量服务可能会对其中几个问题回答“是”。典型的 Web 后端或 CLI 工具可能一条都不会。
## 接下来是什么
从实际经验来看,我们大多数人无论如何都没有机会选择范式。你加入一家公司,接手一个项目,或者内部工具链使得采用一门新语言代价高昂,以至于永远不会发生。选择早在几年前就已经做出了。
因此,有用的问题是,从你已经站立的这一边,你能做些什么。我们暗示了 GC 优化,但从未讨论这些优化到底是什么。Go 在这方面的空间比其名声所暗示的要大得多,你只需要在合适的场合使用合适的工具。你可以减少分配,你可以复用已经分配的内存,你还可以划出收集器完全不需要遍历的内存区域。这就是我们接下来要深入的内容。
相似文章
观察 Go 的新垃圾回收器在堆中的移动
Go 1.26 将 Green Tea 设为默认垃圾回收器,提升了缓存友好性。本文通过 Go 和 C# 可视化堆分配,并讨论了非移动回收器和稀疏页面带来的挑战。
元垃圾回收:使用OCaml的垃圾回收器来回收Rust的内存
Soteria Rust是一个用于验证Rust程序的符号执行工具,它使用OCaml的垃圾回收器来管理其Tree Borrows别名模型的内存,实现了10倍的加速,并将时间复杂度从二次降低到线性。
Python 3.14 垃圾回收的波折
Python 3.14 引入了一个增量垃圾回收器,但由于内存压力报告,该回收器在 3.14.5 中被回滚。本文解释了这些变化、它们的影响以及围绕回滚的争议。
在优化垃圾回收器之前先调优代码
基准测试表明,优化Java代码(例如减少SLF4J日志记录)对延迟的影响远大于选择垃圾回收器,尤其是在高百分位下。
安全 Rust 的边界
TokioConf 2026 的一篇演讲/博客文章探讨了如何通过为复杂指针结构实现追踪式垃圾回收,将安全 Rust 推向极限,并分享处理循环引用与原始指针 GC 设计的技巧。