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() 之后,改一个为什么另一个也跟着变。关注不迷路。

posted @ 2026-10-13 11:17  小小张说故事  阅读(13)  评论(0)    收藏  举报