ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python内存管理全解析:引用计数、循环引用与分代回收

Python内存管理全解析:引用计数、循环引用与分代回收 1. 从“Java卡顿”说起Python的内存管理到底在管什么很多从Java转过来的朋友第一次听说Python的垃圾回收机制都会下意识地拿它跟JVM的GC做对比。尤其是那些玩过《我的世界》Java版的人对“垃圾回收卡顿”应该不陌生——因为你每探索一块新区域JVM就得扫描一遍堆内存把不再使用的对象批量清理掉这个过程如果赶上老年代满了就会触发一次世界停顿Stop The World游戏画面直接卡住一两秒。但Python给绝大多数人的体感却是几乎没有那种“明显卡顿”。这不是Python比Java更了不起而是两者采用了完全不同的内存管理哲学。Python的核心武器是引用计数Reference Counting它走的是“随时回收”的路线每个对象被创建时内部都带着一个计数器记录“现在到底有多少个地方指向我”。当你把变量重新赋值、删除或者函数调用结束这个数字就会减一当它减到零对象立刻、马上、当场就被销毁内存立即释放。这意味着什么意味着Python里的垃圾回收是连续发生的、实时的而不是像Java那样“攒一堆再统一处理”。前者是水滴式支付后者是月底统一结算——水滴式支付虽然频繁但每次只付一点点你几乎感觉不到月底统一结算虽然就一次但那一瞬间现金流压力巨大。不过引用计数也不是银弹。如果有人告诉你“Python有引用计数所以永远不会内存泄漏”那这个人要么没写过大型项目要么就是没遇到过循环引用——两个对象互相指着对方它们的引用计数永远不归零就变成了真正意义上的内存孤儿。这也是Python为什么还需要垃圾回收器GC的原因引用计数负责90%的日常清理GC负责解决那10%的循环引用问题。这篇文章我想把整个Python内存管理链路拆开讲透引用计数怎么工作、循环引用怎么被检测、分代回收的阈值背后是什么逻辑、内存池为什么存在、以及我在实战中怎么用这些知识定位内存泄漏。全程不会贴源码逐行分析但会把机制讲明白、把可操作的经验给你保证你读完能真的用它去排查问题。2. 引用计数Python里“随用随扔”的生存法则2.1 一个对象一生要经历几次“出生入死”我习惯把Python里的每个对象想象成一台带“客流计数器”的街边小店。每当你写下一行a []Python就分配了一块内存把门口的小牌子翻到“1”表示有一个引用。之后你不停地把a传给别人、塞进列表、赋给新变量店里的人流量就一路涨b a计数器变2c a变3。反过来每次有人离开del b、c重新指向别处、函数返回销毁局部变量计数器就减一。当计数器归零的那一瞬间Python不仅会释放这个对象占用的大小比如一个列表的几百字节还会触发它内部所有子对象的清理——如果列表里装了元素那些元素也会各自减一次引用计数层层传导直到整棵对象树全部归零。这里有个很重要的细节引用计数归零的回收发生在当前代码执行的同一线程内。你不需要等什么“GC线程”来处理所以Python能保证内存释放是有确定性的。这也是为什么Python没有Java那种“对象明明没用了但不一定什么时候才被GC掉”的语义——Python里只要没有引用它立刻死给你看。2.2 sys.getrefcount把隐身计数器的盖子掀开说了半天计数器怎么看到它标准库的sys.getrefcount()就是干这个的import sys a [] print(sys.getrefcount(a)) # 输出 2因为参数传递本身也占了一次引用 b a print(sys.getrefcount(a)) # 输出 3 del b print(sys.getrefcount(a)) # 输出 2又是参数占位注意这个函数有个“陷阱”当你调用sys.getrefcount(a)时a作为参数被传进去函数内部又临时引用了一次所以返回的数字永远比直觉大1。还有在交互式命令行里跑输出可能会再大1因为_变量也会持有最后一次表达式的结果。所以看这个数只想看相对变化别纠结绝对数值。真正决定对象生死的那个计数器在CPython源码里对应结构体PyObject头上的ob_refcnt字段。这是个 C 语言的Py_ssize_t类型也就是64位系统上是个64位整数——别担心它会溢出除非你的程序真的同时有一千亿亿个引用指向同一个对象。2.3 什么时候计数器会变化——一份“引用增减”对照表我在实际编码中总结了一份比较容易记的场景清单动作引用计数变化例子创建对象赋值给变量1 → 初始为1x [1, 2]变量之间赋值目标对象 1原指向对象 -1y x容器添加元素元素对象 1lst.append(x)容器删除/清空元素元素对象 -1lst.remove(x)/lst.clear()函数传参传入对象 1返回销毁局部引用时 -1foo(x)del 变量被删变量指向的对象 -1del x变量被重新赋值旧指向对象 -1新指向对象 1x hello这里面最容易被忽视的是容器操作和函数传参。比如你把一个大字典的 key 传给一个长期存活的对象保存下来即使原来那个 key 已经在业务中被“逻辑删除”了只要容器还拿着它它的引用计数就不会归零内存就永远不会释放。这就是为什么很多所谓的“内存泄漏”其实不是GC的bug而是你的某个全局列表、全局缓存、类属性还在偷偷持有引用。2.4 引用计数的硬伤循环引用引用计数有一个天生的致命缺陷拿代码说最清楚class Node: def __init__(self): self.neighbor None a Node() b Node() a.neighbor b # b 的引用计数 1 b.neighbor a # a 的引用计数 1 del a del b此刻a和b都从全局视野中消失了但它们的邻居属性仍然互相指向对方导致两个对象的引用计数都是1永远不为零。内存被两个互相取暖的孤儿占着谁也回收不了。这种问题在真实项目中非常常见父子节点互相引用、观察者模式里订阅者和被订阅者互相持有、ORM模型双向关联、事件回调闭包互相循环。如果你只依赖引用计数这种循环引用会伴随进程直到结束——短脚本还好跑在服务器上的长驻进程就是一场慢性内存泄漏。3. 垃圾回收的保留项目用来“救火”的标记-清除算法3.1 引用计数管不到的角落GC怎么接管Python的 GC 模块不负责常规的对象回收——那是引用计数的活儿。GC 真正的职责就是处理容器对象之间的循环引用列表、字典、集合、自定义类实例等基本类型int、str、float因为不包含指向其他对象的引用永远不会成为循环引用的参与者所以GC不关心它们。GC 用的是**标记-清除Mark-Sweep**算法。它的大致过程是从一组“根对象”root出发——根包括全局变量、当前活动调用栈里的局部变量、模块里的全局引用等等沿着对象之间的引用关系做图遍历把所有能到达的对象都“标记”为存活遍历结束后那些没被标记到的对象就是真正的“垃圾”直接回收。但标记-清除有个天然的效率问题如果你想找“不可达”的对象就得遍历全部对象图。Python 里可能有几十万上百万个对象每次全量扫描代价太高。于是Python采用了另一个经典技术来降低成本——分代回收只让老资格的对象少被扫描新对象多被扫。3.2 为什么叫“分代”新对象死得快老对象活得久由于**弱代假设Weak Generational Hypothesis**的存在——大部分对象的生命周期都很短创建后马上就变成垃圾只有少数对象能活很久——不同“年龄”的对象垃圾率差异巨大。Python的GC把参与追踪的对象分成三代第0代年轻代所有新创建的容器对象先进这里垃圾比例极高。第1代中年代在年轻代里撑过一轮GC扫描还没死的对象升上来。第2代老年代又熬过一轮扫描的升到这一层垃圾率最低扫描最不频繁。分代的思想很简单既然年轻对象大部分都是“秒死”的那我们就频繁扫年轻代既然老年代里大部分都是长期存活的长命对象那就尽量少去骚扰它们把扫描资源留给最可能产生垃圾的地方。这和 JVM 分代收集的底层出发点完全一致区别只是一个用引用计数做日常、GC做补漏Python另一个全靠GC统一调度Java。3.3 阈值到底怎么算700 10 10 背后的故事我第一次看到gc.get_threshold()返回(700, 10, 10)的时候第一反应是这三个数字都啥意思第一个数字700每新创建700个容器对象触发一次第0代的垃圾回收扫描第二个数字10第0代累计触发了10次之后把存活下来的对象晋升到第1代并触发一次第1代的扫描第三个数字10第1代累计触发了10次之后晋升存活者到第2代并触发第2代的扫描。所以三代扫描的触发频率大概是700次新建对象 → 一次0代扫描约7000次新建 → 一次1代扫描约70000次新建 → 一次2代全程扫描。这也意味着老年代扫描的实际频率远比年轻代低得多所以即使全过程扫描成本高分摊到长期运行中也不算夸张。你可以在代码里手动调整阈值import gc gc.set_threshold(1000, 15, 15) # 降低扫描触发频率但说实话绝大多数场景不建议动这个数。除非你明确知道自己项目里短生命周期对象极其密集比如频繁创建的临时DataFrame或者反过来老年代里存着大量巨型对象导致每次全量扫描卡顿明显否则默认值已经是在大量压测下折中出来的最优解。3.4 手动触发GC的两种姿势至于gc.collect()这个是调试救场的不适合放进生产逻辑import gc gc.collect() # 全量回收 gc.collect(generation0) # 只用第0代 got gc.collect(generation2) # 返回本次回收掉多少个对象我自己的经验是做过量的循环引用管理代码时别把gc.collect()当保险栓。它更像“验证你有没有真正解决循环引用”的工具——你在测试环境里跑完之后手动调用一下看返回多少个回收对象如果数字很大说明工程代码里肯定还有盘根错节的循环引用没拆掉。正确的做法是定位并拆掉它们而不是每次都手动GC来兜底。4. 分代回收的实现细节从触发到晋升的全过程4.1 “追踪”名单只有容器对象才值得被GC登记GC不是随便什么对象都管。CPython 的GC系统维护了一张“可追踪对象链表”只有两类东西会被登记进去可以容纳其他对象的容器list、tuple、dict、set、自定义类的实例对象。带有__del__方法的对象下面专门讲这个坑。纯数值、字符串、布尔、None 等都不进这个名单。因为GC判断循环引用必须通过对象的属性引用去找其他对象而int和str不可能引用别人所以没有追踪的必要。你可以用gc.is_tracked()验证import gc a [] b 42 print(gc.is_tracked(a)) # True print(gc.is_tracked(b)) # False这也意味着如果你写了个自定义类但它的实例只持有普通数值属性没有引用其他可变对象那它就算放在循环图里也无法成为“真的循环”因为不追踪就等于不参与GC扫描。4.2 什么时候触发0代扫描一个计数器的故事CPython里有个隐藏的变量统计着“自上次0代扫描后一共新建了多少个可追踪对象”。当这个数达到gc.get_threshold()[0]的值默认700时0代扫描就自动触发。扫描的时候找到0代里全部对象把这些对象和1代、2代的所有对象一起构成“可达图”因为0代对象可能引用了老年代里的对象单独扫0代而不看全局引用关系就会误判——你把老年代对象误杀了标记扫描结束后活下来的0代对象晋升到1代1代如果攒够了10次晋升对象就触发1代扫描活下来的晋升到2代2代的扫描以此类推。这个“扫描时必须看全图”的逻辑很多人会忽略。看到GC在扫0代就以为“只要0代对象够了就扫0代”其实扫描面是全代的只是触发的条件分层。这也是为什么0代扫描的代价其实也不低——它不是只扫700个新建对象而是要扫整个追踪链表里所有对象。4.3 双向链表GC是怎么高效遍历的CPython用了一个巧妙的双链表来组织所有需要追踪的对象。每个对象内存头部都有一个指针_gc_next和_gc_prev把大家串成一个链表。GC做标记扫描时直接从链表头顺着指针遍历每个对象并不需要在堆内存里“散步”这种组织方式让对象的迭代速度非常快也避免每次都做满堆扫描。当对象被回收时它会从链表上摘除、释放内存当对象不再被追踪比如从list变成元组内部不再引用任何容器也会被“解除追踪”从链表摘掉。这套机制保证了GC遍历的输入集合永远是“当前存活着且可能构成循环引用的对象”不会把不会循环的对象也算进遍历成本。4.4 为什么Python 3.11才引入“增量GC”很长一段时间里Python的GC一旦触发就会在扫描正在进行期间暂停整个解释器——虽然时间通常很短毫秒级但在极高吞吐的服务上依然可能造成“毛刺”。CPython 3.11加入的增量GC思路是把一次全量标记过程拆成多个小步每完成一小步就让解释器继续跑一段代码交替执行直到标记完成。这样做的最大收益是平滑了暂停时间虽然总耗时可能更长但单次停顿被分散到极小的时间片里对实时性要求更高的场景游戏服务器、量化交易系统、流媒体网关更友好。原理上类似JVM的G1垃圾回收器的步进式标记只不过Python的实现晚了很多年。5. 内存池与分配器小块对象为什么“死了还能复生”5.1 内存碎片是比垃圾更可怕的敌人如果你真的让CPython去系统底层malloc()任意大小的内存随着对象频繁创建和销毁堆上会出现大量大小不一、散落分布的空洞——这就是内存碎片。碎片多了以后即使总空闲内存很充裕你要分配一个大的连续内存块时也可能失败因为内存被切割得到处都是“小空洞”连不成大块。Python引入了一个**内存池Memory Pool**的中间层来解决这个问题。它把对象的内存分配和维护从“直接系统调用malloc”隔离开来——小块对象就在自己的池子里用非常高效的方式分配和回收基本不产生碎片分配速度也比malloc快得多。5.2 分档次管理Python怎么给“小块”分类CPython把小块内存按8字节的倍数划分档次size class就比如1~8字节的是一档9~16字节是一档17~24字节是一档依次类推直到512字节封顶。每个size class维护一组空闲块链分配时直接找对应档位的链表头部拿一块释放时放回链表头部整个过程O(1)复杂度绝不遍历堆。超过512字节的对象直接转交给系统的mallocC库处理大于某个超大阈值的对象通常是几MB级进一步走mmap匿名内存映射直接在虚拟内存里开辟独立区域好处是可独立释放、完全不影响堆的连续性。5.3 池子里的二级结构arena、pool、block这块是面试高频问题也是理解内存池的钥匙。CPython内存池分成三层block最小单位一个block对应一个对象比如一个8字节的对象占一个8字节的block同一档次的block大小一致pool一组同一档次的block的集合通常一个pool大小为4KB一个系统页大小里面装满同一size class的blockarena由多个pool组成的“大地盘”通常是256KB一个进程会维护若干arena。当你的Python程序销毁一个小对象时它所在的block被归还到pool的空闲链表但pool本身不还给操作系统而是留在池子里备着等下一次同size class的对象创建直接复用。这就是我标题里说的“死了还能复生”——死的是对象池子里的空间一直在轮回。也因为这个机制如果你用top命令盯着Python进程看RSS内存会发现内存一旦涨上去就很难降下来Python把大量pool缓存在arena里不还给操作系统这是设计使然。常驻服务看起来“内存占用缓慢上升”很多时候不是泄漏只是池子缓存膨胀。判断是不是真泄漏的一个土办法稳定运营几小时后跑一次gc.collect()如果内存掉回去说明池子和GC缓存占了空间居多如果掉不回去才是真泄漏。5.4 同档分块设计带来的效率红利size class设计还有一个额外好处同档block大小一致回收和分配完全可以零碎片化。因为块尺寸是固定的一个block被释放后下次任何同类对象都能用同一个block空间利用率高分配速度也不需要调用系统API。而malloc每次要处理不同大小请求的元数据开销明显更重。Python在web服务器、数据处理脚本这种“大量小对象高频创建销毁”的场景里内存池的优势体现得尤为明显。这也是为什么同样处理10万个条目Python相比某些每次分配都走系统调用的语言内存分配这块经常比预期更快的原因之一。6. 深入逼疯你的坑del、弱引用与循环引用连环炸6.1 循环引用加上__del__GC也救不了的死局这是Python内存管理里最著名的“燃气灶”级别的坑。当一个类的实例被循环引用且该类定义了__del__方法时即使GC去扫描标记它也不会乖乖回收。原因在于GC标记阶段无法确定到底是先清理对象A还是先调用A的__del__先执行——如果先调用A的__del__而A的__del__需要访问被引用的BB又还没释放就可能摧毁引用图导致后续行为不可预测没有严谨的顺序。从Python 3.4之后带__del__且处于循环引用的对象会被放进一个特殊的“unfinalizable”集合GC直接把它们晾在一边也不回收也不调用__del__直到程序退出。结果就是内存真的永久泄漏而且你可能在日志里根本看不到任何异常。所以我的铁律很简单生产代码里尽量避免自定义__del__。确实需要销毁前清理资源的话用显式的close()方法上下文管理器with语法代替别依赖Python在垃圾回收时“顺手”帮你调用。你见过几个正经框架是靠__del__释放数据库连接的几乎没有都是为了安全先close()。6.2 弱引用让回收机制不再被“非分引用”绑架弱引用weakref的价值在于它可以安全地“指向”一个对象但不会增加对象的引用计数。对象没有任何强引用时即使弱引用还指着他它也会被正常回收同时弱引用自动失效。这在“缓存”和“观察者”模式里是救命稻草import weakref class Data: pass obj Data() ref weakref.ref(obj) print(ref() is obj) # True del obj print(ref() is None) # True对象已回收再想想我前面写的Node循环引用例子——如果父节点持有的子节点引用用弱引用weakref.ref()或weakref.proxy()实现那么循环就断了引用计数会正常归零对象立刻被释放连GC都不用登场。弱引用不是让你人为降低引用强度而是从源头切断循环这是比依赖GC标记扫描更干净、更高效的做法。6.3 弱引用的几种姿势对比方式接口适用场景weakref.ref(obj)ref() 获取对象None表示已回收最常见的通用弱引用适合单值缓存weakref.proxy(obj)直接调用和原对象几乎无区别需要直接调方法、传参的场景WeakValueDictionary字典形式值弱引用缓存大对象键不再持有值WeakKeyDictionary字典形式键弱引用附加元数据到对象但不影响对象生命周期WeakSet集合形式元素弱引用记录存活对象但不想拥有它们实际项目中WeakValueDictionary是我用得最多的。比如做一个“按ID缓存大模型对象”的模块如果直接用普通dict缓存所有模型对象都永远活着换成WeakValueDictionary后当业务线程不再强引用某个模型它就能自动被释放缓存不会无限膨胀。6.4 用gc模块自测把看不见的垃圾捞出来看排查循环引用最简单粗暴的手段就是gc.get_objects() 遍历找指定类型实例数。我之前写过这么一段脚本定位某服务里的泄漏点import gc import collections class UserSession: pass for _ in range(10000): a UserSession() b UserSession() a.friend b b.friend a # 故意制造循环引用 gc.collect() users [obj for obj in gc.get_objects() if isinstance(obj, UserSession)] print(len(users)) # 如果没有gc.collect或循环未断这里会接近20000找到泄漏类型后再用objgraph.show_refs()或者objgraph.show_backrefs()画引用图一眼就能看出谁一直被谁hold住。闭包、线程栈、全局缓存是三大常见的“隐形持有者”。7. 实战排查经验怎么定位Python进程“内存只涨不跌”7.1 monk区的三种观测手段很多人一看到内存涨就急着开tracemalloc其实应该先确认是池子缓存还是真泄漏。我的优先级是用/proc/pid/status里的VmRSS观察涨势曲线看是否稳定在某水平——稳定就是池子缓存/GC边界持续斜升就是泄漏跑一次gc.collect()看RSS是否回落——回落明显说明循环引用GC能清理只是阈值没到不回落说明存在GC处理不了的东西要么是不可达强引用要么是带__del__的循环要么是C扩展泄漏上tracemalloc抓调用栈定位是哪条业务路径分配的内存最多。顺序很重要别一上来就上重武器否则排查过程本身就能把你内存干爆。7.2 tracemalloc按调用栈追溯内存来源tracemalloc的思路是记录每次内存分配时的调用栈统计每个栈帧累计分配了多少字节。我在FastAPI服务里排查过一次经典泄漏import tracemalloc tracemalloc.start(25) # 保存最多25层调用栈 # ... 跑业务负载 ... snapshot tracemalloc.take_snapshot() top_20 snapshot.statistics(lineno)[:20] for stat in top_20: print(stat) for line in stat.traceback.format(): print( , line)它会清楚告诉你某个路径比如一个视图函数的内部循环占了多少内存。然后你去看那段代码通常都伴随着“把什么东西存进了局部变量却没有及时释放、引用了大对象后一直没删引用、或者数据被某个长期存活容器收割”。值得说明的是tracemalloc只能在程序启动早期开启才有意义生产服务如果已经跑了一晚上再开历史内存数据天知道哪里来的。在有条件的情况下建议在线下压测负载环境里开好它再灌流量。7.3 objgraph把藏在循环里的引用图“照”出来当你定位到某个类型实例数量异常增长时objgraph是下一步利器。它靠gc.get_referrers()和gc.get_referents()反向查找所有引用来源然后输出成图片import objgraph objgraph.show_backrefs( objgraph.by_type(UserSession)[:20], filenameleak.png, max_depth6 )这张图里你能看到一条清晰的引用链UserSession←UserSession.friend← …… ←全局缓存列表。链条走到哪断掉就找到了让你内存涨个不停的真正祸根。7.4 一份我踩过的坑清单这么多年修内存问题最终沉淀下来几条铁打的教训del变量并不保证立刻释放内存——如果它还被某个闭包、默认参数、事件回调隐式持有计数器不会变零。尤其注意给on_xxx类方法传lambda时lambda内部捕获了外层大变量导致闭包引用链非常长。线程的threading.local()、全局字典缓存、类静态属性都是“隐形引用”最容易藏身的地方。排查时用gc.get_referrers()反查一下永远有惊喜。千万别依赖__del__清理跨进程资源数据库连接、文件句柄。进程崩了、解释器退出时的执行顺序完全不可控正确的做法是显式close()或依赖contextlib.closing上下文管理。尽量别写“大型循环引用还期望GC替你兜底”的代码。GC是最后一道防线不是默认回收手段。你控制代码结构它控制死亡对象分工要清晰。8. 关于调优的取舍手动GC、禁用GC和生产环境的平衡有人可能会问既然GC触发不平等、带__del__的循环引用不可靠那我干脆手动调度GC行不行或者干脆gc.disable()行不行我的回答是可以但必须有明确的适用场景和风险预案。gc.disable()在特定场景确实能提升性能——比如一个脚本短期创建大量临时容器整体运行时间极短退出时操作系统会释放所有内存这时禁用GC确实能省下扫描开销。典型例子是各种命令行工具、批处理导入脚本。但凡是长驻进程web服务、爬虫、后台任务禁用GC基本等于自爆——循环引用会不受阻挡地累积服务跑两三天内存就爆了。更温和的策略是只在特定代码路径里临时调整GC行为比如批量数据处理时调高阈值让GC少打扰密集分配处理完再恢复默认阈值。我做过类似的批量ETL任务导入100万行CSV的过程中临时gc.set_threshold(100000, 20, 20)跑了20分钟从未主动调用过gc.collect()整体耗时比默认阈值快约15%内存涨幅可控因为这百万行处理完本身就不需要长期引用池子能复用的块足够多。还有一点值得单独提gc.freeze()是3.7以后提供的方法可以把当前所有对象冻结让GC以后不再扫描它们。用于fork后的子进程比如多进程服务 pre-fork 模式特别有用父进程启动时加载了大量全局初始化数据这些数据永远不会被销毁让GC反复扫描它们纯属浪费。fork后立刻gc.freeze()子进程所有后续GC扫描直接跳过这些冻结对象Long-running worker进程的性能能得到明显改善。说到底GC调优的终极心法不是“把GC打死”而是理解每种配置的成本结构把扫描资源花在最可能产生垃圾的地方。默认阈值适合大多数项目调整前先在压测环境中量化对比别拍脑袋。这几块内容串下来你会意识到一件事Python的内存管理从来不是“一个垃圾回收器”那么简单——引用计数负责精确打击、分代GC负责地毯式扫雷、内存池负责资源复用三者协同才形成了我们日常感受不到的稳定性。而真正决定内存会不会泄漏的永远是你在代码里留下的引用关系。写代码时多想想“这个对象还会有谁引用它”比任何调优技巧都管用。
返回列表