
很多开发者写了几年 Python处理内存问题的时候还是习惯性地写上一句 del就以为万事大吉。说实话我见过太多人对 del 的认知停留在“删掉变量、释放内存”这个模糊的层面真要问起来del 到底是把对象销毁了还是只把名字摘掉能讲清楚的人并不多。再加上 Python 的垃圾回收机制引用计数、标记清除、分代回收这三板斧堆在一起很多人听着名字就头大。这篇内容我打算把 del 语句和垃圾回收这两件事彻底揉碎了讲一遍。不光是语法层面的“怎么用”更重要的是底层“为什么这么设计”以及实际开发中那些教科书上不会告诉你的坑——比如 del 之后内存为什么没降、循环引用到底是怎么泄漏的、del方法什么时候被调用、gc 模块怎么排查问题。无论你是刚入门想搞懂基础还是写爬虫、数据分析脚本时被内存问题折磨过的老手这篇都能给你一些平时文档里翻不到的干货。1. del语句到底删了什么先把结论放在前面del 删除的是名字引用不是对象本身。这一点是整个问题的分水岭很多人后面踩坑都是因为没理解这句话。1.1 变量名与对象的分离Python 里的“变量”在底层和 C/C 完全不是一个东西。C 语言里 int a 10是你真的在栈上开辟了一块内存给它起了个名字叫 a里面放着 10。Python 里 a 10是在内存某个地方创建了一个 int 对象值为 10然后在当前命名空间里登记了一个名字 a让这个名字指向那个对象。所以当你执行 del a 的时候本质操作是什么是把名字 a 从命名空间里摘掉让 a 不再指向那个对象。对象本身还在不在内存里取决于还有没有其他名字指向它。如果只有 a 一个引用那 del 之后对象的引用计数归零立刻触发析构内存释放。如果还有 b、c 也在指它那 del 只是减了一次引用次数对象活得好好的人一个变量名确实被删了但对象本身安然无恙。我用一个最简单的例子演示给你看a [1, 2, 3] b a del a print(b) # [1, 2, 3]这里我删掉的是 a 这个名字但列表对象本身因为有 b 这个引用在完全不受影响。很多人刚学的时候会以为 del a 之后 b 也会报 NameError其实恰恰相反Python 的赋值语句 b a 传的是引用不是值的拷贝。这个例子就是理解 del 本质的钥匙。再补充一个更极端的场景你在函数内部定义一个局部变量函数结束后它自动销毁这个过程和我们手动写 del 是完全同源的——都是通过减少引用计数来触发对象的生命周期结束。只不过函数退出是解释器帮你做的写 del 是你自己动手罢了。1.2 del的合法对象与边界del 语句的语法格式其实很窄它能删的就是三类东西名字包括变量名、函数名、类名、模块名、容器里的元素比如列表的第几个位置、字典的某个键、对象的属性。# 删除名字 x 10 del x # 删除列表元素 lst [1, 2, 3] del lst[1] # lst 变为 [1, 3] # 删除字典键 d {name: test, age: 18} del d[age] # d 变为 {name: test} # 删除对象属性 class Foo: pass obj Foo() obj.attr 1 del obj.attr这里有个容易忽略的细节del lst[1] 和 lst.remove(2) 虽然最终效果都差不多但底层路径完全不同。del 是通过下标直接删除这个槽位时间复杂度取决于列表结构调整remove 是先按值查找位置再删除多了一次遍历查找。如果你要按索引删除大量元素del 比 remove 高效得多。还有一个边界问题del 不能删除内置的东西。比如你手滑在代码里写了 del print这行代码会执行吗其实分两种情况。如果你自己定义了变量 print 覆盖了内置函数那可以 del print 把这个覆盖撤销但恢复之后的内置函数 print 是被解释器内部引用着的你删不掉它。如果你没有覆盖过内置函数直接写 del print解释器会直接抛 NameError因为这个名字根本不在当前局部命名空间里它不是你的私有引用。所以内置函数在任何情况下都是安全防火墙你最多只能撤销自己定义的覆盖。1.3 新手最容易误解的三件事关于 del 的常见误解我已经见过太多次了这里列三个典型的。第一个误解是“del 之后内存立刻释放所以能解决内存爆炸问题”。这句话只说对了一半。del 确实会把引用计数减一但如果这个对象还被全局列表、闭包、缓存字典、异常栈等地方引用着减一之后计数还是非零对象就还在内存里。我排查过不少内存问题最后发现根本不是 del 没写而是有些引用藏得很深比如默认参数里的列表、类属性中的缓存、logging 的 handler 持有日志记录。光靠 del 救不回来得把这些隐式引用一起处理掉。第二个误解是“del 会调用对象的del方法做清理”。这句话得分开看。del 语句只是把名字删掉真正调用del的是垃圾回收那一刻。如果对象引用计数归零那确实马上会调用del然后释放内存。但如果对象活在循环引用中del 之后引用计数不归零del可能要等到分代回收真正扫到它时才会执行。所以你不能依赖 del 来保证资源释放的时机。第三个误解是“del 能把对象从内存中抹掉跟 C 语言的 free 一样”。这个误解危害最大。free 是直接把这块内存还给系统但 Python 的 del 只是解开一个绑定。而且即便引用计数归零内存也不是直接还给操作系统——CPython 的内存池和 pymalloc 机制会把这些内存块留着复用系统层面看到的内存占用可能根本没降。这是解释器层面的正常现象不是你操作失误。2. Python垃圾回收的三驾马车聊完 del 的本质接下来得进入真正的深水区对象被 del 之后到底是谁、在什么时候、用什么规则把它真正清理掉的答案就是 CPython 的垃圾回收机制。它由三套互相协作的机制组成引用计数、标记清除、分代回收。2.1 引用计数是绝对主力CPython 的核心内存管理策略是引用计数。每个对象在内存里都有一个头结构里面存着一个计数器记录“当前有多少个引用指向我”。这个计数器增加和减少的时机非常频繁比如变量赋值、参数传递、容器操作、函数返回几乎每一步都可能触发引用计数的变化。当引用计数降到 0 的时候CPython 立刻执行对象的析构函数回收这块内存。这个过程是确定性的、实时的不需要等什么垃圾回收器跑一轮。import sys obj [] print(sys.getrefcount(obj)) # 输出 2注意这里传入 getrefcount 时又临时加了一次引用为什么是 2 而不是 1因为 sys.getrefcount(obj) 这个调用本身会往函数里传一次引用函数内部又从参数里拿到一次引用所以打印出来会比你直觉上多 1。这个细节很多人第一次看到时会困惑其实解释器就是这么设计的目的就是防止你在查询引用的过程中对象被回收。引用计数机制最明显的优势是实时性。一个对象不再被引用立刻就被销毁不需要等 GC 线程醒来。对于大部分常规场景——循环里产生的临时对象、函数内的局部变量——这套机制已经足够高效。但它有个致命的缺陷无法处理循环引用。如果两个对象互相引用对方它们的引用计数永远不会降到 0因为彼此都“活着”。这就引出了第二个机制标记清除。2.2 标记清除专门收拾循环引用循环引用的经典场景是双向链表、父子节点、观察者模式。为了让你直观理解我写个最小例子class Node: def __init__(self): self.peer None a Node() b Node() a.peer b b.peer a del a del b代码执行到 del 之后从全局变量已经找不到 a 和 b 了。按理说这两个对象应该被回收。但因为 a 的 peer 指向 bb 的 peer 指向 a两者的引用计数都是 1永远降不到 0。如果只靠引用计数这就成了内存泄漏。标记清除算法解决的是这个难题。它的工作方式可以概括成两个阶段先标记后清除。标记阶段从根对象出发全局变量、调用栈、寄存器里的对象等沿着引用关系遍历所有还能到达的对象在它们身上打一个“存活”标记。清除阶段遍历所有被管理的对象凡是没被打上存活标记的统一判定为垃圾回收它们的内存。是不是听着有点耳熟其实这就是从图论里最朴素的可达性分析。你可以把整个内存里的对象想象成一张有向图每个对象是节点每条引用是边。从根节点出发BFS 或 DFS 遍历一遍能走到的都活着走不到的全是垃圾。循环引用哪怕绕成一团只要没有被根引用遍历就根本到达不了它们照样会被清除。这里有个关键点要讲清楚标记清除是在“跟踪的容器对象”之间进行的。什么叫容器对象就是 list、dict、set、自定义类实例等这些可能持有对其他对象引用的类型。单纯的 int、str、float 是不会触发循环引用的它们互相不能持有引用所以这些对象不会被纳入 GC 的监视范围它们的生命周期完全由引用计数管理。2.3 分代回收用空间换时间如果没有分代回收标记清除每次都要从根出发扫描全部对象代价是 O(存活对象数 垃圾对象数)对象一多性能就非常难看。但根据经验规律程序里绝大多数对象的生死往往发生在很年轻的时候——函数里创建、函数结束就销毁生命周期极短。真正能活很久的对象是少数。分代回收就是基于这个经验假设做的优化。CPython 把跟踪的对象分成三代第 0 代是刚创建的对象第 1 代是熬过一轮回收的对象第 2 代是最长寿的对象。新对象出生在第 0 代每经历一次回收没死掉就升到下一代。第 0 代回收触发频率最高第 1 代回收触发频率明显降低第 2 代回收极少触发因为活到这一代的对象通常确实需要长期存在触发时机通过阈值控制默认情况下第 0 代累计创建的对象数量达到阈值后触发一次第 0 代回收第 0 代回收的次数达到第 1 代阈值后顺带触发第 1 代回收以此递推。所以第 1 代回收的时候往往会连带把第 0 代也一起扫一遍第 2 代回收的时候三代全扫。这套设计本质上是用“降低扫描频率”换取“平均性能的稳定”。垃圾回收不是免费的它占用的是你主线程的 CPU 时间。分代回收的目标就是让 GC 停顿的时间尽量少同时不会放过那些真正需要回收的垃圾。你可以在代码里查看当前的阈值配置import gc print(gc.get_threshold()) # 输出类似 (700, 10, 10)这里的 700 表示第 0 代累计新分配对象达到 700 个就触发一次回收10 表示第 0 代回收满 10 次触发一次第 1 代回收另一个 10 同理。不同版本阈值可能有差异但机制是一致的。三种机制的分工可以看这张对比表机制核心思想解决的问题触发时机代价引用计数实时计数器归零即回收常规对象的及时回收引用变化时立即发生维护计数有额外开销标记清除从根出发做可达性分析循环引用导致的泄漏分代回收时顺带执行需要扫描全部跟踪对象分代回收新老对象区别对待降低全量扫描频率累计分配达到阈值回收时有短暂停顿3. 实战场景深度拆解理解了底层的机制接下来把 del 和垃圾回收放到真实的使用场景里去看。同样是写 del在不同场景下的效果天差地别这里挑三个高频场景逐个拆。3.1 容器与迭代场景中的del在实际开发里最常用的 del 是删除列表元素和字典键。先看一个经典的坑循环里按条件删除列表元素时边遍历边 del 会出大问题。# 错误示范 lst [1, 2, 3, 4, 5] for i in range(len(lst)): if lst[i] % 2 0: del lst[i]这段代码跑起来会 IndexError因为每删掉一个元素列表的长度就变了后续索引跟着错位。正确做法有两个要么倒序遍历要么用新列表过滤# 正确方式一倒序删除 lst [1, 2, 3, 4, 5] for i in range(len(lst) - 1, -1, -1): if lst[i] % 2 0: del lst[i] # 正确方式二列表推导式 lst [1, 2, 3, 4, 5] lst [x for x in lst if x % 2 ! 0]倒序遍历的原理很容易理解删除元素只会影响它后面元素的位置倒着删的话每次删的都是当前尾部前面元素索引完全不动。用列表推导式则是典型的以空间换时间牺牲新列表的内存换取代码简洁和性能稳定。字典场景下的坑更多。Python 3 里直接遍历字典时删除键会抛 RuntimeError: dictionary changed size during iteration。正确姿势是先取出要删的键列表再逐个删d {a: 1, b: 2, c: 3} for key in [k for k in d if d[k] 2]: del d[key]这里额外说一句del 字典键时如果键不存在会直接抛 KeyError。所以严格场景下你得先用 in 判断或者用 pop 方法代替——pop 可以在键不存在时返回默认值不抛异常。这两种操作风格在项目里差异很大我一般偏向用 pop 做防御性删除。3.2 自定义对象的del与资源释放自定义类的时候很多人会写del方法以为这样在对象被删除时能自动做一些清理工作比如关闭文件、释放连接。这个出发点是对的但del的实际行为习惯比你想的要复杂得多。class Resource: def __init__(self, name): self.name name print(f{name} opened) def __del__(self): print(f{self.name} closed) def create(): r Resource(temp) return r obj create() del obj这段代码跑起来会先打印 opened然后 del 之后打印 closed。看起来一切正常。但如果你把对象放进一个循环引用里事情就变味了class Resource: def __init__(self): self.ref None def __del__(self): print(cleaned up) a Resource() b Resource() a.ref b b.ref a del a del b这段代码直接跑的话大概率不打印任何内容。因为 a 和 b 互相引用引用计数没有归零del根本不会被立刻调用。它们要等到某一次垃圾回收真正扫描到它们、判定为不可达垃圾之后才会被清理。这就是很多内存泄漏问题的根源你写了一个类里面定义了自己的del但对象被循环引用困住了析构迟迟不执行文件没关、连接没断、资源一直在手里攥着。更麻烦的是del里如果访问了全局变量或者模块级对象恰巧这些对象本身也被这个类的实例引用GC 在清理时会遇到一堆诡异问题。所以我的建议很直接业务代码里尽量不要依赖del释放资源用上下文管理器with 语句配合文件、连接等资源的显式 close 才是正道。如果你确实需要del做一些兜底那至少要注意一点del只应该处理对象自己持有的资源不要回头访问可能已经被回收的其他对象。否则解释器会打印异常信息然后静默吞掉。3.3 内存敏感场景的del实践写数据分析、爬虫、批处理脚本的时候内存压力往往来自大量临时对象。比如爬虫抓了海量网页文本放进列表里做处理处理完不清理列表越来越大最终吃光内存。这种场景下 del 的正确打开方式不是把对象随手删掉而是要确认“这个对象身上没有其他引用在拖后腿”。如果你用一个列表收集了所有抓取结果又要继续跑后续逻辑那哪怕你 del 掉每个局部变量列表本身还持有全部对象内存照样上去。这时该做的反而是把列表清掉或者分段处理完就立刻释放。看一个实际例子import gc def process_batch(): batch_data [] for i in range(100000): batch_data.append({index: i, payload: x * 1000}) # 处理完后务必清掉这批数据 batch_data.clear() del batch_data gc.collect()这里 clear 是清空列表的所有元素引用del 是删掉列表自身gc.collect() 是强制触发一次回收。三步连招下来这批 100 个上万对象的内存才能比较确定地被释放。其中 gc.collect() 主要是用来处理那些可能因为内部临时引用形成的垃圾虽然不调它大概率也能回收但显式调用能让你对内存变化有更确定的把握。还有一个很多人忽略的细节在 Jupyter Notebook 或交互式环境里跑代码变量会被保存在 shell 的全局命名空间里引用持久存在。这时候你在代码里写 del 删掉临时 DataFrame、大列表作用非常有限因为交互环境可能还保留着输出历史或隐式引用。真想清内存得连内核一起重启或者在代码里加一层函数封装让局部变量在函数退出时自动降引用。4. 常见问题与排查技巧实录这一节是纯实战经验。下面这些都是我在各类项目里实际撞过墙、花时间排查过的问题整理成速查形式你照着这个思路排查一般都能找到答案。4.1 循环引用到底会不会永远泄漏先说结论会泄漏一时但通常不会永远泄漏。因为分代回收最后会扫到它们。但问题在于你无法确定“最后”是什么时候。当循环引用的对象进入了第 2 代触发回收的条件变得更加苛刻那批垃圾可能要在内存里躺很久。在一台长期运行的服务里这种半泄漏状态叠加起来就很可观了。怎么判断当前是不是有循环引用垃圾攒着最简单的思路是看 gc 模块能收集到多少对象import gc gc.collect() # 返回本次回收掉的垃圾对象数量如果 gc.collect() 每次返回的数字都很大说明循环引用垃圾一直存在而且没有被及时回收。这时候就该去检查代码里哪些对象互相引用了。常见的高危模式包括类实例互相持有对方引用如双向链表、父子节点闭包内部引用了外层函数的大对象观察者模式中被观察者持有观察者的引用观察者又持有被观察者的引用带del方法的对象本身出现在循环引用里会拖慢整个 GC 流程排查的工具方面objgraph 是个很实用的第三方库可以生成对象引用图直接看出谁持有谁。不过如果你不想装额外依赖纯 gc 模块也能找到可疑对象的大致类型分布import gc for obj in gc.get_objects(): if isinstance(obj, dict) and len(obj) 100: print(type(obj), len(obj))这只是个粗糙的扫描思路排查的方向是找出那些数量异常庞大、迟迟不被回收的对象类型。4.2 __del__方法的执行时机与异常陷阱del的执行时机我已经反复强调过了不是 del 的时候而是对象真正被垃圾回收的时候。这里有一个非常隐蔽的坑Python 的官方文档里专门提醒过在del里抛出的异常只会打印到 stderr然后被忽略不会影响程序主流程。这听起来是“救你一命”实际上是掩盖了问题你很难从日志里意识到自己的清理逻辑其实出了 bug。另一个更恶心的坑是如果del方法里访问了一个全局变量而那个全局变量在模块卸载时已经被回收你会看到类似 name xxx is not defined 的报错。排查这种问题让人非常抓狂因为它的出现时机不固定可能程序退出时才触发也可能延迟到很久之后才触发。所以我的建议还是那句话不要依赖del做核心清理逻辑。真正的资源清理请放在 try/finally、with 语句或者 contextlib.contextmanager 里。del顶多做一个兜底打印日志的操作别让它承担关键任务。4.3 gc模块手动干预的正确姿势gc 模块除了查看阈值和强制回收外还有一些日常用得上的 API。调试时最常用的组合是 get_threshold、get_objects、collect、set_debug。当你怀疑内存有异常增长时第一步可以临时关闭自动回收再对比手动回收效果判断是不是 GC 没有及时工作import gc gc.disable() # ... 运行一段代码 ... gc.collect()这套操作在压测和分析内存变化时很实用但在生产环境里永远不要禁用 GC尤其是处理大量容器对象的程序。关了 GC 等于放弃了对循环引用的清理能力纯引用计数是兜不住的。set_debug 则可以在 gc 模块上开启详细的日志输出帮助你观察回收过程中每个阶段的行为import gc gc.set_debug(gc.DEBUG_SAVEALL)DEBUG_SAVEALL 这个标志位很关键开启之后被回收但不可访问的对象不会立刻销毁而是被保存到 gc.garbage 列表里方便你查看它们到底是什么以及为什么被判定为垃圾。在我排查过的一次诡异内存泄漏中这个列表帮了大忙找到了一批被第三方库内部循环引用拖住的对象。不过用完记得清一下 gc.garbage否则它自己就成了一个巨大的引用源。4.4 使用弱引用打破长生命周期的锁链如果某些对象确实需要互相引用但你又不想让引用计数把对方卡死可以用 weakref 弱引用。弱引用的核心特征是不增加目标对象的引用计数也不会阻止目标被垃圾回收。目标被回收后弱引用会自动失效访问时返回 None。举一个非常现实的例子你有一个全局缓存字典里面存了很多大对象。如果直接用 dict 保存对象永远不会被回收因为字典持有强引用。如果用 WeakValueDictionary 保存当其他强引用都消失后对象会被正常回收缓存的条目也会自动消失不占额外索引空间import weakref class BigObject: def __init__(self, data): self.data data cache weakref.WeakValueDictionary() obj BigObject(some large data) cache[key] obj del obj # 删除最后一个强引用 print(cache.get(key)) # None缓存条目也被自动清理了这个设计非常优雅但要注意一个限制不是所有类型都支持弱引用。列表、字典、int、str 这些内置类型默认不支持弱引用除非你继承了相应类型。自定义类和大部分扩展类型是支持的。想用弱引用的话要么自己包一层类要么就用专门设计的容器类型。5. 踩坑之后的体会与建议这段时间深挖 del 和垃圾回收机制我自己最有感触的一点是对机制的直觉比背 API 重要得多。Python 的内存管理虽然自动但并非不可控。理解了引用计数、标记清除、分代回收这三板斧各自的定位和触发条件之后再回头看你代码里的 del、gc.collect()、弱引用这些操作思路会清晰很多。很多时候不是代码写得不对而是你对“对象在内存里是怎么被持有的”理解不够。最后分享一个小技巧。如果你遇到内存异常别急着到处加 del先冷静下来用 sys.getrefcount 查看关键对象的引用计数用 gc.get_referrers 反向查看是哪些对象持有它。从引用关系入手定位问题比盲目删变量靠谱得多。我在实战中靠这两个函数解决过好几次看似无解的内存泄漏搜代码时省下来的时间远超调试的时间。这套分析方法建议你反复练习直到形成肌肉记忆。