Python 垃圾回收与循环引用深度解析:引用计数是主力,分代 GC 收拾残局
一、引言:Python 也会"内存泄漏"?
很多人对 Python 的印象是"不用管内存"。这话对了一半。
Python 确实没有 C++ 那种"忘了 delete 就泄漏"的日常负担,底层靠**引用计数(reference counting)**在对象不再被引用时立刻回收。但引用计数有一个它自己解决不了的死角:循环引用。两个对象互相指着对方,引用计数永远归不了零,计数机制就失效了。
所以你会在真实项目里看到这种诡异现象:一个处理完的请求对象明明"没人用了",进程 RSS 却缓缓往上爬,爬到几 GB 才被周期性 GC 清掉,严重的甚至一直爬——这就是循环引用漏网。本文把这套机制讲透,并和 C++ 的内存管理正面比对。
1 | import tracemalloc, gc |
二、核心概念:引用计数 ob_refcnt
CPython 里每个对象头部都有个计数器 ob_refcnt。引用 +1、解引用 -1,归零就立即调用析构并释放。它是确定性的——对象"死"的时机你基本能算出来。
1 | import sys |
引用计数的内存布局(示意):
1 | 对象 Node |
C++ 对照:C++ 没有引用计数(除非你用 std::shared_ptr)。裸指针 Node* p = new Node; 得手动 delete p;,忘了就泄漏,多 delete 一次就双重释放,悬垂指针更是经典坑。C++ 的"确定性析构"靠 RAII——对象离开作用域,析构函数立刻跑。Python 的引用计数路径也有这种确定性,但 GC 路径没有(见下)。
三、深入:循环引用为何漏网,以及分代 GC 怎么补
3.1 计数归不了零的环
1 | import gc |
环的内存布局:
1 | a <-----------> b |
引用计数看到的是"还有人指着",于是两个对象谁也不回收——这就是漏网。
3.2 分代 GC 来兜底
CPython 的 gc 模块跑一套分代、追踪式(tracing)垃圾回收:把对象分 0/1/2 三代,新对象进第 0 代;每代有一个分配/回收的阈值,触发后从根集(栈、全局变量等)出发做可达性分析,凡是"从根集出发怎么走都走不到"的对象,不管有没有环,一律回收。
1 | import gc |
关键点:追踪式 GC 不依赖计数归零,而是看"还能不能从根到达"。环里再怎么互指,只要外部没人能到达它们,就判定为垃圾。
3.3 __del__ 的陷阱
如果环里的对象带 __del__(析构方法),且 GC 无法确定安全的回收顺序,旧版本会把整个环丢进 gc.garbage,不自动回收——轻则泄漏,重则你还得手写清理逻辑。
1 | import gc |
现代 Python(3.4+)已能尝试给带
__del__的环定序回收,但不可靠。工程上最佳实践是:别在会成环的对象上放__del__,或改用weakref打破环(见下节)。
四、实战:用 weakref 打破循环 + 排查泄漏
4.1 weakref:引用但不计数
弱引用(weak reference)指向对象,但不增加 ob_refcnt。用它连环,环就不再是"强引用环",GC 能正常回收。
1 | import weakref, gc |
4.2 真实排查套路
1 | import gc |
更进一步的图形化排查可用 objgraph(objgraph.show_backrefs、objgraph.count())直接画出"谁还拽着这个对象不放",比盲猜高效得多。
4.3 一个匿名化的真实踩坑
某次排查一个常驻服务的内存缓涨:一个"请求上下文"对象里挂了个回调列表,回调又闭包捕获了上下文自身,于是 ctx -> callbacks -> ctx 成环。上下文本该随请求结束释放,结果堆积到数万条才被 GC 周期性清掉。修法是把回调对上下文的引用改成 weakref,环断,内存曲线立刻平稳。要点:只要对象会"自己指着自己这一族",就先想 weakref。
五、对比总结:C++ vs Python,计数 vs 追踪 vs 分代
| 维度 | C++(裸指针 / RAII) | Python(引用计数 + 分代 GC) |
|---|---|---|
| 释放时机 | 手动 delete / 离开作用域确定性析构 |
引用计数即时 + 分代 GC 周期兜底 |
| 循环引用 | 无 GC,须手动断环或用 weak_ptr |
分代 GC 自动收不可达环 |
| 确定性析构 | 有(作用域结束即析构) | 仅 refcount 路径确定;GC 路径不确定 |
| 典型坑 | 悬垂指针、双重释放、遗忘泄漏 | 循环引用泄漏、__del__ 环、gc 暂停引起卡顿 |
| 排查工具 | Valgrind、ASan、Heob | gc 模块、objgraph、tracemalloc |
| 心智负担 | 高(要自己管每一块) | 低,但需懂"环"这一死角 |
一句话收束:引用计数解决 99% 的日常释放,分代 GC 专门收拾那 1% 的循环引用残局;写 Python 不必手动管内存,但只要对象会"互相抱着",就得记得 weakref 这把剪刀。

