先说我踩过的一个坑:一个跑批服务,每天处理几十万条数据,跑两天内存就涨到让人心慌,重启后一切正常。当时组里第一反应是某个第三方库有内存泄漏,查了一圈才发现,真正原因是在循环里不断往一个全局列表塞临时结果,整个流程结束后没有清理引用。Python 的内存管理一直是个容易被误解的话题,很少有人能一句话说出它到底做了什么。这篇内容不会把源码贴满,而是按我排查问题的顺序,把对象引用、内存分配、引用计数、分代回收、泄漏定位这五件事讲清楚。适合写业务代码时想搞清楚内存规律的人,也适合遇到内存异常不知道从哪里入手的人。下文默认聊的是官方主流解释器实现,其他实现可能在策略上有区别,但核心思想是一致的。
1. 先解决一个底层误区:变量到底保存了什么?
如果只记一句话,我希望是这句:Python 里的变量不是存放对象的盒子,而是指向对象的标签。执行a = [1, 2, 3]时,解释器做两件事:先创建一个包含三个元素的列表对象;再把名字a绑定到该对象。名字本身不持有列表的内存,它只持有对象的引用。
1.1 变量名是贴着对象的一张贴纸
可以用一个小实验验证:a = [1, 2, 3]; b = a; print(b is a)。b is a返回True,说明b和a指向同一个对象。接着执行b.append(4),再打印a,会发现a也是[1, 2, 3, 4]。因为在这次操作里,b没有复制出一个新列表,只是在另一张贴纸上写了和a一样的门牌号;房间里的内容变了,从哪个门牌进去看到的东西都一样。
这份直觉对排查内存问题非常关键:很多“内存疯涨”并不是凭空创造了很多对象,而是同一个对象被多个地方持有了引用。你以为把a置为None就释放了列表,但如果b还在,这个列表就依然活着。引用计数会把这个对象当作“还有人需要它”,哪怕用户已经看不到它的入口了。
生活化类比:内存里的对象是一间间房间,变量名是贴在房间上的门牌便利贴。只要还有一张便利贴在墙上,物业管理处就不会收走这间房。你要释放它,不是撕掉自己手里那张便利贴,而是要确认所有便利贴都被撕掉。
1.2 函数参数、可变默认值与共享引用
函数传参同样复制引用。调用def consume(lst): lst.append(1)时,形参lst和外部实参共享同一个列表;函数内部修改会直接反映到外部。Python 新手最常踩的坑是可变默认参数:def append_to(item, cache=[]): cache.append(item); return cache。默认的cache列表只创建一次,之后所有调用共享同一个列表对象。这原本不是内存管理的锅,但会带来两个后果:对象一直被默认参数持有,无法回收;数据也互相污染。
不可变对象看起来不一样。x = 100; x += 1不会修改原来的整数对象,而是创建一个新整数101,再把x这个标签贴上去。原来的100如果没有其他引用,就等待回收。列表这类可变对象的+=则是在原对象上原地修改,标签保持不变。
这里还要补一个细节:小整数缓存。官方解释器通常会把-5到256之间的整数对象在启动时创建好,并常驻内存。所以写a = 1; b = 1; a is b很可能是True。这不是语言规范,而是常见实现里的优化,也是我一直不建议用is判断整数相等的原因。理解它比记住结论更重要:数值对象也有生命周期,语言层为了提速牺牲了一部分“每个变量一个独立对象”的直觉。
1.3 为什么这层理解会影响内存观测?
使用id()观察对象地址,看似直观,但要小心地址复用。对象被回收后,新建对象可能落到同一个地址,id相同并不代表是同一个对象。真正用于判断对象同一性的是is,配合__eq__理解相等与同一的区别,可以减少很多误判。后续讲泄漏定位时,判断对象是否依然“活着”,依靠的就是引用关系和gc模块,而不是id。
2. 分段分配器:Python并不是把每个对象都直接交给系统
变量与引用决定了一个对象什么时候“该被释放”,但真正从内存里回收并复用内存的,是一套比malloc/free更精细的分配器。官方解释器里,大部分小对象并不是直接向操作系统申请内存,而是由一个叫pymalloc的层级结构管理。很多人在调优时忽略这一层,结果看到 RSS 不降,就误以为泄漏。
2.1 从 arena 到 pool 再到 block 的三级结构
pymalloc的逻辑可以理解为一个大食堂:厨房先批量采购大块原料,切好统一规格的配菜,按份量分装;取菜的人不再需要每次单独去市场采购。具体来说,内存先按较大的单位划分成arena,每个arena再分成多个pool,每个pool针对某一种块大小,比如 8 字节、16 字节,直到 512 字节。Python 创建一个小对象时,分配器在对应尺寸的空闲块列表里取一个block;对象释放时,这个block被标记为空闲,后续同尺寸对象可以直接复用。
这套设计主要解决两个问题:一是减少系统调用,批量向操作系统要内存显然比每次分配都调用一次malloc更快;二是减少碎片,同尺寸的块放进同一种pool,分配和回收都不容易留下零碎的无法利用的小空间。对于小于等于 512 字节的对象,绝大多数都走这个路径;超过这个阈值的对象才直接交给系统级分配器。
为什么阈值是 512?这是工程权衡的结果。小对象的平均生命周期短、数量大,用专用池可以大幅提升吞吐;大对象数量少、存活时间长,直接交给系统更简单。你可以把pymalloc看作是 Python 自己的内存缓存,它缓存的是“可用块”,而不是业务数据。
2.2 del 之后,内存为什么没有立刻回落?
理解了分配器,就会发现一个反直觉的现象:del x、obj = None之后,进程占用的 RSS 常常没有明显下降。第一层原因是引用计数归零,对象内存确实可用了;第二层原因则是这些内存还留在pymalloc的空闲块池里,随时准备给下一个同尺寸对象使用。等于公共食堂已经把配菜切好了,即使这份菜没被取走,厨房也不会把配菜退回菜市场。
这意味着你看到的 RSS 高,可能只是因为业务高峰期间分配了大量对象,高峰过后 Python 选择了把这些空闲块留着复用。这种“高水位”不是泄漏,不必处理;但如果高水位长期存在,并且内存压力导致容器的可用内存告警,就需要考虑在低峰期让空闲池释放一部分。让对象尽快归零释放,并不能保证操作系统立刻拿到内存。
大对象略有不同:超过 512 字节直接走系统分配器,free之后通常能更快地归还系统。但这里还有一个隐藏的不确定性:具体操作系统内存分配器也有自己的缓存策略,free的内存不一定马上回到进程的 RSS 统计里。所以判断内存是否“泄漏”,不能只看一个时间点的 RSS,要看分配趋势和可回收对象规模。
2.3 用 sys.getsizeof 验证真实占用
想直观感受对象占多少内存,最方便的工具是sys.getsizeof。但有两个必须注意的坑:它返回的是对象自身的大小,不包含内部元素单独占用的内存;而且不同解释器版本、不同平台上结果会有差异。
代码如下,具体输出以你当前解释器为准:
import sys print(sys.getsizeof(())) # 空元组 print(sys.getsizeof([])) # 空列表 print(sys.getsizeof({})) # 空字典 print(sys.getsizeof(int())) # 整数 0空元组、空列表、空字典的头部开销不同;元素越多,列表内部还要为指针扩容。比如列表在追加元素时并不是每次加一个指针的空间,而是按比例预分配,所以getsizeof的结果会呈现阶梯式跳跃。理解这个现象,有助于解释大量小列表累积时的内存占用为什么很吓人。不要被数字本身吓到,它只是让你对“对象外壳”和“元素引用”有直观认识。
3. 引用计数及其解决不了的循环引用
对象引用和内存分配讲完之后,真正决定对象能否被回收的是引用计数。每个对象头里保存着一个计数器,记录当前有多少个地方持有它的引用。计数为零,对象就被释放;计数不为零,对象就继续存活。这个设计简单高效,但也有天生缺陷。
3.1 引用计数并不是只在赋值时变化
许多人对引用计数最大的误解,是以为只有显式x = obj才会加一。实际上,函数传参、返回值、把对象放进列表、字典、集合,甚至迭代期间的临时引用,都会让引用计数变化。反过来,del x、函数返回、从容器里删除元素、变量重新赋值,都会让计数减一。
可以用一个标准库函数来偷看引用计数:
import sys x = [] print(sys.getrefcount(x))输出通常会比你预期的多 1。原因就是调用sys.getrefcount时,参数本身在传递过程中也多了一个临时引用。这个细节不影响正常使用,但调试时容易让人困惑。
引用计数还有一个被人忽略的成本:每一次增减,都可能涉及内存读写和条件判断。官方解释器在对对象进行赋值、传参、容器操作时都要维护这个计数器,这也是 Python 程序天然比 C 程序要慢的一部分原因。在并发场景下,为了让引用计数增减不冲突,解释器利用全局锁保证同一时刻只有一个线程修改计数器,所以从 Python 线程视角看,普通对象的引用计数操作是安全的。
3.2 两个对象互相收藏,谁来把他们放走?
引用计数的短板是循环引用。两个对象互相持有对方的引用,形成一个环;从外部看已经没有变量指向它们了,但它们的引用计数因为互相引用而始终是 1,永远不会降到 0。最典型的例子是双向链表节点、父子互相引用的对象:
class Node: def __init__(self): self.prev = None self.next = None a = Node() b = Node() a.next = b b.prev = a del a del b执行完del a和del b后,a和b其实都已经不可达了,但a.next还在引用b,b.prev还在引用a,二者的引用计数都没有归零。如果没有额外的垃圾回收机制,这两个对象会一直存活到进程结束。平时写算法题里的链表实现不会触发这种问题,但业务代码里大量使用事件回调、缓存、树状结构时,很容易在无意识中形成环。
有环的对象并不只在“自建节点”中出现。两个大字典互相引用、闭包捕获了外部变量、业务对象互为上下级,都可能让对象莫名无法释放。引用计数负责日常的大多数回收,环的问题就要交给下一层机制处理。
3.3 分代回收是给引用计数打的补丁
为了补上循环引用这个漏洞,解释器加入了一个“循环垃圾收集器”。它不是每时每刻检查所有对象,而是只关注那些可能构成环的容器对象,并通过标记-清除算法找出不可达的环,把它们一起释放。这里的关键点很容易被忽略:普通整数和简单字符串不被列入监控,因为一个数字不可能引用一个列表再引用另一个字典;只有容器对象才可能参与循环。
这也是为什么把gc.disable()当作性能优化会非常危险。禁用分代回收后,引用计数依然会把无环对象释放掉,但所有循环引用都会堆积。短期看起来内存没问题,运行几天后你会看到类型数量暴涨。回收器不是性能负担,而是引用计数体系里不可缺少的安全网。
4. 分代回收与 gc 调参:为什么越老的对象越少被检查
循环收集器如果要扫描所有存活对象,代价太高。因为大多数对象是短命的,一个刚刚创建的临时字符串、中间结果,可能下一秒就没人再用;反而是已经活过很长时间的对象,比如配置、缓存、路由表,往往还会继续活下去。分代回收正是基于这个经验法则。
4.1 大多数对象都是短命的,所以按年龄分桶
gc 把被跟踪对象分成三代:第 0 代放最新创建的对象,第 1 代是活过一轮收集的对象,第 2 代是更老的对象。默认阈值是(700, 10, 10),意思是:新建的被跟踪对象累计分配计数超过 700 时,触发一次第 0 代收集;每次第 0 代收集后,第 1 代的收集计数加一;这个计数达到 10 时,触发第 1 代收集;第 1 代收集的次数达到 10 后,触发第 2 代收集。收集时只清理当前代及更年轻的代里不可达的对象。
用代码能直接看到当前阈值和计数:
import gc print(gc.get_threshold()) print(gc.get_count())get_count()返回的是当前三代计数器,是判断触发频率最直接的数据。如果第 0 代的计数长期接近阈值,说明该业务在频繁创建被跟踪的容器对象。不要过度解读数字本身,看它和阈值的关系才有意义。
4.2 标记-清除怎么找到不可达的环
分代收集的核心不是“引用计数减到 0”,而是从根对象出发,判断哪些对象可达。根对象包括当前运行栈、全局变量、模块引用等一切外部入口。收集器先标记所有从根可达的对象,标记完成后,仍然不可达的对象就是垃圾;即使它们之间互相引用,也会被统一回收。这个思路很像清扫一团互相缠绕的线:先从线团的每一根外部线头出发,能摸到的地方都贴上“有用”标签,没贴标签的整团扔掉。
具体到 Python 的 gc 模块,它不会扫描所有普通对象,而是扫描被跟踪的容器对象。自定义类实例默认会带上__dict__,也算容器,所以也会被跟踪。因此类实例互相引用形成的环,也会被这套机制发现并回收。
这里有一个进阶但实用的知识点:如果对象定义了__del__析构方法,并且它处在循环引用中,gc 需要先执行析构方法。析构方法里可能访问对象字段,而字段可能已经被回收了一半,所以 gc 会把这类对象放进一个待处理列表,暂不彻底回收。结果就是你看得到对象“好像泄漏了”,其实是被析构逻辑拖住了。我的建议是:不要让包含__del__的对象轻易进入循环引用,尽量用try/finally或上下文管理器管理生命周期。
4.3 调参前先观察 gc.get_count
很多优化文章鼓励调高阈值来减少 GC 触发频率,理由是 GC 会停顿。这个判断需要分场景。如果业务是大量创建临时容器,第 0 代阈值从 700 调到 7000,确实能让 GC 触发次数变少,但代价是临时对象堆积,内存峰值升高,单次收集要处理的对象也更多,停顿反而可能变长。
正确做法是先观察gc.get_count()在业务跑动时的规律,再看 RSS 曲线。如果第 0 代频繁达到阈值,说明临时容器对象多;如果第 1、2 代计数持续增长,说明有长期存活的对象链在累积。然后小步调整:比如把第 0 代阈值从 700 降到 400,看同样的流量下内存峰值是否降低、GC 总耗时是否上升。没有统一标准答案,只有压测结果。除非你有充分理由,否则不要动阈值;更不要在生产环境全局gc.disable()。
5. 怀疑内存泄漏时,按这套链路排查
前面原理再熟,遇到真实问题还是会慌。我自己的排查套路是固定的:先确认可回收,再定位分配点,最后看引用链。下面这套链路可以照抄。
5.1 先分辨“延迟释放”和“真正泄漏”
第一步永远是手动触发一次完整回收:
import gc collected = gc.collect() print(collected)如果gc.collect()返回大量对象,并且之后内存监控显示 RSS 有明显下降,说明之前有可回收对象堆积,属于“延迟释放”而不是真正泄漏。如果回收前后 RSS 没变化,再去查是不是引用被某处长期持有。这一步经常能排除一半问题。RSS 可以用系统监控工具观察,Linux 下也可以临时读/proc/self/status里的 VmRSS 字段。
但要注意:RSS 不下降不代表内存没被释放。分配器可能还保留着空闲 pool,只有后续对象大量复用才会让内存平滑。所以在大型服务中,我更倾向于对同一流量切片多次观测gc.get_count()和 RSS 趋势,而不是在某个时间点一锤定音。
5.2 用 tracemalloc 锁定分配热点
排除了回收问题后,真正的引用泄漏需要找到对象是谁创建的。tracemalloc 能记录对象的分配调用栈,并统计每个文件行号对应的累计分配量。典型用法如下:
import tracemalloc tracemalloc.start() # 运行你要观测的业务逻辑 snapshot = tracemalloc.take_snapshot() top_stats = snapshot.statistics("lineno") for stat in top_stats[:10]: print(stat)统计结果会告诉你:哪一行代码累计分配了最多内存、当前仍存活的对象是从哪里创建的。配合compare_to(old_snapshot),还能看到两次快照之间的增量。谁在持续增长,谁就是问题函数。
tracemalloc 有性能开销,内存和 CPU 都会增加。线上可以用开关控制,只在对齐流量、压测或排查时打开。很多团队会在预发环境跑 10 分钟拿快照,已经足够找到热点;一直开着反而可能把服务拖垮。
5.3 用 gc.get_objects 分析对象类型分布
分配热点告诉我们对象在哪里创建,类型分布则告诉我们谁还活着。gc.get_objects()返回当前所有被 gc 跟踪的对象,可以按类型统计:
from collections import Counter import gc counter = Counter(type(obj).__name__ for obj in gc.get_objects()) for name, count in counter.most_common(10): print(name, count)如果某种业务对象数量异常大,就非常可疑。比如平均每次请求只有 100 个订单对象,但进程里累积了几十万个同类型对象,说明每次请求结束后,订单对象仍被某处引用。接下来要找出持有引用的是谁,可以遍历对象查看属性,也可以借助能画引用图的第三方工具,把对象之间的引用关系可视化。标准库的gc.get_referrers和gc.get_referents也能手动沿引用链找:
objs = [o for o in gc.get_objects() if type(o).__name__ == "Order"] if objs: sample = objs[0] print(gc.get_referrers(sample)[:10])这一步不需要一次找全,先找出第一个持有该对象的引用者,顺着引用链往上走,最终会到达某个模块级变量、类属性或者闭包变量。
5.4 一次模拟排查:从持续增长到全局引用
结合一个虚构场景:某服务内存两天增长 3 倍。我按下述顺序操作:先开 tracemalloc,半小时后快照显示某模块build_report函数里的一行results.append(summary)累计分配最多;再统计类型,发现该模块定义的Summary对象数量远超历史任务数;随后gc.get_referrers指向了模块顶层的全局列表,列表里的数据来自一个主循环,而主循环结束时只清掉了局部变量,全局列表一直没人清理。
修复很简单:每批任务结束后执行results.clear(),或者把全局列表改成有界队列。问题虽然简单,但如果不按分配热点和类型分布两条线索一起查,而是反复读代码猜,往往会浪费很长时间。这类问题在真实线上很常见:代码明明已经不再使用某些对象,但引用仍然被全局缓存、事件监听器、类属性扣着。泄漏不一定是 GC 的错,更多时候是业务生命周期设计问题。
6. 日常代码里性价比最高的几个内存习惯
排除过足够多的内存问题之后,我的结论是:绝大多数跟内存相关的线上故障,并非“Python 不会管理内存”,而是业务代码在无意中延长了对象的生命周期。把下面几个习惯养成,能省掉很多半夜救火的痛苦。
6.1 无界缓存是最大的“假泄漏”来源
最常见的长期持有引用,是各种全局字典、类属性缓存、结果列表。它们本身不是泄漏,但如果只添加不清理,就会无限增长。业务量小时没人注意,运行一两天后内存开始报警。我的习惯是:所有缓存必须设定上限;有“热点数据、过期可重建”特征的场景,优先用带过期策略的缓存,而不是裸字典。弱引用也能帮上忙,比如缓存键到对象的弱引用会让对象在无强引用时自动消失;但弱引用字典不适合缓存不可变小对象,因为可能刚放进去就被回收了。它更适合“缓存只是为了复用,不用时就不该强留”的场景。
6.2 别急着把数据全部塞进列表
第二个习惯是延迟构造。读取大文件时,不要先lines = f.readlines()再造列表;直接用for line in f逐行处理。需要一次遍历的结果,用生成器表达式代替列表推导式;需要随机访问的少量数据才保留成列表。很多内存优化并不是省出了单个对象的开销,而是避免了成千上万个对象同时活着。
有一类隐藏的“攒内存”行为值得注意:在循环里反复用result_list.append(...),最后某个时刻才一次性处理完整个列表。如果处理速度和生产速度不匹配,中间态对象就会大量堆积。能改成流式处理就改,不能流式处理就分批提交。内存管理的本质是生命周期管理,不该活着的对象,越早结束越好。
6.3 用slots和内存友好类型压一压单对象
如果确实存在大量同构对象,每个实例默认的__dict__开销就很可观。给类加__slots__可以声明固定属性集合,省掉实例字典和底层哈希表内存。对百万级对象来说,节省效果非常明显。代价是不能再随意给实例添加新属性,且有些依赖动态属性的框架会不兼容,所以只建议在稳定热路径上用。
数值密集型数据可以用数组模块或内存视图类类型,而不是把每个数字包成 Python 对象列表。还记得sys.getsizeof的结论吗?Python 的整数是一个对象,它本身就有头部开销;如果数据量很大,用紧凑二进制类型能减少大量内存。需要逐元素计算的场景,可以考虑把计算搬到对内存更友好的数据结构或 C 扩展上,而不是在 Python 层用一个大列表硬扛。
最后再分享一个小技巧:我在线上服务里通常会留一个隐藏的手工排查入口,类似压力阀。它平时关闭,需要时调用gc.collect()并抓一份 tracemalloc 快照输出到日志。很多所谓“内存泄漏”在打开这个开关半小时内就能定位;定位后记得把开关关掉,避免持续的性能损耗。Python 的内存管理并不神秘,理解对象引用、分配器、引用计数和分代回收这四层,排查问题的速度会完全不一样。