Python 系列 · 第三篇

Python 对象没了引用之后,是马上消失,还是要等垃圾回收?

CPython 的内存管理主力是引用计数——每个对象头里有个计数器,记着"现在有多少个地方在指着我",减到 0 立刻释放,不用等任何后台线程。这一篇本机真实验证了这个"立刻"到底有多快,顺手挖到一个真实的、大多数教程不会讲的细节——某些对象的引用计数会被故意设成一个天文数字,永远不会归零;还真实复现了引用计数机制天生的一个漏洞:循环引用,以及 Python 怎么用另一套机制兜底。

3,221,225,472
本机真实测得:None/True/小整数的引用计数——不是笔误,是故意设的
114
本机真实实测:关掉自动回收后手动 gc.collect(),一次收掉的对象数

问题:对象怎么知道自己该被回收

不同语言给了完全不同的答案。

引用计数

每个对象自己记账

CPython 给每个对象头里塞了一个计数器,创建一个新引用 +1,引用消失 -1,减到 0 的瞬间立刻释放——不需要暂停程序去扫描整个堆,内存回收的时机是完全确定的。

代价

循环引用是天生的漏洞

如果 A 引用 B、B 又引用 A,就算外部谁都不再引用它俩,它们互相的引用还在,计数永远到不了 0——纯引用计数拿这种情况没办法。

兜底

另开一套分代垃圾回收

Python 额外维护了一套专门找循环引用的收集器,定期扫描"从根节点出发够不着"的对象,不管它们的引用计数是不是大于 0,照样回收——引用计数管日常,循环检测管漏网之鱼。

真实源码:引用计数字段长什么样

每个 Python 对象的 C 结构体第一个字段就是引用计数。3.14.7 这个版本,标准版(带 GIL)和自由线程版的字段布局完全不一样。

Include/object.h · cpython @ v3.14.7, L110 #ifndef Py_GIL_DISABLED struct _object { union { PY_INT64_T ob_refcnt_full; struct { uint32_t ob_refcnt; // 引用计数,32 位 uint16_t ob_overflow; uint16_t ob_flags; }; }; PyTypeObject *ob_type; }; #else // 自由线程版:本地计数 + 共享(原子)计数分开存,减少多线程下的原子操作竞争 struct _object { uintptr_t ob_tid; uint32_t ob_ref_local; // 只属于当前线程的本地引用计数 Py_ssize_t ob_ref_shared; // 跨线程共享的原子引用计数 PyTypeObject *ob_type; }; #endif

本机装的是标准版(上一篇验证过 sys._is_gil_enabled() == True),用的是上半段那个结构。Py_INCREF/Py_DECREF 最终都是对 ob_refcnt 这个字段做加减:

