引用计数与垃圾回收:谁来负责释放
先看三个真实场面:
- 一段 C++ 代码用
shared_ptr管对象,逻辑上没问题,但两个对象互相持有智能指针,内存一路涨到 OOM; - 一个 Java 服务,平时 P99 是 20ms,每次 Full GC 时飙到 800ms,业务方以为是网络抖动;
- 一段 Python 代码,
with open(...)一离开作用域文件就关了,但一个自定义缓存类里的对象却要等到「某个不确定的时刻」才被清掉。
这三个场面看起来分别是「C++ 智能指针用错」「JVM 调优」「Python 语法」,但它们指向的是同一个问题:一块内存什么时候可以被释放,以及由谁来决定? 两种主流的回答方式——引用计数和追踪式垃圾回收——给出了两套完全不同的答案。
本文讲的不是「哪种 GC 算法更好」,而是这两种机制各自的成本结构:为什么有的语言选了引用计数、有的选了追踪式 GC,以及现代语言为什么大多是「混合体」。
一、它从哪来
很有意思的是,这两种方案诞生在同一年:1960 年。
- 引用计数(Reference Counting):1960 年,George E. Collins 在《A Method for Overlapping and Erasure of Lists》里提出——给每个对象记一个「有多少人在引用我」的计数,计数归零就释放。思路直观得像物理常识:没人拿着了,就可以扔了。
- 追踪式 GC(Tracing GC):同样在 1960 年,John McCarthy 为 Lisp 实现 GC 时提出了 mark-and-sweep——从一组确定的「根」(栈、寄存器、全局变量)出发,把所有能到达的对象标记一遍,没被标记的就是垃圾。它的思路是:判断存活不应该去数引用,而应该从根出发问一句「还够得着吗」。
两条路线的分歧从一开始就埋下了:
| 引用计数 | 追踪式 GC | |
|---|---|---|
| 判断依据 | 局部信息(这个对象被谁指) | 全局信息(从根能不能到达) |
| 何时回收 | 计数归零的那一刻 | 扫描时发现不可达 |
| 循环引用 | 数得到但拆不掉 | 天然处理(不可达即回收) |
| 谁做判断 | 每次赋值都判断 | 定期集中判断 |
后来这条线继续分叉:
- 1983–1984 年,Lieberman & Hewitt 提出「代际垃圾回收」的想法,David Ungar 在 1984 年的 Generation Scavenging 里把它做成了可用的工程方案——这就是今天几乎所有高性能 GC(Java、Go、V8)的分代模型源头。
- 2001 年,David Bacon 和 V.T. Rajan 在《Concurrent Cycle Collection in Reference Counted Systems》里给出了如何用引用计数回收循环垃圾的算法——这直接影响了 CPython 等「引用计数 + 循环回收」的混合方案。
- 2011 年,Apple 在 LLVM 上推出 ARC(Automatic Reference Counting),把引用计数的增减在编译期静态插入,砍掉了运行期判断的开销——这是引用计数路线的一次重要现代化。
二、为什么需要它
因为**「手动管理内存」这件事,人类做得并不好**。它的失败模式有三种,每一种都极其昂贵:
- 泄漏(leak):忘记释放,内存缓慢上涨,最后 OOM。最阴险,因为它不会立刻出事,可能上线几个月才爆。
- 悬垂(dangling):释放太早,还留着指针,之后访问就是未定义行为。最危险,因为它可能表现为任何症状——错误结果、随机崩溃、或者「悄悄改掉了别人的数据」。
- 重复释放(double free):释放两次,野指针被分配给别人,程序状态彻底乱掉。
而自动管理要解决的核心问题,本质是一个判定问题:「这个对象还有用吗?」两种方案的差别,就在于它们用什么信息来回答:
- 引用计数用「局部计数」回答:被引用了就还有用。优点是快、确定、不需要停顿;缺点是「互相引用的孤岛」在计数上看起来永远有用——这就是循环引用。
- 追踪式 GC 用「可达性」回答:从根能到达就还有用。优点是能正确处理循环、判断更本质;缺点是必须定期停下来做全局扫描,而扫描时间随堆大小增长。
本质一句话:引用计数和追踪式 GC 回答的是同一个问题——「这块内存还有用吗」——但一个用「谁还引用我」这种局部信息来回答,另一个用「从根能不能到达」这种全局信息来回答;这个差异决定了一个没有停顿但有循环泄漏、另一个没有循环泄漏但有全局停顿。
三、两张图看懂
先看两者在回收时机上的区别。这不是实现细节,而是它们最本质的分野:
flowchart LR
subgraph RC["引用计数:成本摊在每次赋值"]
A1["每次赋值 / 出作用域"] --> A2["计数 +1 / -1"]
A2 --> A3{"计数归零?"}
A3 -->|"是"| A4["★ 立刻释放"]
A3 -->|"否"| A5["什么都不做"]
end
subgraph GC["追踪式 GC:成本集中在一个时刻"]
B1["平时分配"] --> B2["分配计数到阈值 / 内存吃紧"]
B2 --> B3["★ stop-the-world 扫描"]
B3 --> B4["标记:从根遍历可达对象"]
B4 --> B5["清除:回收不可达对象"]
end
style A4 fill:#e6ffe6,stroke:#009900
style B3 fill:#ffe6e6,stroke:#cc0000
再看追踪式 GC 的分代模型。它的整个赌注就写在图里:大多数对象活不过第一代。
flowchart TD
N["新分配对象 → 进入 gen0"] --> Q{"活过一次 gen0 回收?"}
Q -->|"否(绝大多数)"| D["直接回收"]
Q -->|"是(少数)"| G1["晋升 gen1"]
G1 --> Q2{"活过一次 gen1 回收?"}
Q2 -->|"否"| D
Q2 -->|"是(更少)"| G2["晋升 gen2 老年代"]
G2 --> S["此后很少被扫描<br/>(只在 Full GC 时处理)"]
style D fill:#f2f2f2,stroke:#888888
style G2 fill:#ffe6e6,stroke:#cc0000
两张图合起来说明:引用计数把回收成本「摊平」到每一次赋值上,换来「随时可回收」;追踪式 GC 把成本「集中」到一个时刻,换来「平时零开销」——但那个时刻不可预测,而且随堆增长。
四、它有什么用
1. 引用计数是「即时」的(CPython 3.13 实跑)
引用计数最容易被忽略的特点,是它的确定性:引用归零的那一刻,对象就被回收,不需要等任何后台进程:
1 | [1] 引用计数:对象一被引用就 +1,引用离开就 -1,归零即释放 |
这就是 CPython 里 with 语句、文件句柄能「离开作用域立刻关闭」的原理,靠的不是 GC,是引用计数。C++ 的 RAII 能成立,也是同一套机制(shared_ptr 的计数归零即析构)。
2. 引用计数的死穴:循环引用(本机实跑)
1 | [3] 引用计数的死穴:循环引用,计数永远不为零 |
p.next = q; q.next = p 之后,p 的引用计数是 3(本地变量 p 一个、q.next 一个、getrefcount 的参数一个)。del p, q 之后,两个对象的计数各剩 1,因为互相指着,永远归不了零。引用计数在这里彻底失效,必须靠追踪式 GC 兜底。
CPython 的解法正是「混合」:引用计数为主(处理绝大多数对象)+ 一个分代循环回收器(处理环)。这个组合是 2000 年 Python 2.0 引入的,算法基础就是 Bacon & Rajan 那套循环回收。
3. 暂停时间:两种成本结构的实测对比(本机实跑)
1 | [4] 暂停时间:引用计数释放 vs GC 一次完整回收 |
这张表要诚实地读:两者都随对象数线性增长,引用计数并不是「零成本」,它把成本摊到了每一次赋值的 +1/-1 上。真正的差别在两个方面:
- 可预测性:引用计数的释放点由程序控制(
del x的那一刻),GC 的释放点由运行时决定,你无法预知它会在哪个请求上停顿。 - 扫描范围:引用计数只处理被释放的对象;GC 必须遍历整张存活对象图——堆里活着的对象越多,暂停越长,哪怕这次要回收的垃圾很少。
这就是「低延迟」系统偏爱引用计数、「高吞吐」系统能容忍 GC 停顿的根本原因:前者买的是确定性,后者买的是平时零开销。
4. 分代假说:为什么 GC 敢只扫一小块(本机实跑)
分代 GC 的赌注是:新对象更可能很快死。实测这个赌注赢得很明显:
1 | [5] 分代假说:绝大多数对象活不过第一代 |
同一个对象,被 gen0 回收扫描了 366 次,却没进过一次 gen2。(注:最大暂停随机器负载波动,这里是单次运行观测值。)这就是分代的收益:把「每次全堆扫描」的成本,压到只扫「新分配的那一小块」,因为统计上,垃圾几乎都在新生代里。
赌注的另一面是:如果有个长寿对象被放进 gen0,它会被反复扫描 366 次,直到晋升到 gen2 才消停。这就是「晋升」这个机制要解决的效率问题,也是为什么「大对象直接进老年代」是个常见优化。
5. 两种机制在现代语言里都是「混合」的
1 | 语言 / 运行时 主机制 循环处理 补充 |
注意「循环处理」这一列:选了引用计数的语言,都把「防环」的责任部分转移给了程序员:C++ 要你判断该用 weak_ptr 还是 shared_ptr,Swift 要你标 weak,Python 则选择用 GC 兜底。这就是那条规律:没有免费的午餐,只有「谁在什么时候付账」。
五、反例与边界
- 引用计数不是「没有停顿」,只是「停顿分散」。 一次
del一个十万节点的树,照样会有毫秒级卡顿。「无停顿」是相对追踪式 GC 的 stop-the-world 说的,不是绝对承诺。 - 追踪式 GC 的暂停也可能是「假象」。 现代 JVM 的并发 GC(G1、ZGC)把大部分标记工作放在业务线程之外,暂停被压到毫秒级甚至亚毫秒,「Java 一定会有几百 ms 停顿」这个印象,早已过时。选型要看具体收集器,不是看语言。
- 引用计数有「计数溢出」风险。 32 位计数在极端场景下(海量共享指针)会回绕,一些实现(如 Python 的
ob_refcnt)为此在超大对象上改用 64 位或特殊处理。这是引用计数在实现层面必须防的边界。 - 循环不是引用计数唯一的麻烦,还有一个更隐蔽的:多线程下的原子计数。引用计数的
+1/-1在多线程里必须是原子的,这个开销(写屏障)可能吃掉它相对追踪式 GC 的优势——这也是为什么很多高并发运行时最终选了追踪式 GC + 并发标记。 - 分代假说不永远成立。 如果程序大量分配长寿对象(比如启动期构建一个巨大缓存),分代策略会把大量对象搬来搬去,反而更慢。这也是为什么 JVM 允许你直接调大新生代、或者用
-XX:PretenureSizeThreshold让大对象直接进老年代。 - 别再问「哪种更好」了,先问「我在优化什么」。 追求低延迟 / 可预测(实时系统、游戏引擎、音频处理):偏向引用计数或无停顿 GC;追求高吞吐(批处理、Web 后端):偏向追踪式 GC 的分代 + 并发。同一个语言的不同 GC 实现,服务的就是不同的目标函数。
六、对比表与小结
| 维度 | 引用计数 | 追踪式 GC |
|---|---|---|
| 回收时机 | 即时、确定性 | 延迟、不确定 |
| 暂停特征 | 分散在每次赋值,单次很短 | 集中成一次 stop-the-world |
| 扫描范围 | 只处理被释放对象 | 遍历整张存活对象图 |
| 循环垃圾 | ❌ 无法自动回收 | ✅ 天然处理 |
| 运行期开销 | 每次赋值都有计数开销 | 分配/回收时才付费 |
| 内存峰值 | 低(即死即放) | 高(等回收才放) |
| 实现复杂度 | 低(逻辑简单) | 高(分代/并发/写屏障) |
| 对程序员的暴露 | 需处理循环(weak) | 基本透明,但停顿不可控 |
| 代表实现 | CPython、Swift ARC、COM、shared_ptr | Java HotSpot、Go、V8 |
| 你的诉求 | 更倾向 | 具体做法 |
|---|---|---|
| 延迟可预测、停顿要短 | 引用计数 | Swift / C++ 智能指针;注意打断循环 |
| 吞吐优先、能容忍停顿 | 追踪式 GC | JVM 调参、G1/ZGC、Go 的 GOGC |
| 既要低延迟又要高吞吐 | 混合 | CPython 的 RC + 分代循环回收 |
| 内存峰值敏感 | 引用计数 | 即死即放,堆不会膨胀 |
| 大量短命对象 + 少量长寿缓存 | 分代 GC | 让长寿对象尽早晋升 |
🐾 小结:引用计数和追踪式 GC 常被讲成「旧 vs 新」「弱 vs 强」,但它们从 1960 年诞生起就是两条平行的路线,各自优化一个目标:引用计数优化确定性(什么时候放,我说了算),追踪式 GC 优化吞吐与正确性(平时零开销、循环也能收)。它们各自的死穴也正好对称:引用计数怕环,追踪式 GC 怕停顿。所以现代语言几乎都走了混合路线——CPython 用引用计数保确定性、用分代循环回收补环的漏洞;Java 用分代保吞吐、用并发标记压停顿。真正要记住的不是「哪个更好」,而是那句老话:每种自动内存管理都在做一笔交易——用可预测性换吞吐,或者用吞吐换可预测性。
相关阅读:

