很多人第一次关心 Python 的垃圾回收,不是因为写了多复杂的代码,而是因为遇到了几个很实际的问题:
del obj之后,内存为什么没有立刻降下来?- 明明没有全局变量引用某个对象,它为什么还活着?
- Python 不是有垃圾回收吗,为什么还会出现内存泄漏?
gc.collect()到底该不该在业务代码里手动调用?
这些问题背后其实是同一个机制:Python 的内存回收不是单一算法,而是几层机制一起工作。平时我们说的“Python 垃圾回收”,在最常见的 CPython 解释器里,主要由两部分组成:
- 引用计数:对象引用数变成 0 时,通常立刻释放。
- 循环垃圾回收器:专门处理引用计数解决不了的循环引用。
这篇文章主要讨论 CPython,因为日常用 python 命令启动的解释器,大多数情况下就是 CPython。PyPy、Jython、IronPython 等实现可以采用不同策略,所以不要把本文所有细节直接套到每一种 Python 实现上。
先从变量和对象的关系说起
在 Python 里,变量不是装对象的盒子,更像是贴在对象上的名字。
a = [1, 2, 3]
b = a
这段代码里没有创建两个列表。它创建了一个列表对象,然后让 a 和 b 都指向它:
a ─┐
├──> [1, 2, 3]
b ─┘
执行:
del a
删除的也不是列表对象本身,而是删除名字 a 到列表对象的那条引用。因为 b 还指向这个列表,所以对象仍然活着:
b ───> [1, 2, 3]
这就是理解 Python 垃圾回收的入口:对象什么时候能被回收,取决于还有没有地方能继续访问它。
del 的含义也要顺手纠正一下:del name 删除的是名字绑定;del obj.attr 删除的是属性引用;del some_list[i] 删除的是容器里的一个引用。它们都不等于“立刻把某块内存还给操作系统”。
第一层:引用计数让多数对象及时释放
CPython 里,每个普通对象都会维护一个引用计数。可以把它理解成:
这个对象现在被多少个地方指着?
当有新的引用指向对象时,引用计数增加;当一个引用消失时,引用计数减少。引用计数减到 0,CPython 就知道这个对象已经无法再被程序访问,于是可以释放它。
看一个简单例子:
class Watch:
def __del__(self):
print("Watch object is being destroyed")
x = Watch()
y = x
del x
print("after del x")
del y
print("after del y")
在 CPython 中,常见输出是:
after del x
Watch object is being destroyed
after del y
del x 之后对象没有销毁,因为 y 还引用着它。del y 之后最后一个引用也没了,对象才被释放。
引用计数的优点很明显:大多数对象的释放非常及时。函数返回时,局部变量引用消失;临时列表用完后引用消失;旧值被新值覆盖后,旧对象的引用也会减少。
比如:
def build():
data = [1, 2, 3]
return sum(data)
build() 返回后,局部变量 data 消失。如果没有别的对象引用那个列表,列表的引用计数会归零,CPython 可以马上释放它。
这也是很多 Python 程序“看起来不怎么需要管内存”的原因:大量短命对象靠引用计数已经被处理掉了。
sys.getrefcount 可以观察,但不要迷信
Python 提供了 sys.getrefcount(),可以粗略观察某个对象的引用计数:
import sys
items = []
print(sys.getrefcount(items))
other = items
print(sys.getrefcount(items))
del other
print(sys.getrefcount(items))
不过它有两个常见坑。
第一个坑是:把对象传给 sys.getrefcount() 本身,也会临时多一个引用。所以你看到的数字通常比直觉多 1。
第二个坑是:新版本 CPython 对一些对象做了特殊优化。例如小整数、常用常量、部分内部对象可能是“近似永久存在”的对象,引用计数值不适合拿来当精确业务指标。
所以 sys.getrefcount() 更适合用来帮助理解引用关系,不适合在生产代码里拿来判断对象生命周期。
引用计数解决不了循环引用
引用计数最大的问题是:它只会数引用数量,不知道这些引用是不是已经和外部世界断开了。
看一个最小的循环引用:
items = []
items.append(items)
现在这个列表里面装着它自己:
items ───> list
│
└──> self
如果执行:
del items
外部变量 items 没了,但列表内部仍然有一个指向自己的引用。引用计数不会变成 0。单靠引用计数,这个列表就永远不会被释放。
再看一个更接近业务代码的例子:
class Node:
def __init__(self, name):
self.name = name
self.parent = None
self.children = []
root = Node("root")
child = Node("child")
root.children.append(child)
child.parent = root
这里 root 指向 child,child 又指回 root:
root ──children──> child
^ │
└──── parent ──────┘
树、图、双向链表、缓存索引、观察者模式,都很容易写出这样的结构。如果外部不再引用 root 和 child,它们其实已经没用了,但引用计数仍然可能不为 0。
这就是循环垃圾回收器要解决的问题。
第二层:循环 GC 只处理可能成环的容器
CPython 的循环垃圾回收器不会扫描所有对象。整数、字符串、字节串这类原子对象不会自己引用别的 Python 对象,单独看它们不可能形成循环。
真正需要关注的是容器对象,例如:
listdictsettuple- 自定义类实例
- 一些 C 扩展类型对象
这些对象可能保存对其他对象的引用,也就可能组成环。
可以用 gc.is_tracked() 看一个对象当前是否被循环 GC 跟踪:
import gc
print(gc.is_tracked(123))
print(gc.is_tracked("python"))
print(gc.is_tracked([]))
print(gc.is_tracked(object()))
不同 Python 版本和对象内部状态会影响具体结果,尤其是元组、字典这类容器会有额外优化。这里要抓住的是大方向:循环 GC 主要盯着能引用别人的对象,而不是每个整数、每个字符串都扫一遍。
看到这里,问题就变成了:循环 GC 怎么判断一个环是真的垃圾,而不是仍然能从外部访问?
循环引用检测的核心直觉
假设现在有两组对象:
外部变量 alive
│
v
A ───> B ───> C
^ │
└───────────┘
X ───> Y
^ │
└─────┘
A-B-C 是一个环,但它能从外部变量 alive 访问到,所以它不能被回收。
X-Y 也是一个环,但没有任何外部引用能访问到它,所以它应该被回收。
循环 GC 要做的事情,就是从一批候选容器对象里判断:
哪些对象只是彼此引用,但已经没有外部入口?
CPython 的算法可以简化成几个步骤理解。
第一步,选出一批候选对象。默认构建里,这批对象通常来自某一代或几代 GC 跟踪对象。
第二步,给候选对象准备一个临时引用计数。这个临时计数来自对象真实的引用计数,但 GC 可以安全地修改它,不会破坏对象本身。
第三步,遍历候选对象之间的引用。每看到一个“候选对象引用另一个候选对象”,就把被引用对象的临时计数减 1。
这一步的含义是:先把候选集合内部互相撑起来的引用扣掉。
扣完之后,如果某个对象的临时计数仍然大于 0,说明它至少还有来自候选集合外部的引用。它是活的。从它继续能到达的对象,也都是活的。
如果一组对象扣完之后没有外部引用能到达,就会被放进“暂时不可达”的集合。等 GC 再确认没有活对象能连到它们,就可以把它们当作真正的循环垃圾处理。
可以把这个过程想成:
真实引用计数
- 候选集合内部引用
= 外部入口贡献的引用
如果外部入口贡献为 0,并且没有活对象能再走到它,这批对象就已经和程序断开了。
这个算法的关键不在于“有没有环”,而在于“有没有外部入口”。有环但可达,不能回收;无外部入口的环,才是循环垃圾。
GC 找到循环垃圾后怎么销毁
找到不可达对象只是第一步,真正销毁还要小心处理几个问题。
假设两个对象互相引用:
class Node:
def __init__(self):
self.other = None
a = Node()
b = Node()
a.other = b
b.other = a
del a
del b
这两个对象已经没有外部变量引用,但它们互相指着。循环 GC 确认它们不可达后,会打断它们内部的引用关系。内部引用一断,引用计数就会继续下降,最后对象被释放。
销毁阶段还要处理弱引用和 finalizer。
弱引用不会增加目标对象的引用计数,常用于缓存、反向引用和观察者关系:
import weakref
class User:
pass
u = User()
ref = weakref.ref(u)
print(ref() is u)
del u
print(ref())
当目标对象销毁后,ref() 会返回 None。
如果对象定义了 __del__,事情会更复杂。现代 CPython 已经能处理大多数带 __del__ 的循环引用,但 __del__ 仍然有几个风险:
- finalizer 的调用顺序不适合承载复杂业务逻辑。
- finalizer 里可能让对象重新变得可达,也就是“复活”对象。
- 解释器退出阶段,全局变量可能已经被清理,
__del__里访问模块状态容易遇到奇怪结果。
所以文件、锁、socket、数据库连接这类资源,不要依赖垃圾回收来释放。更稳妥的写法是上下文管理器:
with open("data.txt", "w", encoding="utf-8") as f:
f.write("hello")
或者显式使用 try/finally:
resource = acquire_resource()
try:
use(resource)
finally:
resource.close()
垃圾回收负责内存对象的生命周期兜底,不应该承担关键外部资源的释放时机。
分代 GC:为什么不每次都扫全部对象
如果每创建一些对象就全量扫描所有容器,程序会很慢。CPython 默认构建使用分代收集来降低成本。
它利用的是一个很朴素的观察:大多数对象很快就会死掉。
比如函数里的临时列表、一次请求里的临时字典、一次解析过程里的中间对象,通常活不过几毫秒。相反,配置对象、路由表、模块对象、长期缓存,一旦活下来,往往会活很久。
所以 CPython 把被 GC 跟踪的对象按“年龄”分代:
| 分代 | 直觉 | 收集频率 |
|---|---|---|
| generation 0 | 年轻对象,新创建的容器通常先在这里 | 最频繁 |
| generation 1 | 活过一轮的对象 | 较少 |
| generation 2 | 活得更久的对象 | 最少 |
对象如果在一次 GC 后仍然活着,就可能被移动到更老的一代。年轻代收集便宜,可以经常做;老年代对象多、扫描贵,就要更克制。
可以用下面的代码观察阈值和计数:
import gc
print(gc.get_count())
print(gc.get_threshold())
print(gc.get_stats())
触发逻辑可以这样理解:
- CPython 记录自上次收集以来的对象分配数和释放数。
- 当“分配数减释放数”超过
threshold0,就会触发年轻代收集。 - 年轻代被收集多次后,会进一步检查更老的一代。
- 最老一代的完整收集更谨慎,因为成本和长期存活对象数量相关。
具体阈值不要背死。不同 Python 版本、不同构建方式、不同启动环境都可能变化。需要调优时,应该在目标环境里看 gc.get_threshold()、gc.get_count() 和 gc.get_stats() 的真实数据。
这里还要注意 Python 3.14 之后的一个版本细节:截至 Python 3.14.6 官方文档,普通 CPython 构建的 gc 接口仍按三代统计;threshold2 在 3.14.5 起恢复为匹配 Python 3.13 的行为。读旧资料时,如果看到“3.14 移除了 generation 1”或“threshold2 被忽略”,要核对具体小版本。
free-threaded build 有什么不同
Python 3.13 开始引入了可选的 free-threaded build,也就是去掉全局解释器锁的构建方向。它的 GC 实现和普通 GIL 构建不完全一样。
普通构建依赖 GIL 保证 GC 期间对象图不会被其他 Python 线程同时改乱。free-threaded build 没有这个前提,所以 GC 需要暂停其他正在执行的线程,才能安全地检查引用关系。
CPython 内部文档也说明,free-threaded build 使用不同的数据结构来追踪对象,循环 GC 的扫描策略和普通分代 GC 不完全相同。官方 gc 模块文档还提到,free-threaded build 在运行收集前会额外参考进程内存增长情况,避免只因为分配计数达到阈值就过早触发。
对普通 Python 使用者来说,这部分通常不用每天关心。但如果你在测试无 GIL Python、写 C 扩展、或者排查多线程程序里的 GC 停顿,就不能只拿传统 GIL 构建的经验直接判断。
为什么对象释放了,内存还是没降
这是 Python 内存问题里最常见的误解之一。
对象被回收,表示这个 Python 对象不能再被访问,它占用的那块内存可以被解释器复用。但这不等于进程 RSS 一定马上下降。
原因有几类。
第一,CPython 有自己的内存分配器。很多小对象由 pymalloc 管理,内存会按 arena、pool、block 这样的层次复用。对象释放后,空间可能回到 Python 的分配器里,留给后续对象使用,而不是立刻还给操作系统。
第二,一些内置类型有 free list。某些对象释放后,会被放进内部空闲列表,方便下次快速复用。完整 GC 会清理部分内置类型的 free list,但不是所有内存都会因此还给系统。
第三,RSS 包含的不只是 Python 对象。C 扩展、图片处理库、NumPy、Pandas、PyTorch、数据库客户端、压缩库,都可能在 Python GC 管理范围之外分配内存。
第四,内存碎片也会影响观感。即使很多小块已经空出来,只要所在的大块内存还夹着活对象,操作系统也未必能整块回收。
所以看到“对象已经释放,但进程内存没降”,不一定是泄漏。更实际的问题应该是:
这部分内存后续能不能被复用?
长期观察是否持续增长?
增长来自 Python 对象,还是来自 C 扩展和外部分配器?
这就是下一节要解释的地方:该怎么观察 GC 和内存。
gc 模块能帮我们看什么
gc 模块提供了一组调试和调优接口。日常最常用的是这几个:
import gc
print(gc.isenabled())
print(gc.get_count())
print(gc.get_threshold())
print(gc.get_stats())
gc.get_count() 返回当前三代计数。它不是对象数量,而是 GC 用来判断触发时机的计数。
gc.get_threshold() 返回触发阈值。
gc.get_stats() 返回每代从解释器启动以来的统计信息,比如收集次数、收集到的对象数量、不可回收对象数量。
如果想手动触发一次完整收集,可以调用:
import gc
collected = gc.collect()
print(collected)
gc.collect() 返回收集到的对象数加上不可收集对象数。它适合在测试、调试、基准测试边界、批处理阶段结束后使用,不适合在每个请求、每次循环里随手调用。手动全量 GC 可能制造明显停顿。
调试疑似循环泄漏时,可以这样做:
import gc
gc.set_debug(gc.DEBUG_SAVEALL)
# 运行一段可疑代码
gc.collect()
print(len(gc.garbage))
DEBUG_SAVEALL 会让不可达对象先留在 gc.garbage 里,方便检查。但它也会阻止这些对象真正释放,所以只应该在调试环境使用,查完要关闭调试标志并清理状态。
还可以用:
gc.get_referrers(obj)
gc.get_referents(obj)
它们分别查看“谁引用了这个对象”和“这个对象引用了谁”。这两个接口对排查引用链很有用,但返回结果里可能包含解释器内部对象、临时栈帧、调试器对象。不要在业务逻辑里依赖它们。
如果要排查“哪里分配了内存”,tracemalloc 往往比直接看 GC 更有帮助:
import tracemalloc
tracemalloc.start()
# 运行可疑代码
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
print(stat)
tracemalloc 关注 Python 层内存分配位置。它和 gc 解决的问题不一样:gc 帮你理解对象能不能被回收,tracemalloc 帮你定位内存是从哪行代码分配出来的。
一个可运行的循环回收实验
下面这个实验能直观看到循环引用为什么需要 GC:
import gc
import weakref
class Node:
pass
gc.disable()
node = Node()
node.self = node
ref = weakref.ref(node)
del node
print(ref() is None)
gc.collect()
print(ref() is None)
gc.enable()
第一行输出通常是:
False
因为 node 变量虽然没了,但对象还通过 self 属性引用自己。引用计数没有归零。
手动 gc.collect() 后,输出变成:
True
循环 GC 找到这个不可达的自环对象,打断内部引用,对象被释放,弱引用也失效了。
这个例子也说明了一件事:关闭循环 GC 不等于关闭所有内存回收。引用计数仍然会工作。被影响的是那些引用计数无法处理的循环引用。
什么情况下需要调 GC 参数
大多数程序不需要调 GC。默认参数已经能覆盖大量普通场景。
需要考虑调优的情况通常更具体:
- 程序频繁创建大量短命容器,GC 触发太频繁,带来明显停顿。
- 程序保留了大量长期存活对象,完整收集成本很高。
- 批处理任务在某个阶段制造大量循环对象,希望阶段结束后集中回收。
- prefork 服务希望减少
fork()后的 copy-on-write 内存损失。
调阈值可以用:
import gc
old = gc.get_threshold()
gc.set_threshold(3000, 10, 10)
但这类修改一定要用指标验证。观察至少应该包括:
- GC 每代收集次数。
- 每次收集耗时。
- 请求延迟或任务耗时。
- 进程内存长期曲线。
gc.get_stats()里的 collected 和 uncollectable。
如果只是凭感觉把阈值调大,可能短期减少停顿,长期积累更多循环垃圾;把阈值调小,又可能让程序把更多时间花在 GC 上。
对于 prefork 场景,gc.freeze() 是一个更特殊的工具。官方文档建议的思路是:父进程尽早 gc.disable(),在 fork() 前 gc.freeze(),子进程启动后再 gc.enable()。目的不是“让 GC 更强”,而是尽量减少父进程里长期对象的内存页在子进程中被写脏,从而保留 copy-on-write 共享。
这类技巧适合 Gunicorn、uWSGI 这类预加载应用后再 fork worker 的服务。普通脚本通常没必要使用。
常见误区
误区一:Python 只有标记清除 GC。
不准确。CPython 首先依赖引用计数;循环 GC 是补充机制。循环 GC 的检测过程会分析对象图里的可达性,但不能把 CPython 简化成“只有标记清除”。
误区二:del x 就是释放内存。
del x 只是删除一个引用。如果还有别的引用,对象继续活着。即使对象释放,内存也可能先回到 CPython 分配器,而不是立刻回到操作系统。
误区三:手动 gc.collect() 能解决内存泄漏。
它只能回收已经不可达的循环垃圾。如果对象仍然被全局缓存、闭包、日志上下文、任务队列、线程局部变量引用着,gc.collect() 不会把它变成垃圾。
误区四:禁用 GC 可以提升性能,所以线上应该关掉。
禁用循环 GC 只适合非常明确的场景,比如确认程序不会制造循环引用,或者在某个短时间关键阶段临时关闭。长期关闭可能让循环垃圾越积越多。
误区五:看到 RSS 不降就是 GC 没工作。
RSS 不降可能来自 Python 分配器复用、内存碎片、C 扩展分配、系统分配器策略。判断泄漏要看长期趋势和对象引用链,不能只看某一刻的进程内存。
实战排查思路
如果线上 Python 服务内存持续增长,可以按这个顺序排查。
第一,看增长是不是稳定可复现。偶发高峰后保持平台,不一定是泄漏;每小时持续抬升,更值得追。
第二,用 tracemalloc 或采样工具找 Python 层分配热点。先回答“内存从哪里分配出来”。
第三,用对象统计工具看哪些类型数量持续增长。可以结合 gc.get_objects() 做粗略统计,但要注意它只覆盖 GC 跟踪对象。
第四,针对可疑对象查引用链。gc.get_referrers()、objgraph、调试器都可以帮忙,但要过滤掉调试工具自身制造的引用。
第五,区分 Python 堆和 native 内存。如果 Python 对象数量稳定,但 RSS 持续增长,重点看 C 扩展、缓存库、模型推理框架、内存映射文件和系统分配器。
第六,确认资源释放策略。文件、socket、连接池、线程、进程、GPU 显存都不应该只靠对象析构来管理。
这套顺序比上来就调 gc.set_threshold() 更可靠。GC 参数调优只能优化回收节奏,不能替你修掉仍然被引用的对象。
最后总结
理解 Python 垃圾回收,可以记住这条主线:
普通对象生命周期:
引用计数归零 -> 立刻释放
循环引用对象:
引用计数无法归零 -> 等循环 GC 找到不可达环 -> 打断引用 -> 释放
回收到 Python 分配器:
对象不可访问 -> 内存可复用
但进程 RSS 不一定马上下降
再压缩成几句话:
- Python 变量是引用,不是对象本身。
- CPython 的第一层回收是引用计数,所以多数对象释放很及时。
- 引用计数处理不了循环引用,循环 GC 专门补这个缺口。
- 循环 GC 主要扫描容器对象,不会无脑扫描所有值。
- 分代 GC 是为了减少扫描成本,利用“大多数对象死得早”这个事实。
gc.collect()是调试和边界控制工具,不是内存泄漏万能药。- 外部资源要用上下文管理器,不能指望 GC 按你想要的时间释放。
把这几层分开后,很多 Python 内存问题就不再神秘:对象为什么还活着,要看引用链;内存为什么不降,要看分配器和 RSS;程序为什么偶尔停顿,要看循环 GC 触发和扫描成本。