bigywater.tech
Python · 84

Python 垃圾回收:循环引用如何被发现

引用计数为 0 就立刻释放——这是 CPython 的主要回收机制。但两个对象互相引用时,计数永远不会归零。这篇把「环」怎么产生、gc 怎么发现它、我自己写一遍这套算法能不能得到同样的答案,都真实跑了一遍。

4 个对象
a、b 及各自的 refs 列表:gc.collect() 之前它们没有被释放
[A, B, E]
我用 getrefcount + get_referents 手写的算法与 gc.collect() 判定一致
70.4 MB vs 0.10 MB
20 万对环形垃圾后,gc 关闭 / 开启时仍占用的内存

验证一:引用计数回收不了环

实验 gc.collect(); gc.disable() a, b = N("a"), N("b") a.refs.append(b); b.refs.append(a) # 互相引用 wa = weakref.ref(a) del a, b # 名字没了, 但它们还互相指着
实测 · 本机执行输出
  a、b 互相引用。del a, b 之前 sys.getrefcount(a) = 3 (a 变量 + b.refs 里的一个 + getrefcount 的实参)
  del a, b 之后, 弱引用还活着吗? True → 引用计数没有归零,对象泄漏在堆里
  gc.collect() 返回(回收的对象数): 4 | 之后弱引用还活着吗? False

del a, b 之后没有任何变量指向它们,可弱引用显示对象还在——因为 a 被 b 引用、b 被 a 引用,计数各剩 1,永远不会归零。gc.collect() 之前,它们就泄漏在堆里。回收数是 4:a、b 以及它们各自的 refs 列表(列表也是对象)。

验证二:gc 只追踪「可能成环」的对象

实测 · 本机执行输出
  gc.get_threshold() = (2000, 10, 10) | 分代数: 3 | gc.isenabled() = False
  gc.is_tracked(整数 1          ) = False
  gc.is_tracked(字符串 'x'       ) = False
  gc.is_tracked(空列表 []        ) = True
  gc.is_tracked(空字典 {}        ) = True
  gc.is_tracked({'a': 1}      ) = True
  gc.is_tracked({'a': []}     ) = True
  gc.is_tracked(元组 (1, 2)     ) = False
  gc.is_tracked(元组 (1, [])    ) = True
  gc.is_tracked(自定义对象 N()     ) = True
  gc.is_tracked(N 的类          ) = True

gc 阈值是 (2000, 10, 10),共 3 代。整数、字符串这类不能引用别的对象的东西不需要被追踪(is_tracked 为 False);列表、字典、自定义对象、类都是被追踪的。元组比较特殊:(1, 2) 没被追踪,(1, []) 被追踪了——因为它里面有一个可变的、能引用别人的对象。这里只列观察到的结果,具体怎样触发「取消追踪」的规则我没有逐条验证。

验证三:自己实现一遍「试探性删除」

和 gc.collect() 的结论对比
实测 · 本机执行输出
  (校准: 只被一个全局名字引用的对象 P, getrefcount 原始值 = 2, 所以观察本身带来 1 个额外引用。下表都已减掉)
  节点  引用计数  减去「图内部的引用」之后(gc_refs)
   A       1          0
   B       1          0
   C       2          1
   D       1          0
   E       1          0
  gc_refs > 0 的是外部可达的根: ['C'] → 从根出发能到达: ['C', 'D']
  我的算法判定的垃圾: ['A', 'B', 'E']
  gc.collect() 之前 __del__ 已调用的: []
  gc.collect() 回收对象数: 3 | __del__ 通知到的: ['A', 'B', 'E'] | 一致? True | 全局 C 还活着: C D

思路是:每个对象的引用计数里,有一部分是「这一批对象互相引用」产生的。把这一部分减掉,剩下的就是「批外的人对它的引用」。剩余 > 0 的是根(有人从外面抓着),根能到达的都活着,其余就是垃圾。校准部分里我用一个只被全局名字引用的对象 P,来扣掉getrefcount观察本身带来的引用。结果:C 是根,D 被 C 引用所以活着,A、B、E 是垃圾,gc.collect() 实际调用 __del__ 的也正好是 A、B、E。

验证四:什么时候会自动触发

用 gc.callbacks 记录
实测 · 本机执行输出
  创建 20000 对环形垃圾, gc 自动触发了 38 次回收, 各代次数: {0: 35, 1: 3} | 共回收对象数: 78912
  最后 3 次回收: [(1, 2160), (0, 2072), (0, 2072)]
  gc.get_stats(): [{'collections': 40, 'collected': 72575, 'uncollectable': 0}, {'collections': 3, 'collected': 6480, 'uncollectable': 0}, {'collections': 3, 'collected': 20, 'uncollectable': 0}]

创建 20000 对环形垃圾,期间 gc 自己触发了 38 次回收,绝大多数是第 0 代(新对象),少数升到第 1 代;每次回收都收走了上千个对象。你不需要手动调用 gc.collect(),但如果代码里有大量互相引用的对象,回收发生的时机是由分配数量而不是时间决定的。

验证五:把 gc 关了会怎样

实测 · 本机执行输出
  gc enabled  : 创建 20 万对环形垃圾后, 仍占用     0.10 MB (峰值     0.21 MB)
  gc disabled : 创建 20 万对环形垃圾后, 仍占用    70.40 MB (峰值    70.40 MB)
  之后 gc.collect() 回收: 800000 个对象

同样创建 20 万对环形垃圾:gc 开着,结束时只占 0.10 MB;关掉 gc,占了 70.4 MB,直到手动 gc.collect() 才一次性回收了 80 万个对象。关 gc 是有人会做的优化(比如批处理、fork 之前),但代价是循环引用产生的垃圾不会被回收。

分步演示:一次试探性删除

数字来自验证三里我自己实现的算法(引用计数的基线已校准)
gc 如何判定循环垃圾
此刻在处理的对象

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 gc、weakref、tracemalloc、sys.getrefcount
    • 我手写的算法是对「试探性删除」思路的复现,不是 CPython 源码的逐行对应(没有读 C 实现);没有验证:分代升级的具体条件、gc.freeze()、带 __del__ 的对象在旧版本 Python 里的行为
    京ICP备2026067502号-1京公网安备11010502063487号