news 2026/10/17 9:02:06

一文深入详解Python循环引用与垃圾回收

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一文深入详解Python循环引用与垃圾回收

前言


循环引用(reference cycle)指的是两个或多个对象互相引用,形成一个闭环。它之所以重要,是因为 CPython 的引用计数在这种结构下会失效:闭环内每个对象的计数都因为别人的引用而无法归零,于是它们既用不上、又释放不掉,只能靠循环垃圾回收器兜底。


一个流传很广的说法是"带__del__的对象进了循环就永远回收不了"。这句话在 Python 3.4 之前基本成立,但PEP 442 之后已经改变:3.4 起,带__del__的循环引用也能被安全地终结和回收。今天还留在gc.garbage里的,通常是某些 C 扩展类型。


本文讲清循环引用如何形成、gc如何找到并回收它、__del__的真实行为,以及用弱引用主动打破环的写法。


一、循环引用是怎么形成的


先看引用计数的失效过程。两个对象互相持有对方:


# 适用于 Python 3.8+
import sys


class Node:
def __init__(self, name):
self.name = name
self.peer = None

def __repr__(self):
return f"Node({self.name!r})"


a = Node("A")
b = Node("B")
a.peer = b # b 的引用 +1
b.peer = a # a 的引用 +1

print(sys.getrefcount(a)) # 通常在 3 左右
del a
del b # 两个名字都没了,但对象还在
print("调用 gc 前,A 和 B 已无人可用,却仍未释放")

执行del a和del b后,模块级名字消失了,但a被b.peer引用、b被a.peer引用,两个计数都停留在 1,永远归不到 0,引用计数机制对此无能为力。这类对象叫不可达但仍被引用的对象组。


注意sys.getrefcount()的返回值通常比真实引用多一(传参产生临时引用),官方文档提醒不要依赖具体数值,所以上面的 3 只能当作粗略观察,不要写进判断逻辑。


二、gc如何找到并回收循环


gc模块的回收器只跟踪容器对象——列表、字典、集合、用户定义的实例等。它周期性地遍历这些对象之间的引用关系,找出"外部已无法到达"的闭环并整体释放。像int、str这类原子类型不会被跟踪,因为它们不可能持有对其他对象的引用。


# 适用于 Python 3.8+
import gc


class Node:
def __init__(self):
self.peer = None


print(gc.is_tracked(Node())) # True,用户定义了实例,会被跟踪
print(gc.is_tracked(42)) # False
print(gc.is_tracked("s")) # False

a = Node()
b = Node()
a.peer = b
b.peer = a
del a, b

print(gc.get_count()) # 当前三代计数,形如 (n0, n1, n2)
freed = gc.collect() # 强制全量回收,返回回收对象数与不可回收数之和
print("本次回收计数:", freed)

gc.collect()的返回值是被回收对象数与不可回收对象数之和,官方文档明确写了这一点。它默认generation=2,也就是做一次全量回收。


分代策略的目的是降低开销:新对象进第 0 代,活过一次回收就晋升到第 1 代,再活一次进第 2 代;大多数对象朝生暮死,因此绝大多数回收只需扫描第 0 代。触发频率由gc.get_threshold()返回的三元组控制。




函数用途注意



gc.collect()强制回收在解释器已处于回收过程中调用,效果未定义

gc.get_count()查看当前三代计数只读

gc.get_threshold()查看回收阈值默认值属实现细节

gc.get_objects()列出被跟踪对象开销大,仅调试用

gc.get_referrers(*objs)反查谁引用了它结果可能处于非法状态,仅调试用



gc.get_referrers()有一条官方警告:返回的对象状态可能是非法的,除了调试不要用于其他用途;并且它只能找到支持垃圾回收的容器,C 扩展类型如果引用了其他对象却不支持 GC,就找不到。


三、__del__与 PEP 442


__del__是终结器(finalizer),在对象被销毁前调用。它有两个不可靠之处:调用时机不确定,以及不保证一定被调用(例如解释器退出时)。


历史上(Python 3.4 之前),如果一个循环里的对象定义了__del__,回收器不敢贸然处理——因为终结器可能复活对象、又访问到已经半销毁的邻居。结果这些对象被标为"不可回收",丢进gc.garbage列表,也就是真正的泄漏。PEP 442 改变了这一点:从 Python 3.4 起,带__del__的循环引用可以被安全终结并回收,gc.garbage大多数时候应当是空的。


