R

ReflexKR

Reflex 官方文档

名牌机场:Python 对象模型

·

课程概览

1

标签不是箱子:名字与对象

2

封口的箱子:可变与不可变

3

箱中箱:容器与复制

4

交出箱子的时候:函数与参数

5

区域与通行证:作用域

6

随身行李:闭包

7

箱子规格书:类与实例

8

机场标准流程:双下划线方法

9

规格书的规格书:继承与 MRO

10

安检口:属性访问的秘密

11

无主的箱子:内存与 GC

12

储物柜号码:哈希与相等,综合

名牌机场:Python 对象模型
第11课

时间 8:20–9:10

无主的箱子:内存与 GC

失物报告单

LOST-011

13 号传送带上的幽灵箱子

报告人

夜班设备巡检员 尹师傅

丢了什么

两个一个标签都没有、却没被清理的箱子。已经在 13 号传送带上转了十一天。

结案条件

查明这两个箱子为什么没被清理,清走之后不再出现。

天空岛国际机场 行李中心

从第一天起就有人报告的 13 号传送带上那两个箱子,今天还在转。一个标签都没有。按规定,没有标签的箱子当天就该送去清理场。

都美兰班长把之前的三份记录摊在桌上:第 3 课的 X 光照片、第 6 课的笔记、第 7 课的 partner 记录。“今天就查清这两个家伙的真面目。在那之前,得先把清理规则弄明白。”

11-1. 数标签:引用计数(9 分钟)

CPython 中的每个对象都有一个数字,记着有多少个引用指向自己。这就是引用计数(reference count)。挂一个标签就加 1,摘一个就减 1,变成 0 的那一刻就地清理。

import sys

a = []
print(sys.getrefcount(a))
# 2
b = a
print(sys.getrefcount(a))
# 3
c = [a, a]
print(sys.getrefcount(a))
# 5
del b, c
print(sys.getrefcount(a))
# 2
  • 初始值是 2 而不是 1,是因为调用 sys.getrefcount(a) 时作为参数传进去的那个引用也算在内。
  • c = [a, a] 在列表里插了两个标签,所以加 2。插在箱子里的标签也是标签。
  • del b, c 摘掉了名字 b;列表 c 被清理时,里面的两个标签也一起没了。

别依赖这个数字本身。从 3.14 开始,解释器会做一种跳过部分引用增减的优化。同样的代码放在函数里运行,初始值在 CPython 3.14 中是 1,在 3.13 中是 2。要看的是“一个标签对应 1”这个差值。

变成 0 的那一刻就清理,这可以用 __del__ 来确认。它是对象被清理前一刻调用的双下划线方法(为什么不能信任它,见 11-4)。

class Bag:
    def __init__(self, label):
        self.label = label
    def __del__(self):
        print("discard", self.label)

a = Bag("A")
b = a
del a
print("after del a")
del b
print("after del b")
# after del a
# discard A
# after del b

def check_in():
    tmp = Bag("T")
    print("inside")

check_in()
print("back")
# inside
# discard T
# back
  • del a 之后还剩标签 b,所以没有清理。摘掉最后一个标签的 del b 一执行,立刻打印出“discard A”。
  • 函数结束时调用栏消失,局部名字 tmp 也随之摘掉。所以它在“back”之前就被清理了。
  • 这就是第 6 课班长说的那件事:即使是已经结束的函数里的东西,只要被闭包的 cell 抓着,引用计数就不会变成 0,它就会一直留着。

下面的 GC 观测台会在工作进程里创建真正的对象,运行真正的 gc。每个箱子都显示“引用数”(名牌 + 插在其他箱子里的标签)。从任何一个名牌出发、顺着箱子里面走都到不了的箱子,会画成红色虚线。虚线标签会用 w() → … 显示它现在指向什么。

GC 观测台

Python

在 tags 中,每摘一个标签,引用数就减 1,在变成 0 的那一步被清理。在 chain 的第 4 步,b 虽然没有名牌,但还在 a 里面,所以引用数仍是 1,等到 a 被清理时一起被清理。

小钉提示

→ sys.getrefcount 的数字会随版本和位置(在模块里还是函数里)而变。别看绝对值,要看挂上、摘下一个标签时变化 1 的那个“差值”。

