Python 基础体系 · 第 34/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 内存与垃圾回收:引用计数、循环 GC、分代与诊断
在讨论 Python 内存时,首先要区分三个层次:
- 对象是否仍然可达:程序中是否还有路径能够访问该对象。
- 对象是否已经释放:对象占用的 Python 堆内存是否已经进入释放流程。
- 进程是否把内存归还给操作系统:即使对象释放了,内存分配器也可能暂时保留这部分内存供后续 Python 对象复用。
“对象已经不可用”“对象已经被垃圾回收”“进程 RSS 已经下降”不是同一个事件。Python 的引用计数和循环垃圾回收主要解决第 2 个问题;内存分配器是否把空间归还操作系统,则是更低一层的实现问题。
下文讨论的是 CPython 3.14,并特别说明 3.14 小版本之间的差异。Python 语言规范并不要求所有 Python 实现都使用引用计数;引用计数、gc 的代际策略以及许多对象布局细节,主要是 CPython 的实现行为。CPython 当前使用引用计数,并辅以延迟检测循环引用的机制。(docs.python.org)
一、对象布局:对象不仅有“值”
1. Python 对象的基本组成
在 CPython 中,几乎所有 Python 对象都位于堆上。一个对象至少需要携带两类运行时信息:
- 它的类型;
- 它的引用计数。
在 C API 中,所有对象都可以抽象为 PyObject *。对象的具体类型决定它如何解释自身的内存,以及如何执行析构、遍历引用和清除引用等操作。CPython 官方文档明确指出,即使整数对象也具有类型和引用计数。(docs.python.org)
可以把一个对象抽象成:
对象地址
┌──────────────────────────────┐
│ 类型指针 │ ──> int、list、MyClass ...
│ 引用计数 │
│ GC 相关信息(若被 GC 管理) │
│ 类型自身的数据 │
│ 对其他对象的引用 │ ──> 字典、列表、属性等
└──────────────────────────────┘
这不是 Python 语言层面的可见结构,而是理解 CPython 行为的模型。对象的“值”只是其中一部分。例如,一个用户自定义对象通常还可能包含:
- 实例字典
__dict__; - 弱引用列表;
- 类型指针;
- GC 跟踪所需的附加信息;
- 用户属性所引用的其他对象。
因此,sys.getsizeof() 返回的是对象本身的浅层大小,而不是对象图的总大小。它只计算直接归属于该对象的内存,不递归计算对象引用的内容;如果对象由垃圾回收器管理,返回值还会包含 GC 相关开销。(docs.python.org)
import sys
numbers = [1, 2, 3]
nested = [numbers, numbers]
print(sys.getsizeof(numbers))
print(sys.getsizeof(nested))
这里的 nested 只包含两个指针槽位以及列表自身的管理结构;它不会把 numbers 的大小重复计算进去。更重要的是,numbers 被引用了两次,但内存中仍然只有一个列表对象:
nested ──┬──> numbers
└──> numbers
所以,下面两个问题必须分开:
sys.getsizeof(nested):这个列表容器自身多大?nested及其所有子对象总共占用多少内存?
后者需要遍历对象图、去重并处理共享引用,不能简单地把每个对象的 getsizeof() 相加。
2. __dict__、__slots__ 与对象布局
普通用户类的实例通常支持动态属性:
class User:
pass
u = User()
u.name = "Ada"
u.age = 37
这些属性通常存储在实例的 __dict__ 中。实例对象本身和实例字典是两个对象:
u ──> User 实例 ──> __dict__ ──> "name"、"Ada"、"age"、37
__slots__ 可以改变实例布局:
class CompactUser:
__slots__ = ("name", "age")
v = CompactUser()
v.name = "Ada"
v.age = 37
在不声明 __dict__ 的情况下,实例不再自动拥有普通的实例字典;属性可以放入固定的槽位中。这通常减少每个实例的布局开销,但也带来限制:
- 不能随意添加未声明的属性;
- 实例默认不能被弱引用,除非显式加入
"__weakref__"; - 多重继承时可能出现槽位冲突;
__slots__不会递归压缩属性指向的对象;- 如果父类仍然有
__dict__,子类声明__slots__也不能完全消除字典。
例如:
import weakref
class WithoutWeakref:
__slots__ = ("value",)
class WithWeakref:
__slots__ = ("value", "__weakref__")
a = WithoutWeakref()
b = WithWeakref()
try:
weakref.ref(a)
except TypeError as exc:
print(type(exc).__name__)
print(weakref.ref(b))
输出会类似:
TypeError
<weakref at 0x...; to 'WithWeakref' at 0x...>
这说明对象布局不仅影响内存大小,也影响生命周期观察工具和可用协议。weakref 不会增加被观察对象的强引用,因此适合验证对象何时真正失去可达性;但能否创建弱引用,取决于类型布局。
二、引用计数:对象何时可以立即释放
1. 引用计数的定义
对某个对象 o,记:
强引用可能来自:
- 另一个容器对象;
- 模块全局变量;
- 函数局部变量;
- 调用栈中的参数;
- C 扩展中的强引用指针。
当最后一个强引用被释放时:
CPython 通常立即进入对象的释放流程。对于包含其他对象引用的复合对象,释放它还会递减其子对象的引用计数,于是释放可能沿着引用边级联传播。CPython C API 使用 Py_INCREF() 增加强引用,使用 Py_DECREF() 释放强引用;当计数降为零时,类型对应的释放函数会被调用。(docs.python.org)
在 Python 代码中,下面的赋值并不是复制对象:
items = []
alias = items
状态变化可以表示为:
步骤 1:
items ──> list
步骤 2:
items ──┬──> list
alias ─┘
此时 items 和 alias 是两个名称,但它们指向同一个对象。
import sys
items = []
print(sys.getrefcount(items))
alias = items
print(sys.getrefcount(items))
del alias
print(sys.getrefcount(items))
sys.getrefcount() 的结果通常比直觉多 1,因为调用函数时,参数本身会暂时形成一个引用。此外,某些 immortal 对象具有非常大的特殊引用计数,因此不能把返回值当作精确的“真实引用数量”;官方建议除了判断 0 或 1 外,不要依赖其精确数值。(docs.python.org)
2. del 删除的是名称,不是对象
下面的代码中:
x = []
y = x
del x
del x 删除的是名称 x 与对象之间的一条引用边:
删除前:
x ──┬──> list
y ──┘
删除 x 后:
y ──> list
对象不会因为某个名称被删除就必然销毁。只有当所有强引用都消失时,引用计数才可能变为零:
x = []
y = x
del x
del y
在普通 CPython 构建中,第二个 del 通常会使列表立即进入释放流程。但这不等于 Python 语言对所有实现都保证对象在此刻完成销毁,也不等于底层分配器一定立刻把内存交还给操作系统。
3. 引用计数的局限:它只会沿着“减法”传播
引用计数适合处理无环对象图。例如:
root ──> A ──> B ──> C
当 root 被删除时:
root对A的引用消失,RC(A)减一;RC(A)变为零,释放A;A对B的引用消失,RC(B)减一;RC(B)变为零,释放B;- 继续处理
C。
但是,引用计数无法仅凭“计数是否为零”识别孤立的环。
三、循环引用为何需要 GC
1. 一个最小循环
a = []
b = []
a.append(b)
b.append(a)
引用图为:
a ──> list A ──> list B <── b
│ │
└─────────┘
更准确地写:
变量 a ──> A
变量 b ──> B
A ──> B
B ──> A
当外部变量被删除:
del a
del b
外部引用消失,但内部引用仍然存在:
A ──> B
B ──> A
于是:
因为 B 仍引用 A;同理:
所以引用计数永远不会归零,单纯依靠引用计数无法释放它们。
这就是循环垃圾回收器存在的根本原因:它不只计算“有多少引用”,还要判断这些引用是否来自对象集合内部。
2. 从对象图推导循环垃圾
设候选对象集合为:
对集合中的每个对象,定义:
in_total(o):对象收到的全部引用数;in_internal(o):来自集合S内其他对象的引用数;in_external(o):来自集合外部的引用数。
则:
对于上面的循环:
A 收到:
- B 的 1 条内部引用
- 外部引用 0 条
B 收到:
- A 的 1 条内部引用
- 外部引用 0 条
因此:
如果一个对象集合中所有对象都没有外部引用,那么这个集合就是“从程序根不可达”的候选垃圾。GC 可以通过遍历容器之间的引用关系,计算这种内部引用,并尝试清除对象之间的链接。
这是一种帮助理解的抽象模型。真实 CPython GC 需要遵循对象类型提供的遍历和清除协议,而不是简单扫描任意内存。支持循环 GC 的容器类型需要提供遍历引用的 tp_traverse;可变容器通常还需要提供能够断开内部引用的 tp_clear。(docs.python.org)
3. GC 的工作不是“找所有没用的变量”
GC 不能直接判断一个对象是否符合业务语义上的“没用”。它判断的是对象图上的可达性:
解释器根
├── 模块全局变量
├── 当前栈帧
├── 活跃线程
├── 注册表和缓存
└── 其他运行时根
只要仍存在从根到对象的引用路径,对象就是可达的。例如:
cache = {}
def build():
value = bytearray(10_000_000)
cache["large"] = value
即使 build() 返回后局部变量消失,cache["large"] 仍然持有对象。此时这不是 GC 失效,而是程序仍然保留着一条有效引用路径。
相反,循环中的对象可能每个都拥有非零引用计数,但整个循环从根不可达。这正是引用计数和可达性分析之间的差别。
四、分代收集与阈值
1. 为什么要分代
分代 GC 的假设是:新创建的对象更可能很快死亡,长期存活的对象更可能继续存活。
因此,CPython 把被 GC 管理的对象按存活次数分组:
- 第 0 代:新对象;
- 第 1 代:经历过一次收集仍然存活的对象;
- 第 2 代:更老的对象。
对象如果在一次收集中存活,就会晋升到更老的一代;第 2 代是最老的一代。年轻代收集频繁但扫描范围小,老年代收集较少但成本更高。(docs.python.org)
需要区分“所有 Python 对象”和“被 GC 跟踪的对象”。不包含对其他 Python 对象引用的原子对象,通常不需要循环 GC 支持;列表、字典、实例等容器则可能需要被跟踪。可以用以下 API 观察某个对象是否当前被跟踪:
import gc
print(gc.is_tracked(42))
print(gc.is_tracked([]))
class User:
pass
print(gc.is_tracked(User()))
具体结果属于 CPython 实现行为,不能当作跨实现保证。gc.is_tracked() 适合诊断对象是否进入循环 GC 的观察范围,而不是用来推导所有对象的精确布局。
2. 阈值的含义
import gc
print(gc.get_threshold())
print(gc.get_count())
gc.get_threshold() 返回三个阈值:
(threshold0, threshold1, threshold2)
gc.get_count() 返回当前计数:
(count0, count1, count2)
在 CPython 的代际模型中,GC 主要跟踪自上次收集以来的对象分配与释放差值。概念上可以写成:
当:
就会触发一次以第 0 代为起点的收集。第 0 代被检查若干次后,才会进一步检查第 1 代;threshold1 控制这种晋升检查的相对频率。第 2 代的收集策略更复杂,不能简单理解为“每 threshold2 次就完整扫描一次”。(docs.python.org)
例如:
import gc
old = gc.get_threshold()
print("old:", old)
gc.set_threshold(700, 10, 10)
print("new:", gc.get_threshold())
gc.set_threshold(*old)
这段代码修改的是自动收集触发频率,不是对象存活规则:
- 提高
threshold0,通常意味着更少触发年轻代收集; - 降低
threshold0,通常意味着更频繁检查循环; - 阈值过低可能增加 GC 开销;
- 阈值过高可能让循环垃圾在内存中停留更久。
threshold0 = 0 会禁用自动收集,但不会禁用引用计数的即时释放;它只是停止自动循环 GC。可以通过 gc.collect() 手动触发收集。(docs.python.org)
3. Python 3.14 的版本敏感行为
“Python 3.14 的 GC”不能忽略小版本:
- Python 3.14.0 到 3.14.4 曾使用增量式 GC 实现;
- 由于生产环境中出现内存压力问题,Python 3.14.5 又恢复为 Python 3.13 使用的分代 GC;
- 在 3.14.0 到 3.14.4 中,
threshold2被忽略; - 在 3.14.5 起,
threshold2的行为恢复以维持原有分代 GC 行为; generation=1在 3.14.5 起重新表示中间代收集,以保持与 3.13 的行为一致。(docs.python.org)
因此,如果生产环境运行的是 Python 3.14,应至少记录完整版本:
python --version
python -VV
不能只写“我们使用 Python 3.14”,因为 3.14.2 与 3.14.7 的 GC 行为并不完全相同。
五、finalizer、__del__ 与不可回收风险
1. del 不等于调用 __del__
class Resource:
def __del__(self):
print("finalizer called")
obj = Resource()
del obj
del obj 做的第一件事是删除名称绑定,通常表现为减少对象的一条强引用;只有当对象进入销毁流程时,__del__() 才可能被调用。官方数据模型明确说明,del x 不会直接调用 x.__del__()。(docs.python.org)
因此,以下说法都不严谨:
del 语句就是析构
对象离开作用域就一定立即调用 __del__
__del__ 一定在解释器退出时执行
__del__() 的执行时间、执行线程和周围状态都可能不适合做复杂工作。其内部异常会被忽略,并向 sys.stderr 输出警告;如果它获取锁或调用阻塞资源,还可能造成死锁。解释器关闭阶段,全局变量和模块也可能已经被清理。(docs.python.org)
2. finalizer 遇到循环时发生什么
早期 Python 中,带有 __del__() 的循环对象可能因为无法确定安全的析构顺序而进入不可回收状态。PEP 442 改变了这一点:现代 CPython 通常可以收集带有 __del__() 的循环对象,因此它们不再因为仅仅拥有 Python 层的 __del__() 就自动进入 gc.garbage。(docs.python.org)
但这并不意味着 finalizer 没有风险。关键问题包括:
- finalizer 的执行顺序不应假定;
- 一个对象被清理后,另一个对象可能仍然持有它的引用;
- finalizer 可以执行任意 Python 代码;
- finalizer 可以让对象“复活”;
- C 扩展如果没有正确实现 GC 协议,仍可能制造真正不可回收的对象。
3. 对象复活
对象复活是指 finalizer 在对象即将销毁时,又把自己保存到外部可达位置:
import gc
resurrected = None
class Lazarus:
def __del__(self):
global resurrected
resurrected = self
obj = Lazarus()
del obj
print(resurrected is not None)
print(gc.is_finalized(resurrected))
第一次删除后,__del__() 把对象保存到全局变量 resurrected,因此销毁被中止。对象重新变得可达,但它已经经历过 finalization。gc.is_finalized() 可以用于观察这一状态。CPython 文档还指出,复活对象是否再次调用 finalizer 具有实现相关行为;当前支持 GC 的对象会保留 finalized 标记,相关细节未来仍可能变化。(docs.python.org)
因此,finalizer 不适合作为普通资源管理机制。文件、锁、数据库连接、临时目录等资源需要确定性的释放时,应使用上下文管理器:
with open("data.txt", encoding="utf-8") as f:
content = f.read()
对象垃圾回收与外部资源关闭是两件事。垃圾回收只能说明对象不再需要,不能替代业务层的 close()、事务提交或锁释放。
4. gc.garbage 的正确理解
import gc
gc.collect()
print(gc.garbage)
gc.garbage 保存的是 GC 找到但无法释放的对象。现代 Python 中,普通 Python 类即使定义了 __del__(),通常也不会因此进入该列表;列表非空时,重点应怀疑:
- C 扩展类型存在旧式
tp_del; - C 扩展遗漏了
Py_TPFLAGS_HAVE_GC; tp_traverse没有遍历所有引用;tp_clear没有正确断开引用;- 调试选项
DEBUG_SAVEALL把所有不可达对象都保存了下来。
import gc
gc.set_debug(gc.DEBUG_SAVEALL)
a = []
a.append(a)
del a
gc.collect()
print(len(gc.garbage))
gc.garbage.clear()
gc.set_debug(0)
DEBUG_SAVEALL 会改变观察结果:不可达对象不会直接释放,而是保存到 gc.garbage,因此不要在开启该选项后把 gc.garbage 的数量直接当作生产泄漏数量。官方文档特别说明,DEBUG_LEAK 包含 DEBUG_SAVEALL。(docs.python.org)
六、gc 模块:从“怀疑泄漏”到对象图证据
1. 观察收集统计
import gc
for generation, stats in enumerate(gc.get_stats()):
print(generation, stats)
典型输出类似:
0 {'collections': 12, 'collected': 85, 'uncollectable': 0}
1 {'collections': 1, 'collected': 20, 'uncollectable': 0}
2 {'collections': 0, 'collected': 0, 'uncollectable': 0}
统计值是从解释器启动以来累计的,具体数量取决于程序启动过程和导入模块,不能预先写死。每代统计至少包含:
collections:该代被收集的次数;collected:被成功收集的对象数量;uncollectable:发现但无法收集的对象数量。(docs.python.org)
如果服务运行一段时间后:
collections持续增加;collected也持续增加;- 但进程内存短时间上升后能稳定;
这可能只是正常的临时对象和循环对象被周期性清理。真正需要警惕的是,在相同工作负载下,存活对象集合本身持续增长。
2. 使用回调记录 GC 时延
import gc
import time
def gc_logger(phase, info):
if phase == "start":
gc_logger.started = time.perf_counter()
else:
elapsed = time.perf_counter() - gc_logger.started
print({
"generation": info["generation"],
"collected": info["collected"],
"uncollectable": info["uncollectable"],
"seconds": round(elapsed, 6),
})
gc_logger.started = 0.0
gc.callbacks.append(gc_logger)
try:
for _ in range(100_000):
value = []
value.append(value)
gc.collect()
finally:
gc.callbacks.remove(gc_logger)
回调在收集开始和结束时执行。结束阶段的 info 包含代数、成功收集数量和不可收集数量。回调本身应保持轻量,不能在里面执行复杂日志格式化、网络请求或再次触发 gc.collect(),否则会把诊断代码的成本混入 GC 时延。(docs.python.org)
3. gc.get_referrers() 只能用于调试
如果已经定位到某个不应存活的对象,可以查看直接引用者:
import gc
target = []
holder = {"target": target}
refs = gc.get_referrers(target)
for ref in refs:
print(type(ref).__name__)
但这个 API 有两个重要限制:
- 它只查找支持 GC 的容器;
- 返回结果可能包含正在构造中的对象,或者包含调用诊断函数过程中临时产生的引用。
官方文档建议只把 gc.get_referrers() 用于调试;如果想排除已经不可达但尚未收集的循环对象,可以先调用 gc.collect()。(docs.python.org)
诊断时常见的误区是:
refs = gc.get_referrers(target)
然后因为 refs 自己持有对某些对象的引用,又得出错误结论。对象图诊断应尽量缩短观测窗口,避免把诊断变量长期保存。
七、tracemalloc:回答“内存是在哪里分配的”
1. 它跟踪什么
tracemalloc 是 Python 标准库中的内存分配追踪工具。它记录 Python 内存分配块的 traceback,可以按文件、行号或完整调用栈统计分配,并比较两个快照之间的差异。(docs.python.org)
它适合回答:
哪些 Python 代码行分配了更多内存?
某个操作前后,哪些分配增长了?
这些分配最初来自哪条调用栈?
它不能直接回答:
为什么操作系统 RSS 没有下降?
某个 C 库内部通过 malloc 分配了多少?
显卡、数据库驱动或 mmap 映射占用了多少?
C 扩展可以通过 tracemalloc C API 把自定义分配注册到特定 domain,但未经追踪的原生内存不会自动出现在 Python 快照中。(docs.python.org)
2. 端到端快照示例
import gc
import tracemalloc
tracemalloc.start(10)
before = tracemalloc.take_snapshot()
objects = []
for i in range(10_000):
objects.append({
"id": i,
"payload": "x" * 100,
})
after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, "lineno")[:5]:
print(stat)
del objects
gc.collect()
current, peak = tracemalloc.get_traced_memory()
print("current:", current)
print("peak:", peak)
tracemalloc.stop()
这段程序的流程是:
tracemalloc.start(10)安装内存分配追踪钩子,并为每个分配最多保存 10 层栈帧;before记录基线;objects持有大量字典和字符串;after记录操作后的状态;compare_to()计算两个快照的差异;- 删除
objects并调用gc.collect(); - 通过
get_traced_memory()观察当前追踪内存和峰值。
输出中的行号和大小依赖 Python 小版本、解释器启动状态和代码位置,但通常会出现类似:
example.py:9: size=... KiB (+... KiB), count=... (+...), average=... B
size_diff 为正,表示相较旧快照存在更多仍被追踪的分配;但它不自动证明业务对象泄漏。对象可能仍被合法缓存,也可能只是快照、循环变量或异常 traceback 持有。
3. 从进程启动就追踪
如果在程序运行一段时间后才调用:
tracemalloc.start()
则启动前已经发生的分配不会出现在后续快照中。为了覆盖尽可能完整的启动过程,可以使用:
python -X tracemalloc=10 app.py
或者:
PYTHONTRACEMALLOC=10 python app.py
nframe 越大,调用栈信息越完整,但 tracemalloc 自身需要更多内存和 CPU;应通过 tracemalloc.get_tracemalloc_memory() 评估诊断开销。(docs.python.org)
4. 一个完整的泄漏定位流程
假设服务在重复处理请求时内存不断增长,可以按以下因果链验证:
进程内存增长
│
├── tracemalloc 快照差异增长?
│ │
│ ├── 是:定位文件、行号、调用栈
│ │ │
│ │ └── 检查容器、缓存、全局变量、闭包和异常引用
│ │
│ └── 否:怀疑 C 扩展、底层分配器、mmap 或外部资源
│
└── gc.get_stats() 中 collected/uncollectable 异常?
│
├── collected 持续增长:可能有大量循环垃圾
└── uncollectable 增长:检查 C 扩展 GC 协议或 DEBUG_SAVEALL
这条链路的关键是不要把“内存增长”直接等同于“GC 泄漏”。tracemalloc 提供分配位置证据,gc 提供对象收集证据,两者回答不同问题。
八、常见失败表现与反例
1. 只调用 gc.collect(),但外部引用仍存在
cache = []
def load():
data = bytearray(10_000_000)
cache.append(data)
load()
gc.collect()
这里 gc.collect() 不会释放 data,因为 cache 仍然引用它。解决问题的操作不是增加 GC 次数,而是明确缓存的生命周期:
cache.clear()
gc.collect()
如果清空缓存后对象仍然存活,再使用 gc.get_referrers() 或快照比较查找其他引用。
2. 把弱引用误认为缓存
弱引用不保持对象存活:
import weakref
class Item:
pass
item = Item()
reference = weakref.ref(item)
print(reference() is item)
del item
print(reference())
删除最后一个强引用后,reference() 通常返回 None。如果需要对象继续存在,就不能只保存弱引用;如果需要缓存但不希望缓存阻止回收,可以考虑 weakref.WeakValueDictionary 等弱引用容器,但这改变的是缓存语义,而不仅是内存优化。
3. 把 sys.getsizeof() 当作进程内存
import sys
data = ["x" * 1000 for _ in range(10000)]
print(sys.getsizeof(data))
这个值只表示列表容器本身的浅层大小,不包括其中字符串对象的总大小。它更不能代表:
- Python 分配器保留的 arena;
- C 扩展通过原生接口申请的空间;
- 线程栈;
- 动态库;
- 文件映射;
- 内核页缓存。
因此,sys.getsizeof() 适合比较单个对象布局,tracemalloc 适合定位 Python 分配来源,进程 RSS 则需要操作系统层面的工具辅助解释。关于 Python 对象大小,官方文档明确规定 getsizeof() 不递归计算被引用对象。(docs.python.org)
4. 把 __del__ 当作可靠的资源释放钩子
class Connection:
def __del__(self):
self.socket.close()
这个设计的问题不是“GC 一定不会调用 __del__”,而是调用时机和周围状态不适合承载确定性资源管理。更可靠的设计是:
class Connection:
def close(self):
if self.socket is not None:
self.socket.close()
self.socket = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.close()
使用:
with Connection() as conn:
conn.send(...)
__del__ 可以作为兜底日志或尽力清理,但不应作为业务正确性的唯一保障。解释器退出时也不保证仍存活对象的 __del__() 都会被调用。(docs.python.org)
九、生产取舍:何时调整 GC,何时不要调整
调整阈值前,应先确认问题属于哪一类:
| 现象 | 更可能的问题 | 首先观察 |
|---|---|---|
| 大量短生命周期循环对象 | GC 触发太晚或对象设计制造了循环 | gc.get_stats()、gc.get_count() |
| 对象数量稳定但 RSS 上升 | 分配器保留、C 扩展或外部内存 | tracemalloc 与进程级内存 |
gc.garbage 持续增加 |
不可收集对象或调试选项影响 | gc.get_debug()、扩展类型 |
| 某次请求后对象永久存活 | 仍有强引用路径 | gc.get_referrers()、快照差异 |
| GC 暂停时间升高 | 老年代对象过多或容器图复杂 | gc.callbacks、代际统计 |
在明确没有循环引用的特定程序中,可以临时关闭自动 GC:
import gc
gc.disable()
try:
run_workload()
finally:
gc.enable()
但这只适合有证据支持的场景。关闭自动 GC 后,引用计数仍会释放 RC == 0 的对象,却不会自动处理不可达循环;如果程序后来引入缓存回调、父子对象互相引用、异常 traceback 链或第三方容器类型,原先的假设可能失效。
对于 fork() 后拥有大量长期存活对象的进程,gc.freeze() 和 gc.unfreeze() 还可以改变对象参与后续 GC 的方式,但这属于特定进程模型下的优化,不应作为普通服务的默认配置。gc.freeze() 会把当前跟踪对象移入永久代,gc.unfreeze() 再将它们放回最老代。(docs.python.org)
十、建立一条可复现的证据链
一个可靠的内存问题分析,至少应按以下顺序建立证据:
-
确认运行时
python -VV记录完整 Python 版本、构建类型以及是否为 free-threaded 构建。
-
确认进程级现象
记录 RSS、堆外内存、请求次数、并发度和工作负载,避免把一次性峰值误判为泄漏。 -
确认 Python 分配来源
使用-X tracemalloc=10或tracemalloc.start(10),比较稳定操作前后的快照。 -
确认对象生命周期
对目标对象使用weakref.ref()观察其是否仍然存活,并在必要时调用gc.collect()进行对照实验。 -
确认引用路径
使用gc.get_referrers()查找直接引用者,但不把诊断产生的临时引用当作原始证据。 -
确认 GC 行为
记录gc.get_count()、gc.get_stats()、gc.garbage和gc.callbacks中的收集耗时。 -
排除追踪范围之外的内存
如果 RSS 增长而 tracemalloc 没有对应增长,应转向 C 扩展、底层分配器、内存映射和外部资源检查。
引用计数解释“最后一条强引用消失后为何通常能立即释放”;循环 GC 解释“为什么内部互相引用的孤立对象仍需要额外检测”;分代和阈值解释“为什么 GC 不会每次都扫描整个对象图”;finalizer 解释“为什么对象清理不能简单等同于资源关闭”;gc 与 tracemalloc 则分别提供对象图和分配位置的证据。
掌握这条关系链后,“Python 内存泄漏”就不再是一个笼统标签,而可以被拆分为可验证的问题:对象是否仍可达、循环是否被检测、finalizer 是否改变了生命周期、GC 是否产生不可收集对象,以及增长的内存是否根本不属于 Python 对象分配。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 复制与序列化:浅拷贝、深拷贝、pickle 和不可信输入
- 下一篇:Python weakref 与 slots:生命周期观察、对象布局和内存取舍
- 延伸:Python 性能剖析:timeit、cProfile、tracemalloc、采样和证据链
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论