Python 的垃圾回收到底回收了谁?引用计数+分代回收一次讲清
结论先行:CPython 的垃圾回收主力是引用计数——每个对象头里存着一个计数,引用清零的瞬间对象立即被释放,实时性拉满。但引用计数有一个致命软肋:循环引用(A 指着 B、B 指着 A,计数永远降不到 0)。为了兜这个底,CPython 又叠加了第二套机制:分代回收(generational GC)——定期扫描容器对象,专门清理"计数不为 0 但已经没人要"的循环引用垃圾。两套机制配合,才有 Python "基本不用管内存"的体验。
1. 主力机制:引用计数,清零即释放
CPython 里每个对象都有一个引用计数(ob_refcnt)。每多一个名字指向它,计数 +1;每少一个,计数 -1。计数归零的瞬间,对象立即释放——不需要等待,不需要扫描。
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2:变量 a + getrefcount 的临时参数
b = a
print(sys.getrefcount(a)) # 3:多了个 b
del b
print(sys.getrefcount(a)) # 又回到 2
注意 getrefcount 的结果永远比肉眼多 1——因为调用它时,实参本身也算一次临时引用。这个小细节经常在面试里被追问。
引用计数的优点是实时性:对象一死立刻回收,内存曲线平滑,不会像某些语言一样攒一波再卡一下。代价是每个对象都要维护计数,且每次赋值都要改计数——速度上的开销,换来了行为的可预测。
2. 致命软肋:循环引用
class Node:
def __init__(self):
self.partner = None
a = Node()
b = Node()
a.partner = b
b.partner = a
del a
del b # 两个对象的计数都还是 1,谁都释放不了
a 和 b 互相指着对方,del 之后计数各剩 1,永远降不到 0。如果只靠引用计数,这段代码每跑一次就漏一块内存——跑个几天几夜,服务就 OOM 了。
这类"计数不为 0 但其实已经没人要"的垃圾,只能靠第二套机制兜底。
3. 兜底机制:分代回收
CPython 的分代回收基于一个统计学假设:大部分对象朝生夕死,活得越久的对象,越不可能变成垃圾。
它把所有容器对象分成三代(第 0 代最年轻),每代是一个链表:
| 代 | 绰号 | 触发条件 |
|---|---|---|
| 第 0 代 | 年轻代 | 新建对象数 − 删除对象数 超过阈值 700 |
| 第 1 代 | 中年代 | 第 0 代回收次数满 10 次 |
| 第 2 代 | 老年代 | 第 1 代回收次数满 10 次 |
触发时做什么:从该代开始,遍历容器对象,把互相引用但外部已不可达的对象标记为垃圾,一起清掉。第 2 代回收(full collection)最重,会把所有代都扫一遍。
用 gc 模块可以亲眼看到这套节奏:
import gc
print(gc.get_count()) # (当前0代计数, 1代计数, 2代计数)
print(gc.get_threshold()) # (700, 10, 10)——三代阈值
gc.collect() # 手动触发一次 full collection
print(gc.get_stats()) # 各代历次回收的统计
4. 手动 gc 管不管用?分场景
- 循环引用密集的场景(比如大量创建互相引用的图结构、解析器 AST、带环的配置对象):手动
gc.collect()能立刻把循环垃圾收掉,适合在"一段密集计算结束后"调用。 - 普通业务代码:别瞎调。分代回收本身开销可控,手动 full collection 反而会造成一次明显的停顿。
- 追求低延迟的场景(Web 请求、游戏循环):可以调阈值,比如
gc.freeze()把老年代对象冻结,减少每次 full GC 的扫描量——但这属于进阶操作,改之前先压测。
一个反直觉的事实:gc.disable() 能让吞吐更高,但循环引用会无限堆积,内存只涨不跌。除非你确定程序里根本没有循环引用,否则别关。
5. 常见误区澄清
误区一:del x 之后对象就立刻释放了。
不一定。del 只是把 x 这个标签撕了,计数减 1。如果还有别的引用(比如列表元素、闭包捕获、异常 traceback),对象照样活着。计数归零才释放,del 不保证归零。
误区二:引用计数是"纯 Python"实现的。
不是。它在 C 层,由解释器在对象创建/赋值/传参时自动维护,Python 代码碰不到,也关不掉(能关的只是分代回收那套)。
误区三:Python 有 GIL,所以 GC 是线程安全的。
分代回收确实是解释器统一调度的,但 gc.collect() 在 C 层跑的时候会持有 GIL,长回收会阻塞所有线程——这也是"手动 full GC 造成卡顿"的根源。
6. 对比:其他实现怎么玩
| 实现 | 主力回收机制 | 特点 |
|---|---|---|
| CPython | 引用计数 + 分代回收 | 实时释放;循环引用靠兜底扫描 |
| PyPy | 增量式标记清除 | 无引用计数,靠 GC 统一调度,延迟低 |
| Jython / IronPython | 借宿主机 JVM / .NET GC | 交给虚拟机的成熟 GC |
理解这个对比的意义:引用计数不是 Python 语言的"规定",而是 CPython 这个"参考实现"的选择。写代码时你可以依赖"对象没人要就会被收走",但不要依赖"什么时候精确收走"——那是实现细节。
7. 一句话总结
- 主力:引用计数,清零即释放,实时但怕循环引用;
- 兜底:分代回收(0/1/2 代,阈值 700/10/10),定期扫容器,专杀循环垃圾;
- 实践:循环引用密集处才手动
gc.collect(),del不保证释放,别随便gc.disable()。
想清楚这两套机制怎么配合,你看 Python 内存曲线时的每一个疑问,就都有了解释。
下一篇:《Python 的浅拷贝和深拷贝到底差在哪?一个嵌套列表实验讲透》——copy() 之后,改一个为什么另一个也跟着变。关注不迷路。

浙公网安备 33010602011771号