11-2. 互相装着对方的两个箱子:循环引用与 GC(12 分钟)

把三份记录拼起来,真相就出来了。第 3 课 X 光照片上的 [[...]],是箱子装着自己的样子。第 7 课的记录里,两个箱子用 partner 属性互相指着对方。把两者直接写成代码,就是这样:

标签板

步骤 1 / 0

下一行将执行: 0

正在准备实验台…

刚刚变化的

封口的箱子(不可变)

第 9 行之后,两个箱子通过 partner 互相指着对方。第 10 行之后两个箱子从板上消失,是因为标签板只画从名字出发能到达的箱子。

del a, b 把名牌全摘了。可 A 里面还有指向 B 的标签,B 里面还有指向 A 的标签。两边的引用计数都是 1,永远不会变成 0。这就是循环引用(reference cycle)。

从名字出发到达不了的对象,程序再也没有办法使用,它们就是垃圾。在标签板上消失,也是这个缘故。但光靠引用计数,清不掉这种垃圾。

所以 CPython 还有第二位清洁工:循环垃圾回收器(gc 模块)。

  • 它检查列表、dict、实例这类能装其他对象的对象。
  • 在这一群对象里,把它们彼此之间的引用扣掉再数。完全没有来自外部的引用的那一群,就是只互相抓着彼此的垃圾。
  • 把这样的一群整个清理掉。gc.collect() 让它马上做这件事,并返回清理掉的对象数。
import gc
gc.disable()   # 暂时关掉自动 GC,让示例结果稳定

class Bag:
    def __init__(self, label):
        self.label = label
        self.partner = None
    def __del__(self):
        print("discard", self.label)

a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
del a, b
print("tags gone")
print(gc.collect() > 0)
gc.enable()
# tags gone
# discard A
# discard B
# True
  • del a, b 之后什么都没被清理。“discard”要到 gc.collect() 里面才打印出来。这两行的先后顺序是不确定的。
  • 平时不调用 gc.collect() 也没关系。对象创建得多了,解释器会自己运行它。gc.disable() 只是为了这个示例才用的。
  • 自动 GC 什么时候运行、分几代,每个版本都不一样,即便在 3.14 内部也改过。不要写依赖特定时机或数字的代码。

第 3 课那个装着自己的列表,也是同样的情况。

import gc

a = []
a.append(a)
print(a, a[0] is a)
# [[...]] True
del a
print(gc.collect())
# 例如:23

返回的数字会把在此之前积攒的其他垃圾一起算进去,所以随情况和版本而变。在这台电脑上,同一个文件用 3.13.9 运行得到 1,用 3.14.2 运行得到 23。下面的观测台在开始时先清理一次再计数,所以每次结果都一样。

GC 观测台

Python

在 cycle 中,名牌全部摘掉后引用数仍各剩 1,gc.collect() 清理掉 2 个箱子和它们里面的 2 个列表,共 4 个。break 先切断了循环,不用 gc 就被清理(gc.collect() 为 0),self 就是第 3 课的 [[...]]。

循环最终也会被清理,那就没问题了吗?有两点不同:

  • 引用计数在变成 0 的那一行就清理,而循环 GC 是之后一次性清理。在这期间,它们一直占着内存。
  • 如果循环里的对象抓着文件或连接,那些资源也要等到 GC 运行时才会关闭。

消除循环的方法有两种:用完后亲手摘掉一边的标签(像 break 那样),或者从一开始就把一边做成弱引用。

11-3. 虚线标签:弱引用(8 分钟)

弱引用(weak reference)是不计入引用计数的虚线标签。它指着箱子,却不抓住箱子。箱子被清理后,虚线标签会自动变空。

import weakref

class Bag:
    def __init__(self, label):
        self.label = label

a = Bag("A")
w = weakref.ref(a)
print(w() is a)
# True
del a
print(w())
# None
  • 要像 w() 这样调用一下才能拿到箱子。如果已经被清理,就得到 None,所以用之前总要检查。
  • 不是所有对象都能挂虚线标签。weakref.ref([1, 2]) 会报 TypeError: cannot create weak reference to 'list' object。int、str、tuple、dict 也一样;自己写的类的实例则可以。

