
1. 项目概述1.1 为什么Python开发者必须搞懂内存管理很多Python开发者写了好几年代码却几乎从没主动考虑过内存是怎么被回收的。这其实很正常因为Python的自动内存管理做得太隐蔽了——你只管a [1, 2, 3]用完了也不用管释放好像一切都理所当然。但一旦你开始写长驻服务、处理大数据集、做爬虫批量采集、跑量化回测内存问题就会像幽灵一样找上门进程内存飙到几个G、程序越跑越慢、莫名其妙被系统杀掉、甚至在无界面服务器上直接OOM。到这时再回头看内存管理机制往往已经付出过惨痛代价了。这篇文章的核心就是讲清楚Python的垃圾回收和引用计数到底是怎么工作的。具体来说会覆盖三个层面第一引用计数如何决定一个对象的生死第二循环引用和分代回收机制的底层原理第三如何用gc模块、sys.getrefcount、tracemalloc这些工具真正定位和解决内存泄漏问题。这篇文章适合的人群很明确写过一定量Python代码、想深入理解解释器行为的中级开发者以及在服务端开发或数据处理场景中碰到过内存异常的实践者。今天的内容全部基于CPython官方Python解释器的实现来讲解因为市面上绝大多数的Python环境都是它这也是你排查内存问题时的实际战场。老规矩先给个总览Python内存管理 引用计数即时回收的主力 垃圾回收器处理循环引用的辅助 内存池机制针对小对象的优化。这三者的关系很多人一上来就搞混。这里先打个比方引用计数像保洁员每个对象都有个计数器归零就立刻清理分代垃圾回收像大扫除定期把那些互相引用、没人要的对象找出来收掉内存池则像工地用的标准件仓库小尺寸对象不频繁向操作系统申请而是复用固定大小的内存块。下面挨个拆开讲。2. 核心思路拆解引用计数与垃圾回收的设计逻辑2.1 引用计数Python的默认内存管理策略要理解Python的内存管理先要记住一个底层事实CPython中几乎每个对象都有一个PyObject头其中包含两个核心字段——引用计数ob_refcnt和类型指针ob_type。这两个字段是所有Python对象共有的“身份证”。每当对象被引用时ob_refcnt加1引用被删除时ob_refcnt减1。当计数归零解释器立即调用该类型的析构函数释放内存。引用计数策略的优点是直观、实时、无延迟。你写del x或者x None只要没有其他引用内存立刻归还。但它的缺点也很明显第一每次赋值、传参、容器操作都需要更新计数带来额外开销第二它无法处理循环引用。什么叫循环引用最简单的情况a [] b [] a.append(b) b.append(a) del a del b执行完del a和del b之后这两个列表对象依然存在因为a列表被b列表引用着b列表又被a列表引用着两者引用计数都是1永远不会归零。这就是引用计数的盲区也是垃圾回收器存在的根本原因。关于引用计数还需要补充一个重要细节并不是所有对象的引用计数都在同一个频率上更新。比如整数、短字符串这类不可变对象CPython做了缓存和小整数池优化1这个整数对象实际上是全局复用的你不会看到1的引用计数归零后被销毁因为它常驻在解释器里。理解这一点对排查一些“为什么内存不减”的困惑非常有帮助。2.2 分代回收为什么需要“代”的概念既然循环引用靠引用计数解决不了Python就引入了垃圾回收器gc模块用标记清除法来找出“不可达但还活着”的对象。但是如果每次回收都扫描全部对象性能完全不可接受——绝大多数对象要么生命周期极短要么长期存活没必要每次都全量扫描。于是CPython采用了分代回收策略把对象按存活时间分为三代编号0、1、2。新创建的对象进入第0代每经历一次垃圾回收仍然存活的对象会晋升到下一代。这个设计和JVM的分代收集思路很像核心假设都是大部分对象朝生夕灭只有少数对象活得久。这个假设在绝大多数Python应用中都是成立的——你每轮循环创建的临时列表、字符串切片、函数调用栈上的对象基本活不过下一次回收。分代回收的执行触发条件是“分配次数减去释放次数”超过阈值。这个阈值可以查看和修改import gc print(gc.get_threshold()) # 输出类似 (700, 10, 10) print(gc.get_count()) # 查看当前各代计数默认阈值(700, 10, 10)的含义是第0代累计分配次数比释放次数多700次时触发第0代回收第1代累计回收满10次后触发第1代回收第2代累计回收满10次后触发第2代回收。这个“差值”机制很关键它统计的不是总分配数而是“净新生对象数”因为总量本身没有意义只有新增“未回收”对象才说明可能有垃圾需要清理。手动调用gc.collect()可以强制回收所有代返回的是回收掉的垃圾对象个数这个返回值在验证逻辑时很常用。这里插入一个我的实操建议大多数应用完全不用动这个阈值默认配置在性能和内存占用上已经做了很好的平衡。但如果你在跑长驻服务且内存敏感可以做得更激进一点比如把阈值调低让回收更频繁代价是CPU占用上升。改阈值不是银弹后面会讲更靠谱的定位方法。2.3 标记清除法的工作流程分代回收框架之下真正干活的是标记清除Mark and Sweep算法。它的逻辑分成两步第一步标记阶段从根对象出发沿着引用关系遍历所有可达对象给它们打上“存活”标记。根对象包括当前栈帧里的局部变量、全局变量、模块引用等所有从解释器能直接触达的对象。简单理解就是“顺着所有还活着的名字能摸到的对象都算活着”。第二步清除阶段遍历所有被跟踪tracked的对象把没有被标记为存活的对象回收掉。这里有个关键点“被跟踪”的对象才参与垃圾回收。像整数、小字符串、简单的不可变对象CPython默认是不可被跟踪的因为它们不可能产生循环引用。而列表、字典、类实例、自定义对象等可变容器才会被gc模块跟踪。关于标记清除有一个很实用的细节它不只是回收垃圾还会处理弱引用和终结器。弱引用的对象若被回收weakref回调会触发定义了__del__方法的对象在回收时会被调用。这在实战中经常埋坑——如果两个循环引用的对象都有__del__方法CPython会因为无法确定终结器调用顺序而把它们放不进某些回收流程导致该代垃圾无法清理。我在处理一个分布式任务队列时踩过这个坑后面专门展开。3. 内存管理实操要点引用计数工具与陷阱3.1 用sys.getrefcount查看引用计数想真正感知引用计数的工作方式最直接的方法是使用sys.getrefcountimport sys x [] print(sys.getrefcount(x)) # 输出 2 y x print(sys.getrefcount(x)) # 输出 3 del y print(sys.getrefcount(x)) # 输出 2有没有注意到刚创建x []后引用计数显示2而不是1原因是sys.getrefcount(x)在把x作为参数传入时临时产生了对x的一次额外引用。也就是说返回的数字总是比“实际常见引用数”多1。这个细节第一次遇到时会让人困惑但知道后就不算问题了。引用计数的更新时机非常频繁。每次a b赋值、每次把对象传给函数、每次放入列表或字典都会触发生命周期的引用计数变化。底层实现中宏Py_INCREF和Py_DECREF包揽了绝大部分工作。Py_DECREF中还包含一个优化当引用计数减到0时并非每次都走全量free而是根据对象类型调用对应析构函数并把内存块返还给对应的内存池。3.2 容器与循环引用的典型陷阱循环引用最常见的产生场景是容器类型。下面几个模式在真实项目中非常常见# 模式一对象互相持有 class Node: def __init__(self): self.parent None self.children [] a Node() b Node() a.children.append(b) b.parent a # 模式二对象自引用 data [] data.append(data) # 模式三回调闭包产生的间接环 # 类A的实例方法被B引用B又被A持有形成间接环这些循环引用的可怕之处在于它们不会立即触发回收而是静静躺在内存里等到分代回收到对应代时才会被清理。如果循环引用对象非常多、非常大比如缓存了海量图片数据的列表互相引用那内存峰值会非常吓人。解决循环引用有几个常规思路。第一个是主动置空用完的容器及时clear()或置None解除引用关系。第二个是使用弱引用import weakref class Node: def __init__(self): self.parent None self.children [] a Node() b Node() a.children.append(b) b.parent weakref.ref(a) # 不增加引用计数 # 访问父节点时需要解引用可能返回None parent b.parent() if parent is not None: print(parent)弱引用的本质是不增加目标对象的引用计数。如果目标对象已经被回收弱引用返回None不会产生悬垂指针。在缓存场景如weakref.WeakValueDictionary、WeakKeyDictionary中使用弱引用尤其常见既能防止内存泄漏又能自动丢弃无用数据。这里必须补充一个容易忽略的坑弱引用不可用于所有对象。列表、字典、整数等内置类型不支持弱引用除非它们的类显式声明了__weakref__属性。自定义类默认支持元组、字符串等则不支持。如果拿weakref.ref([])会直接抛TypeError: cannot create weak reference to list object。3.3 小对象内存池与底层分配CPython还有一层被很多人忽略的优化内存池机制。CPython内部定义了一个阈值通常以512字节为界。小于等于512字节的对象不会每次都向操作系统申请内存而是从预先申请好的内存块中复用超过512字节的对象则走malloc/free走系统分配。这个机制最直接的影响是你看到进程占用内存很高不代表那些内存真的“泄漏”了可能只是被Python的内存池缓存着等待后续复用。举个例子如果你循环创建大量小型临时对象比如字符串、小列表进程RSS会整体上升但一旦循环结束内存池中的空闲块并不会立即返还给操作系统而是保留下来。这在任务型脚本中无所谓但长驻服务中就容易造成“内存水位持续抬高”的错觉。结合gc.collect()后内存还不降的现象很多人会误判为泄漏其实只是内存池没归还而已。一个日常可用的检查思路任务前后用resource.getrusage看ru_maxrss如果这个值一直涨说明真实占用在增加如果只涨到某个平台期后不再上涨那多半是内存池复用。这个方法在无GUI的服务器上验证内存问题非常好使。4. 垃圾回收机制详解分代、标记与调试4.1 gc模块的核心API与对象追踪gc模块是Python暴露给开发者的垃圾回收控制面板。除了前面提到的get_threshold和get_count还有几个API值得熟练掌握import gc # 查看当前被跟踪的对象列表谨慎使用可能非常长 gc.get_objects() # 判断对象是否被跟踪 gc.is_tracked(some_list) # 禁用/启用自动垃圾回收 gc.disable() gc.enable() # 手动执行回收 collected gc.collect(0) # 只回收第0代 collected gc.collect() # 回收全代gc.is_tracked特别适合做验证。比如你创建一个类实例后看is_tracked为True而一个元组(1, 2)可能是False因为它没有循环引用风险。这能帮你理解哪些对象真正参与了垃圾回收流程。需要特别注意gc.disable()不等于禁用引用计数。禁用只影响循环垃圾的自动回收不作用于引用计数。很多文章把两者混为一谈这是理解上的重大误区。实际上就算你执行了gc.disable()del x之后对象仍然会因引用计数归零而立刻释放。只有循环引用垃圾才会累积。这个区别在追求极致性能时要格外清楚。4.2 分代回收的晋升机制与阈值调优分代回收的晋升规则是在一次垃圾回收循环中存活下来的对象代龄加1。第0代对象在经历了一次第0代回收后还活着就晋升到第1代第1代对象经历一次第1代回收后存活就晋升到第2代。第2代是最高代除非显式gc.collect(2)否则不会进一步“晋升”。这种设计缩短了垃圾回收的平均扫描时间。因为绝大多数第0代对象在第一次回收时就死掉了只有极少数能进入第1代。第0代的回收频率最高但扫描对象最少第2代回收频率最低但扫描对象可能最多。三个代像三级漏斗完成了“频繁处理短命对象偶尔清理老顽固”的平衡。调优时最常见的两个方向第一提高第0代阈值。如果你的程序大量创建短暂对象比如循环处理百万条日志第0代回收触发会过于频繁导致CPU都花在垃圾回收上。这时候把阈值从700调大到5000甚至10000可以减少回收次数。代价是短命对象堆积更多内存峰值升高。第二隔离长期服务中的周期性峰值。比如你在每天固定时间批量加载大文件那批对象如果大量存活就会在第0代或第1代逗留很久造成内存短时间飙升。这类场景更合理的做法是做了大操作后手动gc.collect(0)一次及时清理而不是盲目改阈值。说实话阈值调优在绝大多数业务代码里收益有限。真正的收益往往来自搞清楚“哪些对象不该被创建”以及“哪些引用不该被长期持有”这比调回收频率高效得多。4.3 调试垃圾回收的实用技巧调试阶段最有用的是gc模块的调试标志和gc.DEBUG输出。开启后回收垃圾时会打印具体对象信息import gc gc.set_debug(gc.DEBUG_LEAK | gc.DEBUG_STATS | gc.DEBUG_OBJECTS) # 制造循环引用 a [] a.append(a) del a gc.collect() # 此时控制台会输出类似 # gc: collecting generation 0... # gc: objects in each generation: 8 0 0 # gc: done, 1 unreachable, 0 uncollectable, 0 uncollectable # gc: collecting generation 1...DEBUG_STATS会打印每次回收的统计信息各代对象数、回收了多少、耗时多少。这些信息能直观告诉你垃圾回收频率是否异常。DEBUG_SAVEALL则是一个神奇的选项开启后垃圾对象不会被真正销毁而是保存到gc.garbage列表里。这对分析循环引用结构特别有用——你可以拿到对象查看它的__dict__、__class__搞清楚引用链是怎么绕起来的。不过注意DEBUG_SAVEALL本身会阻止内存释放只适合调试环境别在生产环境开启。另外一个独门技巧如果怀疑某处代码产生了循环引用可以在可疑代码前后分别执行import gc gc.collect() before len(gc.get_objects()) # 可疑代码 # ... gc.collect() after len(gc.get_objects()) print(f对象净增: {after - before})如果after - before持续大于零说明代码中有对象没被正确回收。配合tracemalloc还能进一步定位是哪个文件哪一行创建的。这个方法我用了很多年是排查内存泄漏最快的起手式强烈推荐。5. 内存泄漏实战排查从现象到根因的完整链路5.1 内存泄漏的四个常见来源排查内存泄漏时首先要相信Python程序的内存泄漏不是“泄漏”在C语言意义上的越界而是持有不想要的引用导致对象无法被回收。只要还有引用链指向对象它就不会消失。最常见的来源有四个第一个是全局缓存无上限增长。比如用模块级字典做缓存只写入不淘汰时间长了必然涨。解决办法是加上容量限制或者直接用functools.lru_cache、cachetools这类带过期的工具。第二个是闭包长期持有大对象。回调函数、生成器、装饰器都有可能在不知不觉中形成闭包把外层大对象保存在__closure__里。比如你在循环里定义嵌套函数函数内部引用了一个大数据集这个数据集就被闭包逮住了哪怕数据集本身早已“逻辑上没用了”。第三个是类级别的可变默认参数。这是Python新手经典错误def append_item(item, cache[]): cache.append(item) return cache默认参数cache[]在定义函数时创建一次之后所有调用共享同一个列表。如果函数被长期持有这个列表也不会被释放。正确写法是使用None作为默认值在函数体内创建新列表。第四个是日志与观测库的隐藏引用。某些日志、指标库在内部维护采样缓冲、最近事件列表长期运行后会积累大量数据。这类问题排查起来最隐蔽因为你的业务代码并没有“明显的”缓存但进程的PSS不断增长。5.2 tracemalloc定位内存分配源头tracemalloc是Python自带的内存剖析模块接口非常友好。用法简单到让人感动import tracemalloc tracemalloc.start() # 运行你的业务代码 # ... current, peak tracemalloc.get_traced_memory() print(f当前内存: {current / 1024 / 1024:.2f} MB) print(f峰值内存: {peak / 1024 / 1024:.2f} MB) snapshot tracemalloc.take_snapshot() top_stats snapshot.statistics(lineno) for stat in top_stats[:10]: print(stat)输出会显示每个文件每行代码对应的内存分配大小和数量直接告诉你内存都是从哪里“生”出来的。这是排查内存增长最权威的工具。我处理过一个案例一位同事写的Web服务内存每24小时涨2个G用tracemalloc跑了一晚上采样结果定位到是某个缓存装饰器把Response对象的所有数据都放进了全局队列队列没有消费者也没有上限。修复一行代码就解决了问题。tracemalloc也有开销生产环境不建议常开但短时间采样比如5分钟到几小时完全够用。另一个可选工具是memory_profiler它按行显示内存占用对于单函数定位非常直观但安装需要额外依赖且性能开销比tracemalloc大不少适合本地调试而非线上采样。5.3 用objgraph可视化引用关系objgraph不是标准库但在可视化对象引用链方面非常强大。它基于gc.get_objects()和gc.get_referrers()生成Graphviz图能打印出某个对象被谁引用、又引用了谁import objgraph x [] y [] x.append(y) y.append(x) objgraph.show_backrefs(x, filenamebackrefs.png, max_depth5)show_backrefs会生成一张图片上面画出x被哪些对象引用以及引用的中间链路。在排查循环引用和意外持有时这张图比任何日志都直观。不过要注意objgraph依赖系统安装Graphviz如果服务器没有图形化环境可以用objgraph.print_chain在命令行打印引用链效果类似但不需要生成图片。实际经验objgraph在两种场景下最有用。第一种是检查某个对象为什么回收不了show_backrefs一眼看到是哪个容器还拽着它。第二种是统计某类型对象的总数比如怀疑某个类实例无限增长objgraph.count(MyClass)直接看数量趋势。其他情况直接用tracemalloc定位源头效率更高。5.4 常见问题速查一表通把日常工作中最常碰到的问题汇总成一个速查表方便收藏对照现象可能原因排查工具解决方向进程RSS缓慢持续上升全局缓存无上限、闭包持有大对象tracemalloc、gc.get_objects()加缓存淘汰机制、解除闭包引用内存冲到很高但gc.collect()后不下降非循环引用的大对象仍被引用、内存池缓存resource.getrusage检查引用链、确认是否能复用内存池循环引用导致的内存增长容器互相引用垃圾回收未及时触发gc.DEBUG_STATS、objgraph使用weakref、手动gc.collect()、调整阈值有__del__方法的对象无法回收终结器顺序不明确导致uncollectablegc.DEBUG_UNCOLLECTABLE避免在循环引用中使用__del__、改用weakref回调性能突然卡顿垃圾回收频率过高扫描耗时gc.DEBUG_STATS查看耗时提高第0代阈值、错开大对象创建时段内存池块堆积导致RSS偏高小对象大量创建/销毁后的缓存resource.getrusage对比属于正常现象关注ru_maxrss趋势而非瞬时值这张表不能覆盖所有情况但覆盖了80%的常规问题。真遇到表里没有的情况老老实实用tracemalloc加objgraph组合定位基本没有解决不了的内存问题。5.5 垃圾回收的性能副作用与规避策略垃圾回收不是免费的。每次gc.collect都需要遍历被跟踪对象这个耗时随对象数量增长。当你用gc.DEBUG_STATS打开统计后可能会看到某次第2代回收耗时几十毫秒甚至更多。在低延迟服务里这种“世界暂停”式的停顿不可忽视。针对这个副作用有几条实战经验第一减少被跟踪对象的数量。当你明确知道某些对象不会被循环引用时可以通过gc.freeze()把启动阶段创建的大量对象排除在回收范围之外。gc.freeze()在Python 3.7引入在导入大量依赖后就冻结一次能显著减少每次回收的扫描面。这在Web框架和数据处理程序中很有效。第二避免在循环引用场景中使用__del__。前面提到过有__del__的对象在循环引用中会成为gc.garbage里的“不可回收”对象既浪费内存又可能让__del__永远不会执行。可以用weakref.finalize替代回调能可靠执行且不干扰回收import weakref class Resource: def close(self): print(资源释放) def cleanup(resource): resource.close() r Resource() weakref.finalize(r, cleanup, r)第三大数组、大DataFrame等敏感操作后主动回收不如主动解除引用。del df后再gc.collect()比依赖自动回收能更快释放内存特别适合批量处理流水线。不过del之后对象是否真的被释放依然取决于有没有其他引用存在所以前面用的引用计数知识在这时候就派上用场了。还有个小技巧sys.getallocatedblocks()可以查看当前分配的内存块总数配合gc.get_objects()数量变化能快速判断程序是在创建新对象还是在复用旧对象这在性能优化时是我必看的两个指标。6. 独立实验手工验证回收机制的五个小实验光看原理不落地始终差点意思我建议你有空跑一遍下面几个小实验半小时内就能对内存管理建立非常直观的体感。每个实验的代码都很短但结论都值得反复品味。第一个实验验证引用计数与delimport sys class A: pass a A() print(sys.getrefcount(a)) # 2 b a print(sys.getrefcount(a)) # 3 del b print(sys.getrefcount(a)) # 2 del a注意这里如果你再做sys.getrefcount(a)会直接抛NameError因为对象已经没了。第二个实验验证循环引用无法靠引用计数回收class Node: pass import gc n1 Node() n2 Node() n1.next n2 n2.prev n1 print(gc.is_tracked(n1)) # True del n1, n2 print(gc.collect()) # 输出可能大于0说明回收了循环垃圾第三个实验验证__del__对回收的影响import gc class WithDel: def __del__(self): print(销毁) a WithDel() b WithDel() a.ref b b.ref a del a, b gc.collect() # 如果两个对象都有__del__可能无法被回收不会有销毁输出第四个实验验证弱引用import weakref class Temp: pass t Temp() r weakref.ref(t) print(r()) # __main__.Temp object at ... del t print(r()) # None第五个实验验证分代晋升import gc gc.collect() objs_before len(gc.get_objects()) class Temp: pass tmp Temp() gc.collect(0) # tmp 还活着并且应该已经晋升到第1代 print(gc.get_count())这几个实验做完你会比背十篇文档更理解Python的内存机制。7. 结尾一些来自实战的心得最后再分享一段我的个人体会。做Python服务端优化这几年我越来越觉得内存管理与其说是一门技术不如说是一种“意识”——写完代码后多问一句“这句话创建的对象到底还有谁在引用它”大多数内存问题在写代码的那一刻就有了答案排查只是辛苦的找回过程。引用计数、分代回收、内存池这些机制看起来是底层理论但真正把它们内化成编程习惯后你写出的代码会自然更清爽、更省内存、更可预测。另外一个无数次被验证的经验是别迷信调参。改gc.set_threshold、手动gc.collect()虽然能解决一时的问题但如果定位不到根因迟早会在别的场景再踩一遍。优先用tracemalloc和objgraph定位到具体代码行再决定是改结构、用weakref还是调整回收策略这条路永远最短。如果你用的是Python 3.11以上版本还可以多关注新解释器在内存分配与回收上的改进如果是在嵌入式或后台任务中运行Python更要把gc.disable()加不加上、gc.freeze()什么时候调用这些细节想清楚。每一个细节背后都是真实的CPU周期和内存字节省下来的都是真金白银。希望这篇文章对你有用也欢迎在评论区聊聊你遇到过的奇怪内存现象说不定能帮其他人少走很多弯路。