本节目标:看懂 CPython 的引用计数在每次赋值、入容器、传参时怎么变,并亲手用
gc模块观察三代回收的真实行为。
适用版本:Python 3.12+(实测 3.14.6)
4.1 引用计数、循环 GC 与分代回收
上一章我们讲了字节码与自适应解释器,最后停在「解释器怎么把一段代码跑快」。这一章换个方向,钻进 CPython 的内存层。先把一句话说清楚:Python 的内存回收不是一种机制,而是两套机制叠在一起——引用计数负责绝大多数对象,标记-清除式 GC 只用来补引用计数的漏。理解这两套机制的分界线,比记住「Python 用 GC」有用得多。
站内专题 Python 内存管理与 GC
已经讲过「怎么用」(__slots__、weakref、对象池)。这一节不复述那些,只做一件事:把引用计数和分代 GC 的内部计数与真实数字摊开给你看。
4.1.1 引用计数:每一次赋值都在改一个整数
每个 CPython 对象的内存里,前 8 个字节就是它的引用计数 ob_refcnt。sys.getrefcount 返回的是这个值——注意它自己会把对象作为参数再引用一次,所以看到的数字永远比「你以为的」多 1。
import sys
a = [1, 2, 3]
print("创建后:", sys.getrefcount(a)) # 2:变量 a + getrefcount 参数
b = a
print("b = a 后:", sys.getrefcount(a)) # 3
lst = [a, a]
print("放进列表两次:", sys.getrefcount(a)) # 5
del b
print("del b 后:", sys.getrefcount(a)) # 4
lst.clear()
print("清空列表后:", sys.getrefcount(a)) # 2
实测输出:
创建后: 2
b = a 后: 3
放进列表两次: 5
del b 后: 4
清空列表后: 2
规律很简单:每多一个指向它的引用(变量名、容器元素、栈上的临时值),计数 +1;引用消失,计数 -1;降到 0,对象立刻被 ob_type->tp_dealloc 释放。这个动作是同步的、确定的,不需要任何后台线程。
传参也会临时 +1:调用期间形参 x 是额外引用,函数返回后恢复。
4.1.2 3.12 起的「不朽对象」:计数不再变
如果对 None、True、小整数调 getrefcount,你会看到一个荒谬的数字:
import sys
print("None:", sys.getrefcount(None))
print("True:", sys.getrefcount(True))
print("小整数 1:", sys.getrefcount(1))
print("大整数 10**18:", sys.getrefcount(10**18))
实测输出:
None: 3221225472
True: 3221225472
小整数 1: 3221225472
大整数 10**18: 3
3221225472 就是 0xC0000000,也就是 3 × 2³⁰。这是 CPython 从 3.12(PEP 683) 引入的不朽对象(immortal object)标记值 _Py_IMMORTAL_REFCNT。凡是进程生命周期内绝不会被释放的对象(None、True/False、小整数、被驻留的短字符串字面量),它们的引用计数被永久钉在这个哨兵值上,增加和减少引用的代码路径会被直接跳过。这是 3.12 一个能真实影响多线程扩展性能的改动,也是「为什么 getrefcount 有时返回天文数字」的标准答案。
判断依据:值等于 3 × 2³⁰ 就是不朽对象,其余是普通对象。大整数 10**18 不是不朽的,所以显示 3。
4.1.3 循环引用:引用计数唯一的死角
引用计数有一类它永远处理不了的情况——环。看下面这段,故意先关掉 GC:
import gc, sys
class Node:
def __init__(self, name):
self.name = name
self.next = None
gc.disable() # 先只让引用计数工作
a = Node("A")
b = Node("B")
a.next = b
b.next = a
print("循环建立后 a 的 refcount:", sys.getrefcount(a)) # 实测输出 3
del a
del b
print("del 之后两个对象仍互相引用,谁也无法释放")
a 与 b 各自被对方引用着,del 只是删掉了外部变量名,两个对象的计数都停在 1,永远不会归零。这就是循环 GC 存在的唯一理由:专门回收那些计数永远降不到 0、但已经不可能再被访问的环。
4.1.4 分代 GC:三代阈值与真实计数
打开 GC 后,先看它的三个旋钮(实测输出直接写在注释里):
import gc
print("阈值:", gc.get_threshold()) # (2000, 10, 10)
print("当前计数:", gc.get_count())
print("冻结数:", gc.get_freeze_count())
实测输出:
阈值: (2000, 10, 10)
当前计数: (4, 0, 0)
冻结数: 0
三元组 (2000, 10, 10) 的含义是:第 0 代对象净增量超过 2000 → 回收第 0 代;第 0 代回收 10 次 → 连带回收第 1 代;第 1 代回收 10 次 → 连带回收第 2 代。注意「计数」不是存活对象数,而是该代分配数减去释放数的净值,所以一个短命对象反复创建销毁不会把计数堆上去。
gc.get_stats() 给出每一代的累计统计,字段含义值得记牢:
import gc
for i, st in enumerate(gc.get_stats()):
print(f"gen{i}: {st}")
gen0: {'collections': 4, 'collected': 29, 'uncollectable': 0}
gen1: {'collections': 0, 'collected': 0, 'uncollectable': 0}
gen2: {'collections': 0, 'collected': 0, 'uncollectable': 0}
collections 是这一代被回收的次数,collected 是累计回收的对象数,uncollectable 是回收不了、被塞进 gc.garbage 的数量(正常情况下恒为 0)。
4.1.5 亲手触发一次第 0 代回收
造一批「自环」对象,观察第 0 代如何被自动触发:
import gc
class Node:
def __init__(self):
self.me = self # 自环,必须靠 GC 回收
gc.collect()
before = gc.get_stats()
for i in range(2100): # 超过阈值 2000
Node()
after = gc.get_stats()
print("gen0 collections 增量:", after[0]["collections"] - before[0]["collections"])
print("gen0 collected 增量:", after[0]["collected"] - before[0]["collected"])
gc: collecting generation 0...
gc: objects in each generation: 1999 0 7146
gc: done, 1986 unreachable, 0 uncollectable, 0.0002s elapsed
gen0 collections 增量: 1
gen0 collected 增量: 1986
一次回收处理掉 1986 个不可达对象,耗时 0.0002 秒——这就是分代回收为什么快:第 0 代只扫新对象,绝大多数新对象活不过第一次回收。gc.collect(0/1/2) 可手动指定代次,返回值是本次回收的对象数。
4.1.6 __del__、gc.garbage 与对象复活
老资料常说「带 __del__ 的对象进了循环就永远回收不了」。这是 3.4 之前的行为,早已过时。 现代 CPython 能把带终结器的循环对象正确回收,并且会按依赖顺序调用 __del__:
import gc
log = []
class Node:
def __init__(self, name):
self.name = name
self.next = None
def __del__(self):
log.append(self.name)
a = Node("A")
b = Node("B")
a.next = b
b.next = a
del a
del b
print("del 后(GC 未跑):", log)
n = gc.collect()
print("gc.collect() 回收:", n)
print("回收后 __del__ 调用:", sorted(log))
实测输出:
del 后(GC 未跑): []
gc.collect() 回收: 120
回收后 __del__ 调用: ['A', 'B']
gc.garbage 只在对象确实无法回收时才非空,正常情况下它永远是空列表(gc.garbage == [])。要观察它被填充,得打开 DEBUG_SAVEALL——它把所有不可达对象都留下来,纯粹是调试开关:
import gc
gc.set_debug(gc.DEBUG_SAVEALL)
class Node:
def __init__(self):
self.me = self
y = Node()
del y
gc.collect()
print("garbage 数量:", len(gc.garbage))
print("garbage 类型:", sorted({type(o).__name__ for o in gc.garbage}))
gc.garbage.clear()
gc.set_debug(0)
实测输出:
garbage 数量: 1
garbage 类型: ['Node']
对象复活(resurrection)是另一个边界:在 __del__ 里把 self 存到外部容器,对象就「活过来」了,GC 不会第二次回收它:
import gc
revived = []
class Phoenix:
def __init__(self, name):
self.name = name
self.self_ref = self
def __del__(self):
revived.append(self)
p = Phoenix("p1")
del p
gc.collect()
print("复活列表长度:", len(revived), "名字:", revived[0].name)
实测输出:
复活列表长度: 1 名字: p1
复活是真实存在的反模式:一旦对象在 __del__ 里重新挂到全局容器上,它会一直活到进程结束。写终结器时,绝不要在 __del__ 里保存 self。
4.1.7 gc.freeze():为 fork 型服务降本
还有一个 3.7 起就有、但在高并发服务里经常被忽略的 API——gc.freeze()。它把当前所有被 GC 跟踪的对象移出跟踪范围(冻结),并把各代计数清零:
import gc
gc.collect()
print("freeze 前:", gc.get_freeze_count(), gc.get_count()) # 0 (1151, 4, 0)
gc.freeze()
print("freeze 后:", gc.get_freeze_count(), gc.get_count()) # 7144 (0, 0, 0)
gc.unfreeze()
print("unfreeze 后:", gc.get_freeze_count(), gc.get_count()) # 0 (2, 0, 0)
一次 freeze() 把 7144 个对象移出了跟踪,计数归零。它的典型用法是 pre-fork 服务器(如 gunicorn 的 worker 模型):主进程加载完所有模块后 gc.freeze(),之后 fork 出的子进程就不会因为 GC 去触碰这些共享页,从而保住 fork 的写时复制(copy-on-write)收益。这个技巧在 3.7 之后才可能,属于实打实的版本红利。
4.1.8 关掉 GC 会怎样:真实 RSS 数字
引用计数处理不了环,所以一旦 gc.disable(),循环垃圾就会堆积。用 psutil 量一下(本机 psutil 7.2.2):
import gc, os, psutil
proc = psutil.Process(os.getpid())
class Node:
def __init__(self):
self.me = self
def rss_mb():
return proc.memory_info().rss / 1024 / 1024
gc.disable()
base = rss_mb()
for _ in range(300_000):
Node()
print(f"起始 RSS {base:.1f} MB -> churn 后 {rss_mb():.1f} MB")
n = gc.collect()
print("gc.collect() 回收对象数:", n)
print(f"手动回收后 RSS 回落到 {rss_mb():.1f} MB")
gc.enable()
实测输出:
起始 RSS 17.6 MB -> churn 后 40.4 MB
gc.collect() 回收对象数: 300000
手动回收后 RSS 回落到 19.4 MB
30 万个自环对象在 GC 关闭时让 RSS 涨了 22.8 MB;手动 collect() 回收了全部 300000 个对象,RSS 回落到 19.4 MB——没有完全回到 17.6 MB。回落不到位的原因是 CPython 的对象分配器会把空闲的内存块(arena)留在进程里备用,不立刻还给操作系统。这是「删了对象,RSS 却不降」的常见解释,也是 4.3 节用 tracemalloc 而不是只看 RSS 的原因。
作为对照,GC 打开时同样造 30 万个自环对象,RSS 基本不动(17.5 MB → 17.6 MB)。
4.1.9 两套机制的分工
引用计数是主回收器,负责绝大多数无环对象,计数归零即刻同步释放、无需停顿,但处理不了环,且每次赋值都要改整数。分代 GC 只负责「有环且不可达」的对象,靠第 0 代计数超 2000 触发,扫代时会暂停分配;它的调优手段是 set_threshold / freeze / disable,而引用计数侧的调优只有 weakref 断环这一条路。
小结
- 引用计数是 CPython 的主回收器,
sys.getrefcount返回的计数永远比「你以为的」多 1(它自己算一个引用)。 - 3.12 起(PEP 683)
None、小整数等是不朽对象,计数恒为3 × 2³⁰ = 3221225472,引用增减被直接跳过。 - 引用计数处理不了环,这是 GC 存在的唯一理由;环里的对象计数永远降不到 0。
- 默认阈值
(2000, 10, 10):第 0 代计数超 2000 触发回收,各级按 10 次晋升;gc.get_stats()给出每代collections/collected/uncollectable。 - 3.4 起带
__del__的循环对象可被正常回收,gc.garbage平时恒空;在__del__里保存self会让对象复活。 gc.freeze()(3.7+)把已有对象移出跟踪,保住 pre-fork 服务的写时复制收益;gc.disable()则会让循环垃圾堆积(实测 30 万自环 → +22.8 MB)。
下一节我们换到「对象自己占多少字节」这个问题上:同一份数据,用 __dict__ 存和用 __slots__ 存,实测内存差多少、为什么差、sys.getsizeof 又会在哪里骗你。
阅读导航:上一节:3.3 实验性 JIT 与自适应解释器
· 下一节:4.2 对象布局、__slots__ 与内存占用测量
。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。