# 适用于 Python 3.8+
import gc


class Marker:
def __init__(self, name):
self.name = name

def __del__(self):
print(f"终结 {self.name}")


x = Marker("X")
y = Marker("Y")
x.other = y
y.other = x
del x
del y
gc.collect() # 3.4+ 下两个对象都会被正常终结
print("gc.garbage 长度:", len(gc.garbage)) # 通常是 0

官方文档对gc.garbage的说明是:从 3.4 起这个列表大多数时候应当为空,除非使用了tp_del槽非空的 C 扩展类型实例。也就是说,如果发现gc.garbage长期非空,多半问题出在扩展模块,而不是纯 Python 代码。


四、用弱引用打破循环


与其依赖gc兜底,更主动的做法是一开始就不制造环。典型场景是父子互相引用:父对象需要持有子对象,子对象也需要回指父对象。把其中一个方向改成弱引用(weak reference),环就断了。


# 适用于 Python 3.8+
import gc
import weakref


class Parent:
def __init__(self, name):
self.name = name
self.children = []

def add(self, child):
self.children.append(child) # 强引用子对象
child.parent = weakref.ref(self) # 回指用弱引用


class Child:
def __init__(self, name):
self.name = name
self.parent = None # 存放 weakref 对象

def get_parent(self):
return self.parent() if self.parent is not None else None

def __repr__(self):
return f"Child({self.name!r})"


p = Parent("P")
c = Child("C")
p.add(c)
print(c.get_parent()) # Parent 实例还在时,返回对象
print(p.children) # [Child('C')]
print(gc.is_tracked(c)) # True,但父子之间已无强引用环

弱引用不会增加目标对象的引用计数,因此当父对象被外部释放后,WeakValueDictionary里的条目会自动消失,Child.get_parent()会返回None。常用的弱引用容器还有weakref.WeakValueDictionary、weakref.WeakKeyDictionary、weakref.WeakSet,以及用于注册清理回调的weakref.finalize。


注意:使用__slots__的类默认不支持弱引用,需要把"__weakref__"加进__slots__才会生成__weakref__描述符。


五、诊断思路


排查"内存不降",可以按下面的顺序来:


# 适用于 Python 3.8+
import gc

gc.set_debug(gc.DEBUG_LEAK) # 包含 DEBUG_SAVEALL,被回收的对象会留在 gc.garbage
# ... 复现问题的操作 ...
gc.collect()
print("疑似泄漏对象数:", len(gc.garbage))
gc.set_debug(0) # 关闭调试标志

gc.DEBUG_LEAK等于DEBUG_COLLECTABLE | DEBUG_UNCOLLECTABLE | DEBUG_SAVEALL,开启后所有不可达对象都会保留在gc.garbage里供你检查,代价是它们不会被真正释放,所以诊断完记得关掉。




现象可能原因处理方向



对象删不掉、内存不降循环引用gc.collect()验证,随后用弱引用断环

gc.garbage长期非空C 扩展类型的tp_del检查扩展模块

回收频率过高阈值偏低或对象创建过快gc.get_threshold()/gc.get_stats()

关闭 GC 后泄漏确有循环引用恢复gc.enable()并断环



常见坑点



  1. 以为del一定释放对象


❌


``python # 适用于 Python 3.8+ a = Node() b = Node() a.peer = b b.peer = a del a, b # 循环还在,未必释放``


✅


``python # 适用于 Python 3.8+ a.peer = None b.peer = None # 主动断开引用 del a, b``



  1. 用__del__做关键清理


❌


``python # 适用于 Python 3.8+ class F: def __del__(self): self.flush() # 时机不定,可能不调用``


✅


```python # 适用于 Python 3.8+ class F: def close(self): self.flush()


def __enter__(self): return self


def __exit__(self, *exc): self.close() ```



  1. 在循环里两边都用强引用


❌


``python # 适用于 Python 3.8+ class Child: def __init__(self, parent): self.parent = parent # 强引用父对象``


✅


```python # 适用于 Python 3.8+ import weakref


class Child: def __init__(self, parent): self.parent = weakref.ref(parent) ```



  1. 在解释器退出时依赖__del__输出


❌


``python # 适用于 Python 3.8+ class A: def __del__(self): print("bye") # 退出阶段的调用顺序不保证``


✅


