Python 里 del x 到底删掉了什么?拆开引用计数、4294967295 和分代 GC 漏掉的那圈循环引用
很多人写过 del x 之后顺手去看内存,发现没降,于是怀疑「Python 不释放内存」。其实 del 从来就不负责释放对象,它只删掉一个名字。
一句话结论:CPython 靠引用计数即时回收,计数归零的那一刻对象就没了;但两个对象互相引用时,计数永远归不了零,这部分要靠分代 GC 补。del 只是把某个名字从当前命名空间里摘掉,跟「对象还在不在」是两件事。
下面全部数据来自本机 CPython 3.13.14 实测。
一、sys.getrefcount 把计数变化摊开
import sys
class X: pass
x = X()
print(sys.getrefcount(x) - 1) # 减 1 是因为 getrefcount 的参数本身也算一次引用
y = x
print(sys.getrefcount(x) - 1)
lst = [x]
print(sys.getrefcount(x) - 1)
del y
print(sys.getrefcount(x) - 1)
del lst
print(sys.getrefcount(x) - 1)
实测输出:
refcount(x) = 1
after y=x = 2
after lst=[x] = 3
after del y = 2
after del lst = 1
一条名字、一个变量、一个列表元素,各自贡献 1。del y 减 1,del lst 再减 1。整个过程是确定的、即时的,没有「等一会儿」。
二、del 删的是名字,不是对象
z = x
del x
print(z is not None, sys.getrefcount(z) - 1)
del x only removes the NAME; z alive? True | refcount(z) = 1
x 这个名字没了,对象还活得好好的,因为 z 还指着它。这也解释了为什么「del 之后内存没变」根本不是问题——只要还有一条引用,对象就不该被回收。
引用计数最讨喜的地方正是这个即时性:最后一个引用消失的瞬间,__del__ 就会跑,文件句柄、socket 之类能立刻关掉,不用等 GC 轮询。代价是,每个赋值、传参、装箱都要维护计数,而且有一类结构它天生处理不了。
三、循环引用:计数归不了零
import gc, weakref
gc.disable() # 先关掉 GC,只看引用计数的表现
class Node: pass
p = Node(); q = Node()
p.peer = q
q.peer = p # 互相指,形成环
wr = weakref.ref(p)
print(sys.getrefcount(p) - 1)
del p, q
print('weakref alive?', wr() is not None)
n = gc.collect()
print('collected', n, '| weakref now None?', wr() is None)
gc.enable()
实测:
cycle created, gc disabled, refcount(p) = 2
after del p,q -> weakref alive? True (refcount alone cannot free a cycle)
gc.collect() collected 2 objects | weakref now None? True
删掉外部名字 p、q 之后,两个对象彼此还各留着一条引用,计数是 1 而不是 0。引用计数对此无能为力——这就是分代 GC 存在的唯一理由:它不替代引用计数,只补引用计数抓不住的那部分。
weakref 在这里很好用:它不增加引用计数,所以能如实告诉你「对象到底还在不在」,而不是因为你手里攥着引用而永远看到「在」。
四、有 __del__ 的循环,3.4 之后也能收
老资料里常说「带 __del__ 的循环引用会漏进 gc.garbage」,那是 Python 3.3 及以前的事。PEP 442 之后不再如此:
class WithDel:
def __del__(self): pass
u = WithDel(); v = WithDel()
u.o = v; v.o = u
del u, v
print(gc.collect())
cycle WITH __del__ (PEP 442, 3.4+) collected: 2
两个对象都被正常回收。不过别因此把清理逻辑全塞进 __del__——它什么时候跑、跑几次,在循环场景下仍不如 with / contextlib.closing 可控。
五、sys.getrefcount(1) = 4294967295 是什么
>>> sys.getrefcount(1)
4294967295
这个数不是「有 42 亿个地方在用整数 1」,而是 2**32 - 1,CPython 3.12 起引入的永生对象(immortal objects)标记。
像 -5 ~ 256 的小整数、None、True、False、部分内部字符串这类被极高频共享的对象,引用计数不再真的增减,而是被钉在这个特殊值上。好处很直接:省掉海量无意义的计数加减,也为「让对象真正只读、跨解释器共享」铺路。
一个实用推论:别拿 sys.getrefcount(1) 当引用计数的教学例子,它从 3.12 起就不再是普通对象了;想演示计数变化,用自定义类的实例(像第一节那样)。
六、分代 GC 的三层账本
>>> gc.get_threshold()
(2000, 10, 10)
>>> gc.get_count()
(514, 8, 0)
>>> gc.is_tracked([1, 2])
True
get_threshold() 的三个数是 (gen0 触发阈值, gen1 触发次数, gen2 触发次数)。3.13 实测默认是 (2000, 10, 10);老版本常见 (700, 10, 10),写脚本时别硬编码。get_count() 是当前各代距上次回收累计分配了多少容器对象。
只有「可能参与循环」的容器才会被 gc 跟踪,gc.is_tracked([1,2]) 为 True。全是不可变标量的元组,GC 可能在某些阶段把它从跟踪表里摘掉(untrack),所以别把 is_tracked 的返回值当永久属性。
分代的依据是很朴素的一条经验:绝大多数对象活得极短。于是新对象放 gen0,扫得最勤;熬过一轮的挪到 gen1,再熬过一轮到 gen2,越老扫得越少。这样一次完整回收不用每次都遍历全部对象。
代价实测一下就知道:
one full gc.collect() cost = 1480.8 us
一次全量 gc.collect() 约 1.5 毫秒。所以不要在热循环里手动 gc.collect()——默认阈值就是替你权衡好的。真要调,也只该在批量创建完一波长命对象之后调一次。
七、两套机制怎么分工
| 场景 | 谁负责 | 时机 |
|---|---|---|
| 普通对象最后一个引用消失 | 引用计数 | 立即 |
| 两个对象互相引用、外部引用已断 | 分代 GC | 达到阈值时 |
| 永生的小整数 / None / 单例 | 不回收 | 进程生命周期内常驻 |
del x |
谁都不负责「释放」 | 只删名字,减一次计数 |
一句话:引用计数管「绝大多数、且要立刻」;GC 管「极少数、且不急」。del 只是给计数减一,别把它当 free()。
八、排错清单(实测踩过的五条)
del x之后对象还活着 → 检查还有没有别的变量/容器/闭包引用它;用weakref.ref判断真实存活,别用变量本身。- 循环引用导致内存不降 → 用
weakref代替强引用(缓存、父子双向指针、观察者列表尤其常见),或显式断环。 - 拿
sys.getrefcount(1)演示计数 → 3.12+ 返回4294967295,是永生对象标记,换自定义类实例演示。 - 热循环里手动
gc.collect()→ 一次约 1.5ms,纯亏。确需调优改gc.set_threshold(),别频繁全量回收。 - 把清理逻辑写在
__del__里等它立刻执行 → 循环场景下时机不可控,改用上下文管理器。
九、小结
del x 的准确语义是「把名字 x 从当前作用域解绑,并让对象引用计数减一」。计数归零,对象当场消失;归不了零又成环,就等分代 GC 来收。中间还夹着 3.12 之后那批被钉在 2**32-1 的永生对象。
把这三件事分开看,很多「Python 内存怪现象」就不是怪现象了:即时回收的不是 GC 的功劳,GC 只是来补那个计数永远差一口气的环。
运行环境:本机 CPython 3.13.14。本文所有输出均由 d53-probe.py 实测取得;gc.get_threshold() 在 3.10 及更早版本常见为 (700, 10, 10),请勿跨版本硬编码。

浙公网安备 33010602011771号