你有没有遇过这种场景:一个Python服务跑上一整天,内存像温水煮青蛙一样一点点往上爬,重启之后立刻恢复正常,可业务代码翻来覆去也找不到哪里“泄漏”了?我第一次认真研究这个问题,是在一个常驻调度服务里——每3秒跑一轮任务,内存稳步上涨,查了半天IO句柄、日志堆积、外部连接,最后发现真正的元凶根本不是业务代码,而是我对Python内存管理机制的几个基础认知出现了偏差。
这篇想聊聊CPython里的三件事:引用计数、垃圾回收(GC)、内存池。它们共同决定了“对象什么时候死”“死了以后内存去哪了”“为什么内存看着高却未必是泄漏”。也会带上我用gc模块、objgraph、tracemalloc排查内存增长的实际套路。适合写过较长驻服务、被内存问题折磨过,或者单纯想理解Python内存原理的开发者。
1. 引用计数:Python默认的“自动释放”引擎
先强调一个大前提:引用计数不是Python语言规范强制要求的,是CPython解释器的核心实现。PyPy、Jython等实现用的是另一种垃圾回收策略。所以下面所有内容都限定在“CPython 3.x”这个语境下讨论,这也是绝大多数人实际使用的环境。
CPython里每个对象都是PyObject结构体,开头两个核心字段:引用计数ob_refcnt和类型指针ob_type。引用计数就是对象的“存活名片”,它记录着当前有多少个变量、容器、临时位置指向这个对象。计数一旦归零,对象立刻走析构流程并释放内存。
1.1 从对象创建看引用计数的增减规则
引用计数听着简单,但实际增减点很多,最容易漏的是“放入容器”和“函数传参”这两类。我整理了一张表,覆盖了常见场景:
| 场景 | 对引用计数的影响 |
|---|---|
| 新对象创建,变量指向它 | 计数从0变1 |
| 另一个变量引用同一对象 | +1 |
| 对象作为参数传入函数 | +1 |
| 函数返回,局部变量生命周期结束 | 对应局部变量的引用-1 |
| 对象放入列表/字典/集合 | +1 |
| 对象从容器中移除 | -1 |
| 变量被重新赋值成其他内容 | 旧对象计数-1 |
del某个变量 | 对应对象计数-1 |
代码验证最直接:
import sys obj = [1, 2, 3] print(sys.getrefcount(obj)) # 2:变量obj的引用 + 传给getrefcount时的临时引用 ref = obj print(sys.getrefcount(obj)) # 3 del ref print(sys.getrefcount(obj)) # 2注意sys.getrefcount本身就有一个临时参数引用,所以结果会比“真实引用数”多1。另外,如果你在交互式环境或调试器里跑这段代码,输出往往会更大,因为工具的变量查看、历史记录也会持有对象。这时候别慌,看增减趋势比看绝对值有意义。
1.2 引用计数归零:立即销毁与__del__触发
和其他语言的垃圾回收相比,CPython的引用计数有一个非常鲜明的特点:确定性释放。意思是只要引用计数降到0,对象马上被回收,不需要等某个GC周期。
class Demo: def __del__(self): print("Demo 被回收") d = Demo() del d # 立刻打印 “Demo 被回收”这里有个经验陷阱:在IPython或Jupyter里跑,del d可能不会立刻打印,因为交互环境会把变量存进历史记录,相当于多了一个看不见的引用。真实业务代码里如果确认没有其他引用,释放时机是可以预期的。
1.3 引用计数的代价:GIL下的线程安全与性能损耗
引用计数不是免费的。每次赋值、函数调用、容器操作都要做计数加减,还要检查是否归零,这本身就是一笔持续的开销。更麻烦的是多线程:不同线程可能同时操作同一个对象的引用计数,加减操作必须原子化。CPython选择用GIL统一保护引用计数,这也是GIL很难被彻底移除的原因之一——一旦去掉,引用计数的线程安全就是个巨大问题。
所以你会发现,纯Python代码在频繁构造小对象时会很吃力。优化思路通常也在这里:减少临时对象创建、复用结构、尽量用内置的高效容器。
2. 循环引用:引用计数解决不了的那道坎
引用计数很直接,但有一个结构性问题:它只能管“引用链没有闭环”的情况。一旦对象之间互相引用,计数就永远减不到零。
2.1 环是怎么形成的
写一个最经典的例子:
class Node: def __init__(self): self.parent = None self.children = [] a = Node() b = Node() a.children.append(b) # b 的引用计数 +1 b.parent = a # a 的引用计数 +1此时a和b互为引用,即使把外界对a、b的引用都删掉,两者的计数仍然是1。程序退出前,这组对象既无法被引用计数回收,也确实不再是垃圾?不是,它们已经不可达了,但它们互相把对方“撑住”了。
并不是所有对象都会进入GC的视野。像整数、字符串这种不能引用其他对象的类型,不需要被追踪;只有可能形成引用网的容器、自定义实例才需要。可以用gc.is_tracked验证:
import gc class Node: def __init__(self): self.children = [] n = Node() print(gc.is_tracked(n)) # True,实例的__dict__可能引用其他对象 print(gc.is_tracked(42)) # False,整数无法引用他人GC的追踪对象是垃圾回收的工作范围,引用计数是“即时释放”,这两个机制是互补的。
2.2 标记-清除:从根开始找仍然活跃的对象
GC面对循环引用的思路不是“数引用”,而是“画地图”。它从根集合出发——模块全局变量、当前运行栈上的局部变量、调用帧等——沿着每个对象对外的引用一路遍历,把所有仍可达的对象标记为“活着”。堆上没有被标记的对象,全都视为垃圾,统一回收。
这个区别很关键:垃圾回收里的“垃圾”定义是“从根不可达”,而不是“引用数为0”。引用计数是从个体视角数“谁引用我”,标记-清除是从整体视角判断“我还能不能从根被发现”。
我常用的类比是:引用计数像挨家挨户问“你们家有没有人记着隔壁老王”,标记清除则是从居委会的名单出发,把所有还能联系上的人画在一张通讯录里,名单之外的人直接销户。两种思路各有代价:
| 维度 | 引用计数 | 标记-清除(GC) |
|---|---|---|
| 触发时机 | 引用变化时即时处理 | 分代达到阈值后扫描 |
| 能否回收循环引用 | 不能 | 能 |
| 释放确定性 | 即时、可预测 | 有延迟,可能停顿 |
| 主要开销 | 每次赋值、传参都在做计数 | 全堆扫描与标记 |
2.3 PEP 442:Python 3.4之后循环引用回收更可靠了
这里有个老版本的大坑:Python 3.4之前,如果循环引用里的对象定义了__del__,GC会因为无法确定终结器的调用顺序而拒绝处理它们,把对象一股脑丢进gc.garbage列表,需要开发者手动清理。很多老教程至今还在教“用gc.garbage判断泄漏”,那其实是旧时代的产物。
Python 3.4引入PEP 442,实现了更安全的终结机制:收集器会对需要执行__del__的对象做排序,再逐个安全调用,带终结器的循环对象也可以被回收了。所以现在排查内存问题,基本不需要再过度担心“__del__导致循环引用无法回收”这件事。但__del__本身仍有其他坑,这个放到第六章细说。
既然GC能处理循环引用,那是不是可以抛弃引用计数、全面改用标记清除?不行。标记清除需要扫描整个堆,频繁全量扫描会卡顿。于是CPython又引入了分代机制。
3. 分代垃圾回收:为什么年轻对象要先被照顾
分代回收的核心来自一条实践统计:绝大多数对象创建之后很快就死了。程序里大量临时对象存活时间极短,只有少数对象能活过几次回收。既然如此,就没必要每次都扫描全部对象,把精力集中在“最可能产生垃圾”的年轻对象上最划算。
3.1 分代假设与默认阈值:新对象为什么频繁被扫描
CPython把内存中的GC追踪对象分成三代:0代、1代、2代。默认阈值是(700, 10, 10),触发逻辑是:
- 每一代记录“分配对象数减去释放对象数”,当0代的这个差值超过700,触发一次0代收集。
- 0代收集后仍然存活的对象晋升到1代。
- 0代收集每完成10次,触发一次1代收集。
- 1代收集每完成10次,触发一次2代收集。
所以2代收集的间隔大致是“新增对象数约70000个”的水平,比0代宽松得多。年限越大的对象,被扫描的频率越低。
可以用gc.get_count()看当前各代的状态:
import gc print(gc.get_threshold()) # (700, 10, 10) print(gc.get_count()) gc.collect() print(gc.get_count())get_count()返回的元组含义是:当前0代累计变更数、距离上次0代收集的0代收集次数、距离上次1代收集的1代收集次数。看着有点绕,但配合阈值就能理解“下一代收集还要多久触发”。
3.2 手动回收与阈值调参:什么情况下需要动手
大部分脚本用默认阈值就够,但有些场景值得手动干预。
场景一:程序刚启动时一次性加载了海量数据、创建了大量临时对象,之后进入稳定期。这时0代收集频繁触发,过去的临时对象还会被晋升到1代甚至2代,造成无谓扫描。可以考虑在启动加载完成后主动调用gc.collect(),把“不再会变化”的一次性数据清理掉。
场景二:某个批量处理窗口内短期创建了几百万个小对象,GC频繁触发导致卡顿。可以临时调高0代阈值,甚至在这个窗口内gc.disable(),结束后再gc.collect()。但长驻服务里绝对不要长期禁用GC,否则循环引用会越积越多。
阈值调多少算合理?我的建议是先开gc.set_debug(gc.DEBUG_STATS)观察一段时间,看每次收集的对象数量与耗时分布,再基于数据调整。盲目把0代阈值调到几万,很可能换来“每次收集卡顿几百毫秒”的代价。
4. 内存池与空闲缓存区:回收之后内存去哪了
引用计数把对象“释放”了,GC把循环垃圾“回收”了,然后呢?内存真的返回操作系统了吗?答案往往是否定的。
4.1 pymalloc:小对象为什么不要走系统malloc
CPython在系统malloc之上又实现了一层内存池,专门处理小对象分配。以512字节为分界线:小于等于512字节的请求由pymalloc管理,超过512字节的大对象才交给系统malloc直接分配。
pymalloc的逻辑很像批发市场。如果每个小对象都直接找系统要内存,每次都要发起malloc系统调用,频繁申请小块还很容易产生碎片。pymalloc会一次性从系统申请较大块区域,再切分成固定大小的“池子”(pool),池子内部再按统一规格切成小块。对象释放后,内存块留在池中复用,下次分配同类大小对象时直接取走,不再打扰操作系统。
这种设计让大量小对象的分配和释放速度明显提升,代价是这些内存不会立刻还给OS。从进程外部看,RSS会维持在一个偏高水平。
4.2 free list缓存:为什么内存占用常常“降不下来”
pymalloc只管内存块的复用,而一些内建类型还在自己的类型结构里维护了“空闲对象链表”。比如float、tuple、list、dict在释放对象时,会把有限数量的对象放在内部缓存里,下次创建同类型对象时直接复用。这样做是为了加速同类高频操作,同时减少了重复初始化的开销。
带来的直接现象是:Python进程在某些操作后内存上涨,之后即便手动gc.collect(),RSS也可能一点不降。因为那些内存都被缓存留着复用了,不是泄漏,只是“舍不得还”。
判断是缓存还是泄漏,最快速的办法是:脚本里跑一次gc.collect(),对比前后RSS,再继续观察一段时间,看内存会不会持续爬升到新高点。如果每次collect后都能回到差不多的基线,基本是缓存+回收滞后;如果基线本身一路抬高,才值得认真追查。
4.3 快速判断实验:RSS与gc.collect
import gc import resource import sys def rss_value(): # Linux 的 ru_maxrss 单位是 KB,部分平台是字节,跨平台比较先换算成字节 val = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss if sys.platform == "darwin": return val # macOS 下是字节 return val * 1024 # Linux 下是 KB print("collect 前 RSS:", rss_value()) n = gc.collect() print("本次收集对象数:", n) print("collect 后 RSS:", rss_value())这个实验我建议在业务平稳期做,不要在业务高峰做,否则污染数据。连续采样几次,配合时间线看趋势,才有参考价值。
5. 排障实操:用gc、objgraph、tracemalloc定位内存增长
理论部分讲完,进入实战。我排查内存问题的套路基本固定:先确认是不是GC能回收的垃圾,再统计对象增长,最后追分配源头和引用链。下面按顺序铺开。
5.1 第一步:排除Python缓存,建立基线
不要一上来就怀疑泄漏。先完成三个动作:记录基线RSS、手动gc.collect()、再次记录RSS。如果collect后RSS回落明显,说明一部分对象确实已经沦为垃圾只是回收滞后;如果collect后RSS纹丝不动,说明内存被某些“活的引用”占着。
在长驻服务里,我会在代码里临时加一个监控函数,每小时记录RSS和gc.get_count(),跑一整天再画出来看趋势。曲线能告诉你的信息比任何工具都多:是持续线性上涨(疑似引用链漏清理),还是锯齿状上涨(疑似GC滞后),还是阶梯式上涨(疑似缓存被批量填充)。
5.2 gc三板斧:get_objects、get_count、set_debug
gc.get_objects()返回当前GC追踪的所有对象。注意,它不包含整数、短字符串这类不需要追踪的对象,所以别拿它当“全量对象清单”。统计对象类型分布是常规操作:
from collections import Counter import gc gc.collect() counter = Counter(type(obj).__name__ for obj in gc.get_objects()) for name, count in counter.most_common(15): print(name, count)如果对比两个时间点的快照,某个类型数量疯涨,线索就出现了。比如Node从1000涨到100万,方向基本锁定。
gc.set_debug(gc.DEBUG_LEAK)可以开启详细的回收日志,输出哪些对象被回收、哪些无法回收。这是排查“为什么某个对象死不掉”的好帮手:
import gc gc.set_debug(gc.DEBUG_LEAK) # 跑一段触发回收的业务逻辑 gc.collect()日志里会打印gc: collectable <Node object at 0x...>之类信息。一旦看到大量疑似不该存在的对象出现在输出里,就去看看谁一直在引用它。
还有一个小众但好用的API:gc.freeze()。在multiprocessing用fork创建子进程后,子进程会继承父进程全部对象引用,如果服务跑了一段时间才fork,子进程的GC扫描负担会很重。在fork之后调用gc.freeze(),可以冻结当前对象,避免它们进入后续代的追踪范围,能显著加快子进程的GC过程。
5.3 objgraph:把对象引用链画出来
gc能告诉你“有哪些对象”,但回答不了“谁在引用它”。objgraph正好补这块短板,它可以用Graphviz生成引用关系图。
pip install objgraph在代码里简单使用:
import objgraph # 查看最近内存里增长最快的前10类对象 objgraph.show_growth(limit=10) # 对某个可疑对象画引用链 objgraph.show_refs([suspicious_obj], max_depth=5, filename="refs.png")show_refs生成的图里,你能直观看到目标对象被哪个根对象或中间对象“拿住”了。我通常这么用:先用show_growth找增长最异常的类型,再在业务代码的稳定期拿到一个该类型实例,最后show_refs定位引用路径。视觉化面对复杂引用网时,效率远高于人肉翻代码。
5.4 tracemalloc:定位对象分配的真实源头
objgraph解决“谁持有它”,tracemalloc解决“谁创建了它”。这是Python 3.4+内置的模块,能精确到“哪个文件哪一行代码分配了这个对象”。
import tracemalloc tracemalloc.start(25) # 保留更深调用栈 # 运行需要观察的业务逻辑,或者部署后运行一段时间 snapshot1 = tracemalloc.take_snapshot() # 继续运行 snapshot2 = tracemalloc.take_snapshot() for stat in snapshot2.compare_to(snapshot1, "lineno")[:15]: print(stat)输出的stat会显示文件路径、行号、内存增量和分配次数,例如/usr/local/app/service.py:128: size=18.2 MiB, count=34000。看到这个就很容易定位到具体模块。
注意:tracemalloc有一定性能开销,生产环境不建议全景长期开启。我通常只开一个短时间窗口,比如在服务刚启动未接入流量时tracemalloc.start(),运行半小时后做一次snapshot对比,足够抓到大多数热点。
6. 规避内存泄漏:三个值得记住的坑
很多“内存泄漏”其实不是Python的GC不工作,而是业务设计上让对象一直从根可达。下面这几个坑,我都在真实项目里踩过。
6.1 坑一:全局缓存持有“该淘汰却永不淘汰”的对象
假设有一个全局字典缓存,键是数据版本,值是加载后的大数据对象。数据对象内部又把自己放进了某个容器,形成循环引用。循环引用本身不是问题,问题是全局字典作为根,让这些对象永远可达,GC根本不认为它们是垃圾。
这种问题的根不在循环引用,而在缓存没有淘汰策略。改用弱引用缓存,或者给缓存加条数上限、时间过期,才是正向解法。否则无论怎么调GC阈值都无济于事。
6.2 坑二:闭包与回调隐式持有大对象
闭包会捕获外部变量,捕获的变量又可能牵住一整条引用链。比如某个定时任务模块注册了一个回调函数,回调内部引用了业务对象,业务对象又引用了超大历史数据。定时器一直注册着回调,就等于每条引用链都被根对象长期持有。
排查思路:event系统的回调列表里找“看起来没清理”的注册项;异步任务队列里检查是否有任务长期排队持有数据。对需要长期存在的订阅关系,尽量让回调只持有必要的小对象,大对象通过weakref.ref或ID在真正使用时再去取。
6.3 坑三:__del__里的意外与弱引用的正确姿势
__del__有两个麻烦:一是执行时机不可预测,尤其对象在循环引用中时,回收可能延后到GC周期;二是在__del__里抛异常,解释器会打印到stderr并吞掉异常,很容易掩盖资源未释放的问题。
终结逻辑的正确姿势是尽量try/finally包起来,把真正的资源清理放到上下文管理器而不是__del__。如果只是想“缓存一份对象但不想阻止它被回收”,弱引用是更安全的方案:
import weakref class HeavyData: pass cache = weakref.WeakValueDictionary() def get_data(key): data = cache.get(key) if data is None: data = HeavyData() cache[key] = data return data弱引用不增加对象的引用计数,所以对象不会再因为缓存而“想死都死不掉”。对象真的被回收后,对应的弱引用条目也会自动失效,下次访问时返回None并重新创建。这是大对象缓存最推荐的实现方式之一。
讲完这些,我自己的实际感受是:排查内存问题最忌讳“怀疑GC不干活”。多数时候GC都没问题,是从根可达的一条引用链把一个本该销毁的对象死死拽住。按“先跑gc.collect()看回落,再看对象类型增长,最后trace分配源头”的顺序走一遍,基本都能收敛到具体模块。把这些机制记清楚,内存问题就谈不上玄学了。