``python # 适用于 Python 3.8+ class A: def cleanup(self): print("bye")``



  1. 把gc.collect()放进热路径


❌


``python # 适用于 Python 3.8+ for item in huge_list: process(item) gc.collect() # 每次都全量回收,开销很大``


✅


``python # 适用于 Python 3.8+ for item in huge_list: process(item) gc.collect() # 循环结束后收一次``



  1. 把gc.get_referrers()的结果用于业务判断


❌


``python # 适用于 Python 3.8+ refs = gc.get_referrers(obj) if len(refs) > 1: do_something() # 结果可能处于非法状态``


✅


``python # 适用于 Python 3.8+ # 仅用于调试打印,不做逻辑判断 print(len(gc.get_referrers(obj)))``



  1. 在带__slots__的类上期待弱引用可用


❌


``python # 适用于 Python 3.8+ class C: __slots__ = ("x",) # weakref.ref(C()) 会 TypeError``


✅


``python # 适用于 Python 3.8+ class C: __slots__ = ("x", "__weakref__")``


总结




要点结论



循环引用计数永不归零,引用计数无法释放

gc职责只回收容器对象之间的循环引用

gc.collect()强制回收,返回回收对象数加不可回收数

__del__3.4 起(PEP 442)循环中的终结器可被处理

gc.garbage3.4 起多数时候为空,非空多为 C 扩展

弱引用不增加计数,用来主动断环



循环引用的本质是"引用计数管不到的死角",而gc就是补这个死角的。实践中的优先级应当是:先靠设计避免制造环(父强子、子弱父),再靠gc兜底,最后才用gc.collect()在关键时刻手动干预。真正的清理工作交给with语句或显式的close(),不要指望__del__。




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 9:01:36

深度学习毕设实战:基于CNN的花卉枯萎识别项目全解析

深度学习毕设别再卷目标检测了,花卉枯萎识别这个方向,论文好写、答辩好讲、代码量适中,关键是你能完整走完“数据-训练-调优-部署”全流程。我前前后后带过好几个做类似项目的同学,也亲手复现过不少次,今天就把这个项目…

作者头像 李华
网站建设 2026/10/17 9:01:19

金融微服务架构设计:基于能力域分治的financial-services落地实践

1. 项目概述:这不是一个“App”,而是一套可落地的金融服务能力组装方案“financial-services”这个标题乍看像某个API文档里的命名空间,或是某家科技公司内部服务模块的代号,但在我过去十年参与过27个银行、保险、持牌消金及区域性…

作者头像 李华
网站建设 2026/10/17 9:01:01

Unity射击Demo拆解:场景、射线AI与打包避坑实战

简介:《unity射击游戏.zip》提供了一套基于Unity3D制作的初级射击游戏完整工程,面向刚入门Unity的开发者,帮助他们把零散概念串联为可运行的小型Demo。项目中通过C#脚本实现了射击数与消灭数的实时计数,用粒子系统呈现子弹轨迹和命…

作者头像 李华
网站建设 2026/10/17 8:59:27

基于昇腾NPU的GRPO强化学习实战:从数独任务到奖励函数设计

我最近在昇腾NPU上完整跑通了一套GRPO强化学习训练流程,任务很简单:教一个小尺寸的开源语言模型解决9x9数独。选这个题不是因为难度高,而是因为数独是一个“规则极其清楚、奖励极其机械”的推理任务——你根本不需要人去打分,写一…

作者头像 李华
网站建设 2026/10/17 8:58:30

CLI-Anything:面向工程化工作流的Agent-Native CLI中枢系统

1. 项目概述:CLI-Anything 不是又一个命令行工具,而是 CLI 生态的“操作系统级抽象层”你有没有遇到过这样的场景:刚在 GitHub 上发现一个超酷的 Python 工具,比如pdf2image或yt-dlp,兴冲冲敲下pip install pdf2image&…

作者头像 李华
网站建设 2026/10/17 8:57:24

C++实战:从零用控制台写贪吃蛇,掌握游戏编程核心

1. 为什么选贪吃蛇作为入门游戏编程的第一课1.1 贪吃蛇是游戏编程的"最小可行产品"很多刚学完C语法的人都会卡在同一个地方:语法书翻完了,循环、数组、函数、指针都认识了,但打开IDE却不知道能写什么。这种"知识在手、项目没有…

作者头像 李华