了解垃圾回收、所有权和引用计数如何影响速度、时延与安全性,以及如何选择符合目标的语言。

内存管理是程序用来请求内存、使用内存并归还内存的一组规则和机制。每个运行中的程序都需要内存来保存变量、用户数据、网络缓冲区、图像和中间结果。由于内存是有限并与操作系统及其它应用共享,语言必须决定谁负责释放内存以及何时释放。
这些决策影响两个大多数人关心的结果:程序的响应速度(感觉上的快慢),以及在高负载下的可靠性。
性能不是单一的数字。内存管理会影响:
一个分配很快但有时会暂停进行回收的语言在基准测试中看起来很棒,但在交互式应用中可能会显得抖动。另一个避免暂停的模型可能需要更细致的设计来防止泄漏和生命周期错误。
安全是指防止与内存相关的故障,例如:
许多高曝光的安全问题都源自诸如 use-after-free 或缓冲区溢出等内存错误。
本文是对主流语言使用的主要内存模型的非技术性概览,说明它们各自优化的目标以及选择某种模型时你接受的权衡。
内存是程序在运行时保存数据的地方。大多数语言围绕两个主要区域来组织内存:栈和堆。
把栈想象成用于当前任务的一叠便签。当函数开始时,它在栈上获得一个小的“帧”来保存局部变量;函数结束时,整个帧一次性移除。
这很快且可预测——但仅适用于大小已知且生命周期随函数调用结束的值。
堆更像一个储物间,你可以在里面按需保存对象直到需要时再取。它适合动态大小的列表、字符串或在程序不同部分共享的对象。
因为堆上的对象可能超出单个函数的生命周期,关键问题成为:谁负责释放它们、何时释放? 这个责任就是语言的“内存管理模型”。
指针或引用是间接访问对象的方式——就像知道储物间中某个箱子的货架号。如果箱子被丢弃但你仍然记得货架号,读取时可能得到垃圾数据或崩溃(典型的 use-after-free 错误)。
想象一个循环,它创建一个客户记录,格式化一段消息,然后丢弃它:
有些语言会隐藏这些细节(自动清理),而另一些语言会暴露它们(你要显式释放内存,或必须遵循谁拥有对象的规则)。下面探讨这些选择如何影响速度、暂停和安全性。
手动内存管理意味着程序(因此也是开发者)显式请求内存并在之后释放它。在实践中,这表现为 C 中的 malloc/free,或 C++ 的 new/delete。在需要精确控制何时获取和归还内存的系统编程中,这种方式仍很常见。
当对象需要超出当前函数调用的生命周期、大小动态增长(例如可调整大小的缓冲区),或需要特定布局以便与硬件、操作系统或网络协议互操作时,通常会分配内存。
没有后台垃圾回收器运行时,惊讶式的暂停更少。分配和释放在搭配自定义分配器、内存池或定长缓冲时可以非常可预测。
手动控制也可以减少开销:没有追踪阶段、没有写屏障,且通常每个对象的元数据更少。当代码经过精心设计,你可以达到严格的延迟目标并将内存使用控制在严格限制内。
折衷是程序可能犯下运行时不会自动防止的错误:
这些错误会导致崩溃、数据损坏和安全漏洞。
团队通过缩小允许原始分配的区域并依赖如下模式来降低风险:
std::unique_ptr)来编码所有权手动内存管理通常适合嵌入式软件、实时系统、操作系统组件和性能关键库——这些地方更看重紧密控制和可预测延迟,而非开发便利性。
垃圾回收(GC)是自动内存清理:运行时追踪对象并回收不再可达的对象。这样你可以把精力放在行为与数据流上,而系统处理绝大多数分配与回收决策。
大多数收集器通过先识别存活对象然后回收其余对象来工作。
追踪式 GC从“根”(如栈变量、全局引用和寄存器)开始,沿引用标记所有可达对象,然后扫描堆以释放未被标记的对象。如果没有引用指向某对象,它就有资格被回收。
分代 GC基于“多数对象短命”的观察。它将堆划分为不同代,并更频繁地收集年轻代,这通常更便宜且提升整体效率。
并发 GC在应用线程运行的同时执行部分回收工作,目标是减少长时间的暂停。它可能需要更多的 bookkeeping 来在程序继续运行时保持内存的一致视图。
GC 通常用运行时工作来交换手动控制。有些系统优先保证稳定的吞吐量(每秒完成大量工作),但可能会引入 stop-the-world 暂停;其他系统则尽量减少暂停以满足延迟敏感型应用,但会在正常执行中增加开销。
GC 消除了整类生命周期错误(尤其是 use-after-free),因为只要对象仍可达就不会被回收。它也减少了因遗漏释放导致的泄漏(尽管通过不当保留引用仍然可以“泄漏”)。在大规模代码库里,当所有权难以手动追踪时,GC 常常加快迭代速度。
带垃圾回收的运行时常见于 JVM(Java、Kotlin)、.NET(C#、F#)、Go,以及浏览器和 Node.js 中的 JavaScript 引擎。
引用计数是一种内存管理策略,每个对象跟踪有多少“所有者”(引用)指向它。当计数降为零时,对象立即被释放。这种即时性直观易懂:一旦没有任何东西能到达对象,内存就被回收。
每次复制或存储对对象的引用时,运行时会增加计数;当引用消失时会减少。计数为零时立即触发清理。
这使资源管理直观:对象通常在你不再使用它们的时刻附近释放,这可以减少峰值内存使用并避免延迟的回收。
引用计数通常有稳定、恒定的开销:增/减计数操作发生在许多赋值和函数调用处。这开销通常较小,但无处不在。
好处是你通常不会遇到像某些追踪 GC 那样的大规模暂停。时延常常更平滑,尽管当大量对象图失去最后的所有者时仍会出现一波释放。
引用计数无法回收处于循环引用中的对象。如果 A 引用 B 而 B 引用 A,且没有其他引用指向它们,二者的计数仍保持大于零——从而造成内存泄漏。
生态系统通过几种方式处理这个问题:
所有权与借用是与 Rust 密切相关的内存模型。其思想很简单:编译器强制执行规则,使得难以创建悬垂指针、双重释放以及许多数据竞争——而无需在运行时依赖垃圾回收器。
每个值在任意时刻都有且只有一个“所有者”。当所有者超出作用域时,值会立即且可预测地被清理。这赋予了确定性的资源管理(内存、文件句柄、套接字),类似手动清理,但出错方式大为减少。
所有权也可以发生移动:将值赋给新变量或传入函数可以转移责任。移动之后,旧的绑定不可再使用,这从根本上防止了 use-after-free。
借用让你在不成为所有者的情况下使用一个值。
共享借用允许只读访问,可自由复制。
可变借用允许更新,但必须是独占的:在它存在期间,不能有其它代码读或写同一值。这个“一个写者或多个读者”的规则由编译器在编译期检查。
由于跟踪生命周期,编译器可以拒绝引用超出其所指向数据生命周期的代码,从而消除许多悬垂引用错误。相同规则也防止了并发代码中的大量竞争条件。
代价是学习曲线和一些设计约束。你可能需要重构数据流、明确所有权边界,或为共享可变状态使用专用类型。
该模型非常适合系统级代码——服务、嵌入式、网络和性能敏感组件——当你希望在没有 GC 暂停的情况下得到确定性的清理和低时延时,它表现出色。
当你创建大量短生命周期对象(解析器中的 AST 节点、游戏帧内的实体、Web 请求期间的临时数据)时,逐个分配和释放的开销可能主导运行时间。Arena(也称 region)和 pool 是权衡细粒度释放以换取快速批量管理的模式。
arena 是一个内存“区”,你可以在上面分配许多对象,随后通过丢弃或重置 arena 来一次性释放它们。
与其单独跟踪每个对象的生命周期,不如把生命周期绑定到一个明确的边界:“这个请求期间分配的一切”,或“编译此函数期间分配的一切”。
arena 通常很快,因为它们:
这能提升吞吐量,也能降低由于频繁释放或分配器竞争导致的时延峰值。
arena 和 pool 常见于:
主要规则很简单:不要让引用逃逸出拥有该内存的区域。如果 arena 中分配的对象被存为全局或在 arena 生命周期之外返回,就会有使用已释放内存的风险。
不同语言和库对此有不同处理:一些依赖使用习惯和 API,另一些能把区域边界编码到类型中。
arena 和 pool 不是垃圾回收或所有权的替代品——它们经常作为补充。GC 语言常为热路径使用对象池;所有权型语言可以用 arena 来分组分配并显式生命周期。谨慎使用时,它们能在不丢失内存释放可预测性的前提下实现“默认快速”的分配。
语言的内存模型只是性能与安全故事的一部分。现代编译器与运行时会重写你的程序以减少分配、尽早释放并避免额外的 bookkeeping。这就是为什么“GC 慢”或“手动内存最快”这类经验法则在真实应用中经常失效的原因。
许多分配仅用于在函数间传递数据。通过逃逸分析,编译器可以证明对象不会逃出当前作用域并将其保留在栈上而不是堆上。
这可以直接消除堆分配及其相关成本(GC 跟踪、引用计数更新、分配器锁)。在托管语言中,这也是小对象开销比预期低的主要原因之一。
当编译器内联函数(用函数体替换调用)时,它可能“看穿”抽象层。这能触发优化,比如:
经过优化后,精心设计的 API 即便在源码中看起来分配很多,也能变成“零成本”。
JIT(即时编译)运行时可以基于真实运行数据进行优化:哪些代码路径是热点、典型对象大小和分配模式等。这通常能提高吞吐量,但可能带来启动时间与偶发用于重编译或 GC 的暂停。
**提前编译(AOT)**必须更早做出猜测,但能提供可预测的启动性能和更稳定的延迟。
基于 GC 的运行时通常暴露堆大小、暂停时间目标和代阈值等设置。当且仅当你有测量证据(例如延迟峰值或内存压力)时再去调整它们,而不是一开始就动手。
两个“相同”算法的实现可能在隐藏的分配次数、临时对象和指针访问方面不同。这些差异与优化器、分配器和缓存行为交互——因此性能比较需基于剖析而非假设。
内存管理的选择不仅改变你如何编写代码——还影响工作发生的时间、需要保留的内存量以及用户感受到的性能一致性。
吞吐量是“单位时间内完成多少工作”。想想一个每晚处理 1000 万条记录的批处理任务:如果垃圾回收或引用计数增加少量开销但提升开发效率,你可能总体完成得更快。
时延是“单次操作的端到端耗时”。对于 Web 请求,单个慢响应会损害用户体验,即便平均吞吐量很高。偶发暂停可能对批处理可以接受,但对交互式应用很容易被察觉。
更大的内存占用会增加云成本并可能使程序变慢。当工作集难以很好地适配 CPU 缓存时,CPU 更频繁等待 RAM 数据。某些策略以额外内存换取速度(例如把已释放对象保留在池中),而其他策略则降低内存但增加 bookkeeping 开销。
碎片化发生在可用内存被拆成许多小空隙时——像在杂乱的停车场里很难找到一个能停货车的连续空位。分配器可能花更多时间搜索空间,内存也可能在看似“足够”的情况下增长。
缓存局部性意味着相关数据被放得更近。pool/arena 分配通常改善局部性(一起分配的对象在内存中相近),而混合对象大小的长期堆会使布局变得不利于缓存。
如果你需要一致的响应时间——例如游戏、音频应用、交易系统、嵌入式或实时控制器——“大多数时间很快但偶尔缓慢”往往比“略微慢但一致”更糟糕。这种情形下,可预测的回收模式和对分配的严格控制尤为重要。
内存错误不仅是“程序员的失误”。在许多真实系统中,它们会演化为安全问题:导致服务拒绝(崩溃)、意外数据泄露(读取已释放或未初始化内存)或被攻击者利用的条件,从而执行非预期代码。
不同的内存管理策略倾向于以不同方式失败:
并发改变了威胁模型:在一个线程中“看起来没问题”的内存,在另一个线程释放或修改后会变得危险。那些强制对共享进行规则约束(或要求显式同步)的模型可以减少导致损坏状态、数据泄露和间歇性崩溃的竞争条件。
没有任何内存模型能完全消除所有风险——逻辑错误(认证失误、不安全的默认值、校验缺陷)仍会发生。强健的团队会叠加保护措施:在测试中使用 sanitizer、提供安全的标准库、严格的代码审查、模糊测试(fuzzing),并在 unsafe/FFI 代码周围设置清晰边界。内存安全能显著降低攻击面,但并非绝对保证。
当你在引入变更后尽早捕获内存问题时,修复成本最小。关键是先度量,然后用合适的工具缩小问题范围。
先决定你在追求速度还是内存增长。
对于性能,测量实时时钟时间、CPU 时间、分配速率(字节/秒)和 GC 或分配器耗时。对于内存,跟踪峰值 RSS、稳定态 RSS 以及随时间的对象计数。使用相同的工作负载和一致的输入;小的差异会掩盖分配抖动。
常见迹象:单次请求分配远超预期,或内存随流量上升而不断增长但吞吐量稳定。解决方法通常包括重用缓冲区、对短期对象使用 arena/pool 分配,以及简化对象图使更少对象跨周期存活。
重现问题的最小输入后,启用最严格的运行时检查(sanitizer/GC 验证),然后捕获:
把第一次修复当作实验;重跑测量以确认改动确实降低了分配或稳定了内存——并确保问题没有迁移到别处。更多关于权衡的解释,请参见 /blog/performance-trade-offs-throughput-latency-memory-use。
选择语言不仅关乎语法或生态——其内存模型决定了日常开发速度、运行风险和在真实流量下性能的可预测性。
通过回答几个实用问题,把你的产品需求映射到内存策略:
如果你要切换模型,需为摩擦做好计划:调用现有库(FFI)、混合内存约定、工具链与招聘市场。用原型来揭露隐藏成本(暂停、内存增长、CPU 开销)通常很有价值。
一种实用方法是用你在考虑的环境各自实现同一功能的原型,并在代表性负载下比较分配率、尾时延和峰值内存。有些团队会以“苹果对苹果”的评估方式验证:搭建一个小型 React 前端与 Go + PostgreSQL 后端,在真实流量形状下迭代请求与数据结构,以观察基于 GC 的服务在实际流量下的行为(并在准备好后导出源代码)。
定义最重要的 3–5 个约束,构建薄原型,并测量内存使用、尾时延和失败模式。
| 模型 | 默认安全性 | 时延可预测性 | 开发速度 | 典型陷阱 |
|---|---|---|---|---|
| 手动 | 低–中 | 高 | 中 | 泄漏、使用已释放内存 |
| GC | 高 | 中 | 高 | 暂停、堆增长 |
| RC | 中–高 | 高 | 中 | 循环、开销 |
| 所有权 | 高 | 高 | 中 | 学习曲线 |
内存管理是程序为数据(如对象、字符串、缓冲区)分配内存并在不再需要时释放的方式。
它影响:
栈(stack)是快速、自动的,与函数调用绑定:函数返回时,其栈帧会一次性移除。
堆(heap)用于动态或长期存在的数据,但需要有策略来决定“何时”和“由谁”释放它。
一个常见经验法则:栈适合短期且大小固定的局部变量;堆用于生命周期或大小不可预测的对象。
引用/指针允许代码通过间接方式访问对象。危险在于对象内存被释放后仍然存在对它的引用。
这可能导致:
手动内存管理意味着你显式分配和释放内存(例如 malloc/free、new/delete)。
它适用于需要:
代价是,如果不能谨慎管理所有权和生命周期,容易引入大量缺陷。
如果设计得当,手动管理可以有非常可预测的延迟,因为没有后台 GC 周期可能暂停执行。
你还可以通过:
来优化性能。
但也很容易犯错(碎片化、分配器竞争、频繁的小块分配/释放等)而产生高成本模式。
垃圾回收(GC)会自动找出不再可达的对象并回收它们。
大多数追踪型垃圾回收器的工作流程是:
这通常提高了安全性(减少 use-after-free),但会增加运行时开销,并可能根据收集器设计引入暂停。
引用计数在对象上维护一个“引用数”,当计数变为零时立即释放对象。
优点:
缺点:
很多生态通过或在引用计数之上加一层循环检测来缓解循环引用问题。
所有权/借用模型(以 Rust 为代表)使用编译期规则来防止很多生命周期错误。
核心思想:
这样可以在没有 GC 的情况下实现可预测的清理,但通常需要重构数据流以满足编译器的生命周期检查。
Arena/region 把大量对象分配到一个“区域”里,然后通过重置或丢弃 arena 一次性释放它们。
当你有明确的生命周期边界时(例如:每个 web 请求、每帧游戏逻辑、编译器的临时节点),这很有效。
关键的安全规则是:不要让分配在 arena 中的引用逃逸出该区域的生命周期。
先在代表性的负载下进行真实测量:
然后使用针对性工具:
只有在能指出具体问题后再调整运行时参数(如 GC 设置)。