GC 观测台

Python

虚线标签 w 不计入引用数。在 weak 中,摘掉最后一个名牌的那一刻就被清理;在 weak+cycle 中,即使有虚线标签,循环也还是由 gc.collect() 来清理。

弱引用用得最多的地方是缓存。用普通 dict 做缓存,放进去的对象会被缓存永远抓着,内存只增不减。WeakValueDictionary 用虚线标签拿着值,别处不再使用时,条目会自动消失。

import weakref

class Bag:
    def __init__(self, label):
        self.label = label

desk = weakref.WeakValueDictionary()
c = Bag("C")
desk["C"] = c
print("C" in desk, len(desk))
# True 1
del c
print("C" in desk, len(desk))
# False 0

还有用虚线拿着键的 WeakKeyDictionary,以及用虚线拿着元素的 WeakSet。想给对象另外附加信息、又不想延长它的寿命时,就用它们。

想在箱子被清理时执行某件事,就用 weakref.finalize。

import weakref

class Bag:
    def __init__(self, label):
        self.label = label

d = Bag("D")
f = weakref.finalize(d, print, "discard D")
print(f.alive)
# True
del d
# discard D
print(f.alive)
# False

finalize 最多只调用一次;如果箱子一直活到程序结束,就在退出时调用。它比下一节的 __del__ 可靠。不过要注意:如果传进去的函数和参数抓住了那个箱子(比如传 d.close),箱子就永远不会被清理。

11-4. 别信 __del__:清理交给 with(6 分钟)

__del__ 看起来很适合放清理代码,但有三个原因让它靠不住:

  1. 不知道什么时候会被调用。在循环里的话,要拖到 GC 运行时。在 PyPy 这类不用引用计数的其他 Python 实现中,可能会更晚。
  2. 里面抛出的异常不会传到外面。只在标准错误里打印一次,然后被忽略。
  3. 程序结束时还活着的对象,不保证会调用 __del__。
class Jam:
    def __del__(self):
        raise RuntimeError("belt jam")

x = Jam()
del x
print("still running")
# (标准错误)例如:Exception ignored while calling deallocator <function Jam.__del__ at 0x...>:
# (标准错误)... RuntimeError: belt jam
# still running

文件、套接字、锁这类必须关闭的资源,用 with 来关。离开代码块的那一刻,即使发生了异常,__exit__ 也会被调用(第 8 课)。

class Belt:
    def __enter__(self):
        print("belt on")
        return self
    def __exit__(self, *exc):
        print("belt off")
        return False

with Belt() as belt:
    print("moving")
# belt on
# moving
# belt off

try:
    with Belt():
        raise ValueError("stuck")
except ValueError as e:
    print("caught", e)
# belt on
# belt off
# caught stuck

open() 返回的文件也用 with open(path) as f: 来写。清理的时机就定在代码里看得见的地方。

小钉提示

→ __del__ 不承诺“什么时候”。必须关闭的东西用 with;要和对象寿命绑在一起的清理,用 weakref.finalize。

11-5. 永生对象、箱子的重量,以及 3.14t(5 分钟)

None、True、小整数这类到处都在用的对象,是永生对象(3.12,PEP 683)。它们的引用计数是固定的,无论挂上、摘下多少标签,都不会被清理。

import sys

print(sys.getrefcount(None))
# 3221225472
x = None
print(sys.getrefcount(None))
# 3221225472
print(sys._is_immortal(None), sys._is_immortal([]))
# True False
  • 这个数字是表示“永生”的标记值,各版本不同。CPython 3.14 中是 3221225472,3.13 中是 4294967295。
  • sys._is_immortal 是 3.14 新增的。名字以下划线开头,属于 CPython 的实现细节。

sys.getsizeof 只称箱子本身的重量,不算里面的东西。

import sys

print(sys.getsizeof([]), sys.getsizeof([1, 2, 3]))
# 56 88
big = [0] * 1000
print(sys.getsizeof(big), sys.getsizeof([big]))
# 8056 64

