AIGC标识 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()。

八、排错清单(实测踩过的五条)

  1. del x 之后对象还活着 → 检查还有没有别的变量/容器/闭包引用它;用 weakref.ref 判断真实存活,别用变量本身。
  2. 循环引用导致内存不降 → 用 weakref 代替强引用(缓存、父子双向指针、观察者列表尤其常见),或显式断环。
  3. 拿 sys.getrefcount(1) 演示计数 → 3.12+ 返回 4294967295,是永生对象标记,换自定义类实例演示。
  4. 热循环里手动 gc.collect() → 一次约 1.5ms,纯亏。确需调优改 gc.set_threshold(),别频繁全量回收。
  5. 把清理逻辑写在 __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),请勿跨版本硬编码。

posted @ 2026-11-07 09:48  小小张说故事  阅读(6)  评论(0)    收藏  举报