← 返回题目列表

Python 如何回收循环引用?引用计数和垃圾回收有什么关系?

高频 困难 第 15 / 27 题 更新于 2026/07/31
垃圾回收引用计数循环引用gc

简化版

CPython 主要靠引用计数回收对象:引用数变成 0,对象通常会立刻释放。但引用计数处理不了循环引用,所以还需要分代垃圾回收器定期扫描容器对象,找出彼此引用但已经不可达的对象。

详细版

引用计数的优点是及时、简单:变量、容器、栈帧等每多持有一次对象引用,对象引用数加 1;引用消失时减 1;降到 0 就释放。缺点是两个对象互相引用时,即使外部已经访问不到它们,引用数也不为 0。

循环引用的典型例子是列表互相包含、对象之间互相保存对方。CPython 的循环 GC 主要跟踪可能参与循环的容器对象,如 listdict、自定义对象;对于纯数字、字符串这类不包含其他对象引用的简单对象,通常不需要进入循环检测。

分代 GC 基于一个经验:大多数对象“朝生夕死”,存活越久越可能继续存活。新对象先进入年轻代,经过多次扫描仍存活就晋升到老年代。面试回答时可以说:引用计数负责大多数立即释放,分代循环 GC 负责补引用计数处理不了的循环垃圾。

完整版教学

一、为什么引用计数很适合 CPython

Python 程序会频繁创建临时对象,比如函数调用里的局部变量、表达式中间结果、列表推导式临时结果。引用计数让这些对象在没人使用时尽快释放,不必等到某个统一停顿时刻。这个特性让资源释放也比较直观,比如文件对象没人引用后可能很快触发关闭逻辑。

看一个数字例子:a = [] 创建列表后,变量 a 持有一次引用;b = a 后引用数至少加 1;del a 只减少一次引用,列表仍被 b 指向,不会释放;再 del b,如果没有其他引用,引用数归零,就可以释放。

a = []      # list 被 a 引用
b = a       # 同一个 list 又被 b 引用
del a       # 引用减少,但 b 还在
del b       # 最后一个外部引用消失

这种机制的代价是每次引用变化都要维护计数。它也解释了为什么 CPython 和 PyPy 的内存释放时机可能不同:引用计数是 CPython 的实现细节,而不是 Python 语言规范要求所有实现都一样。

二、循环引用为什么会漏过引用计数

引用计数只能回答“还有多少引用指向我”,不能回答“这些引用是否还能从程序入口访问到”。循环引用的问题就在这里:对象彼此引用,导致引用数不为 0,但整个环已经没有外部入口。

a = []
b = []
a.append(b)
b.append(a)
del a
del b

删除 ab 后,两个列表仍然互相引用:

外部变量:  无

list A ──引用──> list B
  ▲              │
  └────引用──────┘

如果只看引用数,A 和 B 都不是 0;如果看可达性,它们已经从任何局部变量、全局变量、栈帧都到不了。循环 GC 就是为了解决这种“计数不为 0,但整体不可达”的垃圾。

三、循环 GC 扫描的对象范围

循环引用只可能发生在“能引用其他对象”的容器对象之间。比如 list 可以装对象,dict 可以保存 key/value,自定义对象可以有属性引用其他对象;而普通 int、很多 str 对象本身不再引用别的 Python 对象,通常不需要参与循环检测。

这能降低扫描成本。假设进程里有 100 万个整数和 2 万个容器对象,循环 GC 没必要把所有整数都拿来做图扫描;它关注那些可能形成引用环的对象。否则每次 GC 都全堆扫描,成本会非常高。

对象类型是否可能参与循环GC 关注点
int、简单 str通常不会不需要循环检测
listdictset可能内部元素引用
自定义对象可能属性引用
函数闭包、栈帧可能局部变量和闭包单元

判断循环引用时不要只盯着“变量名”,变量名删除后,对象之间的引用图才是关键。

四、分代回收为什么能减少开销

分代 GC 的核心经验是:新对象更可能很快死亡,老对象更可能继续存活。把所有对象每次都扫一遍太贵,所以 CPython 把被跟踪对象放进不同代,年轻代扫描更频繁,老年代扫描更少。

可以用一个简化时间线理解:

创建对象 -> 第 0 代
第 0 代扫描后仍存活 -> 晋升
多次存活 -> 更老的代
老年代 -> 扫描频率降低

假设一个 Web 请求创建了 10_000 个临时对象,其中 9_500 个在请求结束前引用计数就归零了,剩下 500 个容器对象进入年轻代检查。频繁扫描年轻代就能覆盖大多数短命对象;老年代少扫,避免长期缓存、模块级对象反复消耗 GC 时间。

五、__del__、弱引用和资源释放的坑

__del__ 的对象参与循环引用时要格外谨慎,因为解释器需要决定析构顺序。现代 Python 已经比早期更能处理带 finalizer 的循环,但资源释放仍不应依赖“什么时候 GC”。文件、锁、连接这类外部资源,应该优先用 with 或显式关闭。

弱引用 weakref 可以打破某些引用环。普通引用会增加对象引用计数,弱引用不会阻止对象被回收,常用于缓存、观察者列表、父子关系里的反向引用。

import weakref

class Node:
    pass

parent = Node()
child = Node()
child.parent = weakref.ref(parent)

如果父对象只被弱引用指向,它仍然可以正常回收。代价是使用弱引用时要处理“对象可能已经不存在”的情况,调用弱引用返回 None 时不能继续当真对象用。

六、怎么排查和控制 GC

Python 提供 gc 模块观察和控制循环回收。常用方法有 gc.get_count() 查看各代计数,gc.collect() 手动触发回收,gc.get_objects() 查看被跟踪对象。生产排查内存问题时,还会结合 tracemalloc、对象增长曲线和请求维度日志一起看。

import gc

print(gc.get_count())
unreachable = gc.collect()
print("unreachable:", unreachable)

手动 gc.collect() 不是性能优化银弹。它可能引入明显停顿,尤其在对象图很大时。更好的方向通常是减少不必要的对象保留、避免全局缓存无限增长、用上下文管理器释放外部资源、为缓存设置容量。

七、常见误区与追问

  • 误区:Python 只有垃圾回收,没有引用计数。 CPython 的主力机制是引用计数,循环 GC 是补充机制。
  • 误区:引用计数能处理所有垃圾。 循环引用会让对象引用数不为 0,需要循环 GC 通过可达性判断清理。
  • 误区:del x 一定释放对象内存。 del 只是删除一个引用;只有最后一个强引用消失,对象才可能释放。
  • 追问:为什么不扫描所有对象? 大量简单对象不会形成循环,全量扫描成本高,所以主要跟踪容器对象并使用分代策略。
  • 追问:弱引用有什么用? 它引用对象但不增加强引用计数,适合缓存和反向引用,能降低引用环风险。
  • 追问:为什么资源释放不要依赖 GC? GC 时机不稳定,不同 Python 实现差异明显;文件、锁、连接应该用 with 或显式关闭。
  • 追问:循环引用一定是内存泄漏吗? 不一定,循环 GC 可以回收不可达环;真正危险的是仍可达的全局缓存、注册表、闭包持有等。

八、加强记忆

把 CPython 内存回收记成“两层保险”:引用计数负责绝大多数对象的即时释放,循环 GC 负责引用计数看不穿的不可达引用环。再用“容器才会成环、年轻代多扫、老年代少扫、资源用 with”这条线串起来。面试里只说“Python 有 GC”太轻,补上引用计数、循环引用、分代扫描和弱引用边界,才算真正把机制讲清楚。