在 64 位 CPython 中,列表的空身子是 56 字节,每个用来插标签的位置再加 8 字节。根据创建方式,有时会预留富余的位置,所以 [1, 2, 3] 是 88 而不是 80。[big] 是只有一个位置的箱子,所以是 64 字节。里面的 big 即使有 8056 字节,也不计算在内。

关于自由线程构建(3.14t)的一句话。在没有 GIL 的构建中,多个线程会同时修改引用计数。因此,创建对象的线程快速地计自己那一份,其他线程则以原子操作单独计共享的那一份(biased reference counting,偏向引用计数)。表面上的规则是一样的。把本课的示例用 3.14.3t(sys._is_gil_enabled() 为 False)运行,引用计数的值、立即清理、由 gc.collect() 清理循环,全都一样。唯一不同的,只有 gc.collect() 返回的数字。

结案(10 分钟)

两个箱子是用彼此的 partner 互相抓着的循环引用。虽然没有标签,但引用计数各剩 1,所以没被清理;在循环 GC 运行之前,它们一直留在传送带上。班长一运行 gc.collect(),13 号传送带就空了。

想让它不再出现,就让箱子用虚线标签记住搭档。用第 10 课的 property 包一层,使用方的代码完全不用改。

import weakref

class Bag:
    def __init__(self, label):
        self.label = label
        self._partner = None

    @property
    def partner(self):
        return self._partner() if self._partner is not None else None

    @partner.setter
    def partner(self, other):
        self._partner = weakref.ref(other)   # 用虚线标签记住搭档

a = Bag("A")
b = Bag("B")
a.partner = b
b.partner = a
print(a.partner.label, b.partner.label)
# B A
watch = weakref.ref(a)
del a
print(watch() is None, b.partner)
# True None

只用一行 del a,A 就立刻被清理,B 的 partner 变成了 None。从一开始就不存在需要等 GC 的循环。

下班前,尹师傅又拿来一行记录:幽灵箱子 B 上个月曾被存进失物储物柜,可当时谁也没能把它取出来。班长说了句“这个明天最后再看”,把储物柜钥匙放在了桌上。

结案报告 — LOST-011。两个幽灵箱子是用 partner 互相抓着的循环引用。用 gc.collect() 清理,再改为用 weakref.ref 记住搭档,防止再次发生。

课后练习

  1. 预测 a = []、a.append(a) 之后 sys.getrefcount(a) 的值。在模块里运行会得到什么?为什么不是 2?
  2. 往 weakref.WeakValueDictionary() 里放一个字符串作为值。会报什么错?用自己写的类的实例包一层又会怎样?
  3. 写一个打开文件、在 __del__ 里关闭的类,再改成用 __enter__、__exit__ 关闭。确认即使代码块里发生异常,文件也会被关闭。

班长的工作手册

  • 引用计数 = 箱子上挂的标签数。在变成 0 的那一行立刻清理。

  • sys.getrefcount 连作为参数传入的引用也算。数值随版本而变,只看差值。

  • 互相装着对方的箱子(循环引用)不会变成 0,由循环 GC 之后清理。gc.collect() 返回清理的数量。

  • 自动 GC 的时机和分代构成随版本而变。不要依赖它们。

  • 弱引用是虚线标签。缓存用 WeakValueDictionary,清理时要做的事用 weakref.finalize。

  • 别信 __del__。必须关闭的资源用 with。

  • None 这类永生对象不会被清理。getsizeof 只称箱子本身。

← 上一课

第10课 · 安检口:属性访问的秘密

下一课 →

第12课 · 储物柜号码:哈希与相等,综合

订阅新课时和新课程通知

本课程新增课时或推出后续课程时,我们会发邮件通知你。

收集项目:电子邮箱 · 用途:本课程的新课时和上线通知 · 保存期限:直到你要求删除。不会用于其他用途,也不会提供给他人。回复任意通知邮件,我们会立即删除。

问答

关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。

以纯文本显示,不支持链接和格式。

R

ReflexKR

动手学 Python 和 Reflex。所有示例均已在 Reflex 0.10.0 上实际运行验证。

ReflexKR 免费,没有横幅广告。如果对你有帮助,欢迎请我们喝杯咖啡。 ☕ 请我们喝杯咖啡

联盟链接与赞助说明
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex