Python 内存管理这块,我在不同的项目里反复被问到过,也从踩坑里学到不少东西。很多人写 Python 写了好几年,天天用列表、字典、类实例,但你要问他“这个对象到底什么时候被销毁?内存什么时候还给系统?为什么有时候 Python 进程占了 1 个 G 的内存就是不降下来?”——大多答不上来。
这篇文章不做教科书搬运,我就把 CPython 的内存管理机制掰开揉碎讲清楚,重点放在引用计数、垃圾回收、循环引用这三个核心机制上,再结合我实际干活时用到的排查手段和优化经验,给你一份能直接拿去用的参考。
1. 内存管理的整体设计思路拆解
1.1 Python 为什么不用手动管理内存
写 C 和 C++ 的同学对内存释放一定不陌生,malloc 出来的东西要记得 free,new 出来的对象要记得 delete,忘了就是内存泄漏,释放早了就是悬垂指针,程序崩得莫名其妙。这玩意儿是个巨大的心智负担,所以 Python 选择了另一条路:自动内存管理。
那自动管理怎么做?市面上大致有三条路线:
- 引用计数(Reference Counting):每个对象维护一个计数器,记录自己被引用的次数。引用数为 0 就立即销毁。
- 追踪式垃圾回收(Tracing GC):从根对象出发,遍历所有可达对象,没被遍历到的就是垃圾,统一回收。Java 的 JVM、Go 的 runtime 就是这条路线。
- 两者混合:CPython 就是这么干的,以引用计数为主,以分代垃圾回收为辅。
CPython 选择引用计数作为主方案,一个原因就是实现简单、回收及时。一个对象引用计数归零,内存立刻还回系统,不像 JVM 那样还要等 GC 触发。当然这个方案有个臭名昭著的伴生问题:循环引用。两个对象互相引用,各自引用计数永远不为零,内存就泄漏了。为了兜底这个缺陷,CPython 才额外实现了分代垃圾回收器,专门对付循环引用。
这个组合设计我觉得是 Python 最灵魂的部分之一:用一个优雅的辅助机制,去弥补主机制的结构性短板。
1.2 和 C 语言的内存管理对比来看
很多人在学 Python 的时候会疑惑,为什么 Python 不用像 C 那样管内存?对比一下两边的思路就清楚了:
| 维度 | C 语言 | Python (CPython) |
|---|---|---|
| 分配 | malloc / calloc | 内存池 + 对象特定分配器 |
| 释放 | free 手动调用 | 引用计数归零自动触发 |
| 泄漏风险 | 漏 free 就泄漏 | 循环引用可能导致泄漏 |
| 悬垂指针 | 存在,且难排查 | 不存在(引用计数保证) |
| 回收时机 | 程序员决定 | 引用计数归零立即回收 |
| 处理循环引用 | 不关你事(手动断开) | 分代 GC 定期清理 |
我写 C 的时候,最烦的就是排查堆内存泄漏,Valgrind 跑一遍,密密麻麻的告警,逐行核对 release 逻辑,太痛苦了。Python 把这块的负担拿掉了大半,但代价是运行时多了一堆簿记工作:每个对象都要多一个字段存引用计数,每次赋值、传参、容器插入都要做计数加减。所以 Python 比 C 慢,这是结构性成本,不是优化能完全抹平的。
1.3 CPython 以外的选择
这里要敲一个重点:这篇文章聊的是 CPython,也就是你从 python.org 下载的官方版本。其他 Python 实现走的是完全不同的路线:
- PyPy:用的是追踪式 GC,没有引用计数,性能在长跑型服务上往往比 CPython 好。
- Jython (JVM) / IronPython (.NET):直接复用宿主平台的垃圾回收器。
- MicroPython:面向嵌入式,内存管理做了极简化。
所以你要是听别人说“Python 的垃圾回收机制”,先确认他聊的是不是 CPython,不然很多结论是对不上的。日常工作里绝大多数人用的都是 CPython,所以下面的内容都基于 CPython 展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 引用计数:Python 内存管理的基石
2.1 引用计数到底怎么工作的
引用计数的核心逻辑就是一句话:每个 Python 对象都有一个计数器,记录当前有多少个“指针”指向它。对象一出生,计数就是 1;每多一个引用,计数加 1;每少一个引用,计数减 1;计数跌到 0,对象立刻被销毁,内存被回收。
在 C 语言层面,Python 的每个对象头部都有 PyObject 结构,里面长这样:
c复制typedef struct _object {
PyObject_HEAD
Py_ssize_t ob_refcnt; // 引用计数
PyTypeObject *ob_type; // 对象类型
} PyObject;
ob_refcnt 就是那个计数器。你在 Python 层写代码时感觉不到它的存在,但它时刻都在跳动。
来看个最简单的例子,我用 sys.getrefcount() 来查看引用计数:
python复制import sys
a = [1, 2, 3]
print(sys.getrefcount(a))
# 输出 2
为什么是 2 而不是 1?因为 getrefcount(a) 这行代码执行的时候,把 a 作为参数传给了函数,这个传参动作本身就让引用计数临时加了一次。所以你在任何地方调用 getrefcount,看到的数字都要减 1 才是对象的真实引用数。
继续往下看,引用计数是怎么变化的:
python复制import sys
a = [1, 2, 3] # 列表对象的计数:1
b = a # 计数:2
c = a # 计数:3
lst = [a] # 列表里存放 a 的引用,计数:4
del b # 计数:3
del c # 计数:2
del lst # 计数:1 (lst 没了,里面指向 a 的引用也跟着消失)
del a # 计数:0,对象被销毁,内存回收
每次 = 赋值、把变量塞进容器、把对象作为参数传入函数、把对象作为返回值返回,都会让引用计数发生变化。理解了这个,你就能明白 Python 里“变量是引用”这个说法的真实含义了。
2.2 引用计数归零后发生了什么
当一个对象的引用计数从 1 掉到 0,CPython 会立刻执行销毁流程:调用 __del__ 方法(如果有)、释放对象占用的资源、把内存块交还对象分配器。整个过程是确定性的——你不需要等待任何 GC 触发,引用归零的那一刻,回收就发生了。
这个特性在某些场景下极其重要。比如文件句柄、数据库连接、网络 socket 这类资源,如果封装在对象里,当引用计数归零时资源就会被释放,不需要显式调用 close。但这同时也带来一个反直觉的坑:如果一个资源对象被一个循环引用困住了,__del__ 永远不会被触发,资源就一直在那里晾着。
再举个例子,Python 的 with 语句那么香,本质就是帮你保证资源一定被释放。但如果你写的是个长驻内存的服务,某个对象的引用计数归不了零,那它占的资源就一直在,日积月累就是事故。
2.3 可变对象在容器里的引用陷阱
很多初学者写代码时,在列表里存了对象,就以为“存在列表里就安全了”。其实不然。看这个:
python复制a = [1, 2, 3]
b = a
a.append(4)
print(b) # [1, 2, 3, 4] b 也变了
# 修改 a 没有改变 b 对同一个对象的引用
# a 和 b 本来指向的就是同一个东西,改的是那个东西本身
这个例子大家都懂,但真正容易出错的是把可变对象藏进了不可变容器:
python复制t = ([1, 2], [3, 4]) # 元组是不可变的,但里面的列表是可变的
t[0].append(100) # 合法!t 本身没变,但里面的列表变了
print(t) # ([1, 2, 100], [3, 4])
引用计数管的是“引用”的增增减减,它不管你引用指向的对象内容是可变还是不可变。这种设计带来的一个隐性问题是:你在多个地方持有同一个对象的引用,任何一个地方修改了对象内容,所有引用方都能感知到——这就是著名的“别名(aliasing)”问题。
2.4 引用计数的性能开销
这年头大家都很在乎性能,引用计数也不是免费的午餐。每次你写 a = b,CPython 内部要执行 Py_INCREF(b);每次函数结束、局部变量消亡,要执行 Py_DECREF(b)。这些操作都是原子性的,涉及全局解释器锁(GIL)的保护,所以多线程环境下还有锁竞争的开销。
有人会问:这个开销大吗?平均一个赋值操作可能要额外执行几条 C 指令,在纯 Python 层你感觉不到,但如果你在做高性能计算、大循环里频繁创建临时对象,这个开销就很可观了。这也是为什么 NumPy 底层用 C 写、用数组而不是 Python 列表逐个操作的原因之一——减少对象创建和引用计数的频次,性能成倍提升。
3. 分代垃圾回收:专治循环引用
3.1 为什么有了引用计数还不够
引用计数的最大软肋就是循环引用。看这个:
python复制class Node:
def __init__(self):
self.next = None
self.data = "x" * 1000
a = Node()
b = Node()
a.next = b
b.next = a
del a
del b
执行完 del a; del b 之后,发生了什么?a 指向的对象被 b.next 引用着,b 指向的对象被 a.next 引用着,两个对象的引用计数从 2 掉到了 1,但谁也到不了 0。这一对对象就成了内存里的孤岛,没人能访问到,但又被彼此拽着不放,永远回收不了。
关键是:这段逻辑不会报错,也不会崩,就是静悄悄地吃内存。如果这个发生在长驻服务里,日积月累,内存就会缓慢增长,最后 OOM。
操作系统的回收、sys.getrefcount 都救不了它,因为引用计数机制从根上就看不出这俩对象是垃圾。
3.2 CPython 的分代回收器设计
为了解决循环引用,CPython 引入了一个辅助的分代垃圾回收器。它的设计朴素但极其有效,核心就是“分代”二字:
- Python 把所有需要参与垃圾回收追踪的对象划分为三“代”(generation),编号 0、1、2。
- 第 0 代:新创建的对象都从这一代开始。
- 第 1 代:从第 0 代活过一次垃圾回收扫描的对象,会被晋升到第 1 代。
- 第 2 代:从第 1 代活过一次扫描的对象,晋升到第 2 代。
- 第 2 代存活时间最长,也是扫描频率最低的一代。
为什么这么设计?因为实测数据显示:大多数对象都是短命的,创建出来很快就变成垃圾。只有少数对象能活过几轮 GC。既然短命对象多,那就对年轻对象勤快点扫描;长命对象少且稳定,就少扫描几次,节省开销。
三个代的触发阈值用 gc.get_threshold() 查:
python复制import gc
print(gc.get_threshold())
# 输出类似 (700, 10, 10)
这个三元组的意思:
- 第 0 代新增对象数量达到 700,就触发一次第 0 代扫描。
- 第 1 代扫描的次数达到 10,就触发一次第 1 代扫描(也就是晋升 0 代对象到 1 代)。
- 第 2 代扫描的次数达到 10,就触发一次第 2 代扫描。
说人话就是:新对象攒到 700 个就查一次;第 0 代扫了 10 次,才扫一次第 1 代;第 1 代扫了 10 次,才扫一次第 2 代。年代越老,查得越少,这也是对性能的妥协和优化。
3.3 标记-清除算法的工作过程
分代回收器用的算法是标记-清除(Mark and Sweep)。过程分三步:
- 标记阶段(Mark):从根对象(全局变量、调用栈、寄存器等)出发,沿着引用链遍历所有可达对象,把它们标记为“存活”。
- 清除阶段(Sweep):遍历所有被追踪的对象,那些没有被标记到的,就是不可达对象,也就是垃圾,直接回收。
- 晋升阶段:存活下来的年轻对象,晋升到上一代。
这里有个很隐蔽但重要的细节:分代回收器只处理“可追踪对象”。哪些是可追踪对象?只有那些可能形成循环引用的容器对象,比如 list、dict、set、自定义类的实例等等。整数、字符串、浮点数这种简单对象不会被追踪,因为它们永远不可能引用别的对象,也就不可能参与循环引用。
看一个实际检查循环引用的办法,你可以用 gc.collect() 手动触发一次回收,再用 gc.garbage 看看有没有回收不了的对象:
python复制import gc
class MyClass:
pass
a = MyClass()
b = MyClass()
a.ref = b
b.ref = a
del a, b
print(gc.collect()) # 返回本次回收了多少对象
# 输出可能是 2 或更多,说明循环引用被识别并回收了
注意:gc.collect() 返回的数字是本次回收的对象数量,包含循环引用的对象和其他可回收垃圾。你还可以传代数参数,gc.collect(0) 只回收第 0 代,gc.collect(1) 回收 0 代和 1 代,gc.collect(2) 全量回收。
3.4 del 方法和循环引用打架的坑
这里有个大坑,写过很多年代码的人都踩过:带 __del__ 方法的对象,如果陷入循环引用,垃圾回收器不会回收它,而是直接把它扔进 gc.garbage 列表里,由你手动处理。
为什么?因为 __del__ 是自定义的析构逻辑,GC 无法确认这个析构函数会不会访问到正在被回收的其他对象。为了安全起见,CPython 选择不自动清除这类对象,而是丢给你处理。
看个例子:
python复制import gc
class Evil:
def __init__(self):
self.ref = None
def __del__(self):
print("我被回收了")
a = Evil()
b = Evil()
a.ref = b
b.ref = a
del a, b
print("手动回收前")
gc.collect()
print("gc.garbage:", gc.garbage)
运行这段代码,你会发现 __del__ 没有被调用,两个对象进了 gc.garbage。在你的代码里,这俩对象就泄漏了,除非你手动断开它们的循环引用。
踩过这个坑之后,我的习惯是:能不用 __del__ 就不用,资源释放优先用 with 和 contextlib.closing。如果非要写析构逻辑,脑子里时刻绷着一根弦:这个对象会被别的对象引用吗?引用会不会循环?会不会因为 __del__ 泄漏?
4. 解决循环引用的实战手段
4.1 weakref 弱引用:不增加引用计数
循环引用的本质是两个对象互相“抓住”对方。要打破这个环,最优雅的工具是 weakref,它让一个对象可以引用另一个对象,但不增加引用计数。被引用的对象引计数照样跌到 0,照样被回收,而弱引用会自动失效(变成一个“死亡引用”)。
看这段代码:
python复制import weakref
class Node:
def __init__(self, name):
self.name = name
self.next = None
self.parent = None
a = Node("A")
b = Node("B")
a.next = b
b.parent = weakref.ref(a) # 持有弱引用
print(b.parent()) # <__main__.Node object at ...>
del a
print(b.parent()) # None,因为 a 已经被回收
注意,b.parent() 返回的是 a 对象本身,但如果 a 已经被回收,就返回 None。用 weakref.ref 必须通过调用操作符 () 来获取对象,这就是“弱引用是间接的”这个特点。
weakref 模块还提供了 WeakKeyDictionary、WeakValueDictionary、WeakSet,这些容器特别适合做缓存和观察者模式。比如在做缓存的时候,你希望缓存不阻止对象被回收,用 WeakValueDictionary 是最合适的:
python复制import weakref
cache = weakref.WeakValueDictionary()
class Heavy:
pass
h = Heavy()
cache["key"] = h
print(cache["key"]) # 对象还在
del h
print(cache["key"]) # KeyError,因为对象已经被回收了
这个特性在实现缓存、对象池、事件监听器、树结构的父节点指针时非常实用。凡是“引用只是为了方便访问,不想因此延长生命周期”的场景,都可以考虑弱引用。
4.2 显式断开循环引用
弱引用不是万能的,有些场景你控制不了对象之间的引用关系,那还有一个朴素的办法:在对象不用了之后,显式把引用关系断开。
典型的做法是提供一个 close() 或 cleanup() 方法:
python复制class Node:
def close(self):
self.next = None
self.prev = None
a = Node()
b = Node()
a.next = b
b.prev = a
# 使用完毕
a.close()
b.close()
清掉互相的引用之后,引用计数就能正常归零,__del__ 也能正常触发。这个方法不够优雅,但确实是最可控、最不依赖机制的方式,写库给外部用的时候尤其要考虑到这一点。
4.3 自引用函数的陷阱
还有一类很隐蔽的循环引用,很多人压根没意识到。看这个:
python复制def outer():
x = [1, 2, 3]
def inner():
return x
return inner
f = outer()
# f 闭包引用了 x,而 x 是从 outer 的作用域里来的
# 如果 f 被某个全局引用持有,x 就永远不会被回收
闭包捕获了外层函数的变量,形成了一个引用链。如果闭包对象本身还活着,外层变量就无法回收。这种“隐式循环引用”在事件回调、装饰器、生成器里很常见。
还有一种更隐蔽的:类和实例方法的绑定方法。obj.method 拿到的是一个绑定方法对象,它持有 obj 的引用。如果你把绑定方法存到 obj 自己的属性里,比如:
python复制class Demo:
def run(self):
print("run")
d = Demo()
d.callback = d.run # 对象引用绑定方法,绑定方法又引用对象
循环闭环了。这类 bug 在 GUI 编程、信号槽机制、回调注册里非常容易出现。
处理办法没啥高深的,就是别把绑定方法存到对象自己身上。真要存回调,存函数本身或者用弱引用包一层。
4.4 为什么函数内部的临时循环引用不用太担心
关于循环引用,还有个好消息:如果循环引用的对象都是函数内部的局部变量,那函数结束之后,这些对象就已经不可达了——即使它们互相引用,GC 在下一次扫描时也能把它们识别出来回收掉。
所以循环引用真正需要担心的是藏在全局变量、长生命周期容器、类属性里的那部分。写代码的时候,多想想你创建的对象会不会被某个活得比你预期的还久的东西引用着。
5. 内存回收的实际体验:什么时候回收、什么时候不回收
5.1 引用计数 vs 分代 GC 的触发时机
很多初学者会困惑:我啥时候该手动调 gc.collect()?答案其实取决于你的程序对内存的敏感程度。
先总结一下两类回收的触发时机:
| 回收机制 | 触发条件 | 典型场景 |
|---|---|---|
| 引用计数 | 引用计数归零立即回收 | 局部变量出作用域、del 变量、容器销毁 |
| 分代 GC | 第 0 代达到阈值(默认 700 个新对象) | 大量临时对象创建、循环引用、频繁创建销毁容器对象 |
在绝大多数业务代码里,你什么都不用做,CPython 会把这一切按部就班地办好。真正需要你干预的场景有两类:
第一类:内存占用偏高但暂时不想回收。比如你在一个游戏循环里,每帧创建大量临时对象,频繁触发 GC 反而会产生 CPU 卡顿。这时候可以暂时用 gc.disable() + gc.set_threshold(0) 把自动回收关掉,在帧末或空闲时手动触发一次性回收。
第二类:性能敏感阶段不想被 GC 打断。比如你正在写一个实时音频处理的循环,GC 的 stop-the-world 暂停会导致音频卡顿,就要在这段热点代码执行期间暂停回收。
看一段临时关掉 GC 的代码:
python复制import gc
gc.disable() # 关闭自动回收
# ... 执行热点代码 ...
gc.collect() # 手动回收一次
gc.enable() # 恢复自动回收
这里是有代价的。关闭 GC 期间如果产生了大量循环引用对象,内存占用会持续上涨,所以你要控制这个时间窗的长度,并且确保手动回收能覆盖到该回收的东西。
5.2 常用对象的内存保障手段:内存池(PyMalloc)
引用计数和分代 GC 是对象层面的回收机制,但在更底层,CPython 还有一个叫 PyMalloc 的内存池机制,值得了解一下。
PyMalloc 做的事是:为了避免频繁向操作系统申请小块内存的开销,CPython 会一次性地向系统申请比较大的内存块,然后内部再把这些内存划分成大小固定的小块,按需分配给小的 Python 对象。
这在操作系统层面看,就是 CPython 进程启动后占的内存会多一些,但内部的内存分配和释放都很快,不用每次 malloc / free 都进入内核态。类比一下,就像你装修房子不会每次要一颗螺丝都跑一趟五金店,而是每次买一大盒放家里慢慢用。
这个机制的代价是:内存池里空闲的小块未必能及时还给操作系统。所以你经常会看到,一个 Python 进程创建了一堆临时对象,删掉之后进程占的内存并没有下降——那些内存还在 PyMalloc 的内存池里躺着,等待下次分配使用。
这就是很多人误以为“Python 有内存泄漏”的常见原因。实际不是泄漏,是“缓存”。如果你用 psutil 查看进程的 RSS 内存,会发现占用很高,但如果你强制触发一次 gc.collect() 或者进程内对象大量减少,RSS 可能依然不降。这是 PyMalloc 的特征,不是 bug。
小于等于 512 字节的对象走 PyMalloc 内存池,大于这个的会直接用 malloc。知道这个特性后,你就能理解为什么大对象(比如大列表、大数组)删除后 RSS 下降比较明显,而小对象删了跟没删一样。
5.3 判断内存有没有泄漏的工具
要真正确认一个 Python 进程是否存在内存泄漏,不能靠“感觉”,要靠工具。我用的最多的有三件套:
第一件:tracemalloc
Python 3.4+ 自带的 tracemalloc 模块,可以追踪对象的内存分配来源,输出内存热点。对于定位内存增长非常有用:
python复制import tracemalloc, time
tracemalloc.start()
# 模拟工作负载
data = []
for i in range(10000):
data.append("item" * 100)
time.sleep(1)
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:10]:
print(stat)
输出会告诉你每一行代码分别占了多少内存、在哪分配的对象最多。排查“内存默默上涨”问题的时候,先跑一段 tracemalloc 拿到快照,基本能锁定大方向。
第二件:gc 模块调试
python复制import gc
gc.set_debug(gc.DEBUG_LEAK)
设置之后,GC 会把回收不掉、引起泄漏的对象打印到标准错误流,并转储到 gc.garbage 列表。有两种情况它会打印:一是类定义中有 __del__,二是对象被循环引用困住而无法回收。
第三件:memory_profiler
第三方库,它可以按逐行统计内存使用情况。给目标函数加上 @profile 装饰器,运行后就能看到每行的内存增量(MiB):
bash复制pip install memory_profiler
python -m memory_profiler my_script.py
这个工具最直观,适合定位函数级别的内存热点。缺点是有一定的运行时开销,生产环境不建议开,本地压测和调优就够了。
5.4 生成器和迭代器:内存友好的利器
再补一个实战性很强的点:如果想减少 Python 程序的内存峰值,生成器几乎是零成本的解决方案。
普通函数全量返回一个列表,所有数据一次性进入内存;生成器则是一个惰性求值工厂,每次 next() 只产出当前一个值。处理几百万行日志、海量数字计算时,用生成器可以让内存占用从 GB 级降到百 MB 级。看代码:
python复制# 一次性加载,内存爆炸
def load_logs(filepath):
lines = []
with open(filepath, 'r') as f:
for line in f:
lines.append(process_log(line))
return lines
# 生成器惰性处理,每次只留一条数据在内存
def load_logs_lazy(filepath):
with open(filepath, 'r') as f:
for line in f:
yield process_log(line)
for entry in load_logs_lazy("big.log"):
# 处理单条 entry,内存占用极小
pass
数据量小的时候看不出差别,数据量上了百万,差别就是“卡死”与“流畅”的对比。
6. 常见问题速查表与排坑心得
6.1 高频问题排查对照表
我在实战中遇到的问题,整理成了一张表,遇到相关现象直接对应排查就行:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 进程内存居高不下 | PyMalloc 内存池缓存 | 用 RSS 长期观察,不一定是泄漏 |
| 内存持续线性增长 | 循环引用,或闭包引用 | tracemalloc 快照对比 |
__del__ 不执行 |
对象陷入循环引用,且进入了 gc.garbage | gc.set_debug(gc.DEBUG_LEAK) 观察 |
| 大量短生命周期对象导致 GC 频繁 | 第 0 代阈值太低,或代码创建对象过多 | 调高 gc.set_threshold,或用生成器 |
| 绑定方法回调导致内存不释放 | 对象持有绑定方法,形成自引用环 | 用函数代替绑定方法,或用弱引用 |
| 大数据处理内存爆炸 | 一次性加载太多数据 | 用生成器 / 分块处理 / NumPy |
| 多线程下内存增长异常 | 线程函数闭包了大型数据 | 检查线程生命周期和线程局部数据 |
6.2 调优 GC 参数的思路
gc.set_threshold 可以帮你微调 GC 触发频率,但别拍脑袋改,要有依据。我先说思路:如果你的程序运行尾声有明显的 GC 停顿,说明 GC 扫描的对象太多,你可以提高阈值减少扫描频率,但同时内存峰值会上涨。如果你发现内存上涨太快,则相反。
生产环境我一般不建议大幅调整 GC 参数,默认值对大多数程序都够用。真正该做的是用 tracemalloc 定位哪些代码在疯狂造对象,然后从代码层面优化,比如复用对象、换生成器、改用 NumPy。GC 参数只是治标,代码层面减少对象创建才是治本。
6.3 聊几个我踩过的真实坑
第一个坑:在大型 ETL 任务里用闭包存了 DataFrame。当时写了一个数据处理流程,闭包里把每一批处理完的 DataFrame 都要存到外层列表,因为想“等所有批次跑完统一处理”。结果数据量一大,进程直接 OOM。最后改成生成器逐个处理,内存占用从 8GB 降到了 300MB 以内。
第二个坑:自定义类里写了 __del__ 做清理,结果对象被循环引用困住。当时做一个图结构,节点之间有互相引用,每个节点 __del__ 里要关闭数据库连接。上线后跑了一周,数据库连接数爆了。排查半天,发现节点对象全部滞留在 gc.garbage 里,__del__ 从没执行过。后来把 __del__ 改成显式的 close() 方法,在逻辑确保不再使用节点后手动调用,问题立刻解决。
第三个坑:把回调绑定方法存到了事件对象里。做事件最快响应模块的时候,事件对象里存了订阅者的绑定方法,订阅者对象又通过事件系统引用了事件对象,形成闭环。结果事件系统运行时间越长,内存越大,最后崩了。换成只存函数名 + 弱引用的方式,完美解决。
这三个坑有一个共同点:问题不在写业务功能的代码,而在对象生命周期管理。当你设计的类互相引用比较密集的时候,建议把“谁在什么时间引用谁”画出来,在关键生命周期节点明确释放关系,心里有谱才不会等到 OOM 再去救火。
6.4 什么时候该手动干预 GC
把这个问题说透,什么场景值得手动干预?我总结下来就两个:
一是后台服务、常驻进程。这类程序运行时间长,内存管理要是出问题,影响是日积月累的。我会在关键路径上监控 gc.get_count(),看看各代对象增长速度,如果异常偏快,就主动 gc.collect() 一下并做日志告警。
二是对延迟敏感的实时程序。比如游戏服务器、量化交易系统,GC 的暂停时间可能影响体验。此时可以用 gc.freeze()(Python 3.7+)把初始化阶段创建的对象冻结起来,让 GC 在后续扫描时直接跳过它们,减少扫描量。如果程序生命周期里对象大多在启动阶段创建,这个方法实测非常有用。
7. 最后说点我个人实际操作的体会
写 Python 这些年,我最大的感悟是:内存管理不是出了 OOM 才去研究的,而应该在设计阶段就想清楚对象的生命周期。你写的每一个类、每一个缓存、每一个回调,都要在大脑里过一遍“谁在引用谁、什么时候可以释放”。
多花十分钟分析代码里对象的引用关系,比上线后拿着 memory_profiler 排查一整天要划算得多。也别迷信换语言能解决内存问题——C++ 手动管理内存,坑只有更深;Java 有 GC 不假,但对象生命周期设计不合理一样内存暴涨。任何语言的自动内存管理,都只是帮你处理了琐碎的部分,核心的对象生命周期设计,永远是程序员自己的责任。
再分享一个小技巧:在任何长期运行的 Python 程序中,启动时保留一份内存基线,然后定期拍摄 tracemalloc 快照,存成序列化文件,等出问题的时候直接对比前后快照差异。 这样排查内存问题时,不用面对一片空白的状态,拿着两份快照逐行比较,问题基本当场就能定位。这招救过我很多次,比临时起意去抓现场高效太多。