Include/refcount.h · cpython @ v3.14.7, L252 static inline Py_ALWAYS_INLINE void Py_INCREF(PyObject *op) { ... PY_UINT32_T cur_refcnt = op->ob_refcnt; if (cur_refcnt >= _Py_IMMORTAL_INITIAL_REFCNT) { // the object is immortal return; // 不朽对象,直接跳过,连加法都不做 } op->ob_refcnt = cur_refcnt + 1; ... }

这里出现了一个此前没提过的概念:_Py_IMMORTAL_INITIAL_REFCNT——"不朽对象"。

真实发现:有些对象的引用计数是故意设成天文数字的

NoneTrue/False、-5~256 的小整数、空元组这些高频用到的单例对象,真实引用计数测出来是这样的:

真实实测
import sys
print('None:', sys.getrefcount(None))
print('True:', sys.getrefcount(True))
print('小整数 5:', sys.getrefcount(5))
print('大整数 99999999999:', sys.getrefcount(99999999999))
print('空元组:', sys.getrefcount(()))
None: 3221225472 True: 3221225472 小整数 5: 3221225472 大整数 99999999999: 3 空元组: 3221225472

不是溢出、不是 bug——3221225472 正是 3ULL << 30,精确对上真实源码里的常量:

Include/refcount.h · cpython @ v3.14.7, L49 /* In order to offer sufficient resilience to C extensions using the stable * ABI compiled against 3.11 or earlier, we set the initial value near the * middle of the range (2**31, 2**32). That way the refcount can be * off by ~1 billion without affecting immortality. */ #define _Py_IMMORTAL_INITIAL_REFCNT (3ULL << 30)

这是 Python 3.12 引入的"不朽对象"(PEP 683)优化:None/True/小整数这类对象在真实程序里会被疯狂引用又释放(几乎每次条件判断、每次小整数运算都会摸一下),给它们做真实的加减法是纯粹的浪费——干脆把初始引用计数设成一个大到几乎不可能自然到达、又留了足够安全余量的数,Py_INCREF 检测到这个值直接跳过不做加法,Py_DECREF 同理跳过减法。源码注释里那句"离中间值有意留了 10 亿的余量",是专门为了兼容 3.11 之前编译、还不知道"不朽"这回事的 C 扩展——就算它们对这类对象错误地做了几亿次减法,也不会把计数真的减到 0。99999999999 这种不在小整数缓存范围(-5~256)里的大整数,是真实的普通对象,引用计数就是正常的个位数。

真实实验:引用计数归零,释放快到不用等

__del__ 当探针,验证"计数到 0 就立刻释放"这件事,不是"最终会被释放"那种模糊承诺。

真实实测 $ python3 exp_immediate_dealloc.py creating object: created A refcount right after creation: 1 deleting the only reference: __del__ called for A (freed IMMEDIATELY when refcount hit 0) (notice __del__ already printed above -- no waiting for a GC cycle)

__del__ 的打印紧跟在 del t 后面同步出现,中间没有任何延迟——这和 Java、Go 那种"对象啥时候真的被回收不确定,finalize 可能很久之后才跑,甚至可能不跑"的模型完全不同。

真实实验:循环引用,引用计数拿它没办法

关掉自动垃圾回收(gc.disable()),纯靠引用计数,构造一个真实的循环引用。

真实实测 $ python3 exp_cycle.py refcount of A right after linking: 2 (1 from the local var 'a', 1 from b.other) refcount of B right after linking: 2 deleting the local names 'a' and 'b' (the only EXTERNAL references)... (no __del__ printed above -- refcounts are 1, not 0: each node still holds the other) real memory: are A/B still alive? checking gc.garbage / gc.get_objects()... Node objects still alive (found by scanning the live heap): ['A', 'B'] now manually running gc.collect() (the cycle-detecting collector)... gc.collect() reports 114 objects collected __del__ called for B __del__ called for A

del a; del b 之后,两个对象的 __del__ 都没有触发——用 gc.get_objects() 扫描真实堆,两个节点确实还活着。只有手动跑一次 gc.collect()(真实收了 114 个对象,包括之前运行过程中积累的其他垃圾),__del__ 才终于打印出来,而且是先 B 后 A——收集器不看引用计数,看的是"从根节点还能不能走到"。

真实源码:分代收集器怎么决定什么时候扫、扫多深

本机真实配置(默认值):

真实实测 $ python3 -c "import gc; print(gc.get_threshold())" (2000, 10, 10)

三代对象分别记数:新创建的对象先进 0 代,0 代积累的对象数超过阈值(本机是 2000)就触发一次 0 代扫描,活下来的对象晋升到 1 代;1 代超过阈值(10 次 0 代扫描没被晋升掉)触发一次 1/0 代联合扫描,以此类推。这个策略基于一个很朴素的假设——大多数对象活得很短,老对象很少变成垃圾,没必要每次都把所有对象重新扫一遍。

Python/gc.c · cpython @ v3.14.7, L1254 /* Find the oldest generation (highest numbered) where the count * exceeds the threshold. Objects in the that generation and * generations younger than it will be collected. */ static int gc_select_generation(GCState *gcstate) { for (int i = NUM_GENERATIONS-1; i >= 0; i--) { if (gcstate->generations[i].count > gcstate->generations[i].threshold) { // 额外还有一条启发式规则:只有"长期存活的待处理对象"占 // "长期存活总对象"的比例超过 25%,才会真的触发最老一代 // (最贵)的全量扫描——避免对象数一多,全量扫描的开销跟着 // 平方增长。 ... } } }

这条 25% 的启发式规则是 2008 年从 python-dev 邮件列表里的一次真实讨论定下来的(源码注释里直接留了当时的分析链接)——目的是避免"每积累固定数量的新对象就做一次全量扫描"这种朴素策略在长期存活对象很多的场景下,扫描开销随对象总数平方级增长。

演示:引用计数 + 循环检测,一步步看

复现上面两个真实实验:先是普通的引用计数增减(X 计数归零立刻释放),然后是一个真实的循环(A/B 互相引用),最后一次 GC 扫描把它们找出来。

引用计数演示

数据来自独立验证过的 Python 模拟:每一次 dealloc 都核对过触发时刻的引用计数确实是 0;GC 扫描阶段找出的"不可达对象"用一套独立的可达性分析重新算过一遍,确认和演示里标记的完全一致。

参考与说明

  • 本文全部真实实验(sys.getrefcount__del__ 立即释放、gc.disable() 下的循环引用、gc.collect() 手动回收、不朽对象引用计数测量)均在本机真实运行的 Python 3.14.7 上完成,数据未做删改。
  • 源码引用(Include/object.hPyObject 结构体、Include/refcount.hPy_INCREF/_Py_IMMORTAL_INITIAL_REFCNTPython/gc.cgc_select_generation)取自 python/cpython 仓库 v3.14.7 标签,与本机安装的 Python 3.14.7 完全一致。默认分代阈值 (2000, 10, 10) 通过 gc.get_threshold() 在本机确认为真实运行值,没有去找具体的默认值初始化代码行(那部分分散在解释器状态初始化逻辑里,不影响本文对阈值含义和触发规则的解释)。
  • 演示动画的引用计数模型(创建、增减、归零释放、循环引用、GC 可达性扫描)由 Python 独立实现并交叉验证过——不是这份 C 源码的逐行翻译,而是复现了它对外可观察的行为规则,用真实实测过的两个场景(普通归零 + 循环引用)做参数。
  • 没有涉及:自由线程(no-GIL)版本的本地/共享引用计数具体怎么合并与迁移、弱引用(weakref)如何绕开循环引用问题、__del__ 方法本身如果又创建了新的循环引用会发生什么(历史上这曾经是"不可回收"垃圾的来源之一,近几个版本已经改进)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电