只改一个方向的引用,循环引用就能立刻被回收?一文讲透 weakref 弱引用

「Python 进阶之路」系列 Day23

写在前面

Day22 讲了循环引用要靠 gc.collect() 兜底才能清理,del 完对象却"卡"在内存里不动。今天这篇往前一步:能不能从设计上直接避免循环引用产生,而不是每次都指望 GC 事后来擦屁股?答案是 weakref------一种不增加引用计数的引用方式。


一、是什么:弱引用不增加引用计数

弱引用(weak reference) :一种"不计入引用计数"的引用方式。用 weakref.ref(obj) 创建一个弱引用对象,通过调用它(r())可以拿到原对象;但这个弱引用本身不会让原对象的引用计数加一------如果原对象的其他强引用都没了,弱引用完全不能阻止它被回收。

python 复制代码
import weakref, sys

class Node:
    def __init__(self, name):
        self.name = name
    def __del__(self):
        print(f"Node {self.name} 被销毁了")

n = Node("A")
print(sys.getrefcount(n))   # 2

r = weakref.ref(n)   # 创建弱引用
print(sys.getrefcount(n))   # 2 ------ 完全没变!弱引用不增加引用计数

print(r())            # <__main__.Node object at 0x...>
print(r() is n)        # True ------ 弱引用调用能拿到原对象

对象被销毁后,弱引用调用会返回 None

python 复制代码
del n
# Node A 被销毁了   ← del n 立刻触发销毁,因为n本来就没有循环引用

print(r())   # None ------ 原对象已经没了,弱引用调用返回None

二、为什么要主动用weakref,而不是完全依赖gc兜底

Day22 讲了循环引用会一直"卡"在内存里,直到 gc.collect() 扫描到才会被清理。既然有 GC 兜底,为什么还要主动用 weakref 去避免循环引用?

  • GC 扫描本身有开销:对象数量越多,扫描一遍的成本越高,能从设计上直接避免循环引用产生,比依赖事后清理更高效
  • 回收时机不可控:GC 什么时候扫描到、什么时候真正清理,取决于分代阈值触发的时机,不像引用计数那样"立刻生效",如果程序对内存释放的时机比较敏感,这种不确定性是个问题
  • 某些场景本来就不该是"双向强引用":比如缓存持有对象、观察者模式里被观察者持有观察者列表、树结构里子节点指向父节点------这些场景的语义本来就是"单向真正拥有、另一个方向只是知道对方存在",用弱引用能更准确地表达这种关系,而不是造出一个本不该存在的循环引用再指望 GC 帮忙擦屁股

三、怎么用

1. 实测:weakref打破循环引用

把 Day22 那个循环引用的例子改一下,让其中一个方向变成弱引用:

python 复制代码
class NodeWeak:
    def __init__(self, name):
        self.name = name
        self.other = None
    def __del__(self):
        print(f"NodeWeak {self.name} 被销毁了")

a = NodeWeak("a")
b = NodeWeak("b")
a.other = b                       # a 强引用 b
b.other = weakref.ref(a)           # b 只弱引用 a,不增加a的引用计数

del a
del b
# NodeWeak a 被销毁了
# NodeWeak b 被销毁了
# ↑ del 完立刻触发销毁,完全不需要 gc.collect() 介入!
flowchart LR A[NodeA] -->|强引用| B[NodeB] B -.->|弱引用不计入引用计数| A

对比 Day22 的例子(双向都是强引用,del 之后必须靠 gc.collect() 才能清理),这里只要有一个方向换成弱引用,循环就被打破了------a 的引用计数只剩 b.other 这个弱引用(不计数),del a 之后 a 的引用计数正常归零,立刻销毁;a 销毁后 b 也不再被任何东西引用,跟着立刻销毁。一个方向的弱引用,就足以让原本的"循环"变成一条能正常靠引用计数回收的单向链。

2. weakref.proxy():像代理一样直接用

weakref.ref() 每次要拿对象都得多写一层调用(r()),weakref.proxy() 提供了一个可以直接当原对象用的代理:

python 复制代码
class Data:
    def __init__(self, value):
        self.value = value
    def __del__(self):
        print("Data 被销毁了")

d = Data(42)
p = weakref.proxy(d)
print(p.value)   # 42 ------ 直接像用d一样用p,不用加括号调用

del d
# Data 被销毁了

print(p.value)
# ReferenceError: weakly-referenced object no longer exists

原对象被销毁后,再访问 proxy 会直接抛出 ReferenceError,提醒你"这个对象已经不在了",而不是像弱引用调用那样悄悄返回 None------两种方式适合不同场景,proxy 更适合"用起来要和原对象一模一样、但访问失效时希望立刻报错"的场景。

3. WeakValueDictionary:不阻止对象被回收的缓存

一个经典应用场景:写缓存时,希望"缓存能加速访问,但不应该因为缓存持有着对象,就阻止这个对象被正常回收",weakref.WeakValueDictionary 就是为这个场景设计的:

python 复制代码
class Resource:
    def __init__(self, name):
        self.name = name
    def __del__(self):
        print(f"Resource {self.name} 被回收了")

cache = weakref.WeakValueDictionary()
res = Resource("res1")
cache["key1"] = res

print("key1" in cache)   # True
print(cache["key1"].name)   # res1

del res   # 删除唯一的强引用
# Resource res1 被回收了   ← 立刻被回收,缓存没能"续命"

print("key1" in cache)   # False ------ 对象没了,缓存里的条目也自动消失

如果这里用的是普通 dict 做缓存,cache["key1"] = res 会给 res 增加一次强引用,哪怕外部的 res 变量被删了,缓存里那份引用也会让对象一直存活,变成事实上的内存泄漏------WeakValueDictionary 恰好避免了这个问题,值被外部回收时会自动从字典里消失。


四、面试追问

Q1:gc 模块是怎么检测出循环引用的?

gc 只追踪"容器类型"对象(list、dict、自定义对象等可能参与循环的类型),不追踪 int、str 这类不可变原子类型。扫描时会计算每个被追踪对象"来自其他被追踪对象内部的引用数",从它的真实引用计数里减掉这部分:剩下计数仍大于 0,说明有外部真实引用撑着,是存活对象;剩下为 0,说明所有引用都来自彼此之间,是纯循环垃圾,可以回收。

Q2:weakref 弱引用和普通引用的区别是什么?

普通(强)引用会让对象的引用计数加一,只要强引用存在,对象就不会被回收;弱引用不会增加引用计数,通过 weakref.ref(obj)() 访问对象,如果对象已经因为没有强引用而被销毁,弱引用调用会返回 None

Q3:为什么要主动用 weakref,而不是完全依赖 gc 兜底?

GC 扫描本身有开销,对象越多扫描成本越高;而且 GC 的回收时机不确定,不像引用计数那样立刻生效。更重要的是,某些关系本来就该是单向的"知道对方存在"而不是双向"互相拥有"(比如缓存、观察者模式、树结构里子节点指向父节点),用弱引用能更准确地表达这种语义,从设计上直接避免不必要的循环引用,而不是造出问题再指望 GC 事后清理。

Q4:weakref 有哪些常见应用场景?

常见场景包括:缓存(WeakValueDictionary,缓存的值被外部正常回收时会自动从缓存里消失,避免缓存变相阻止对象被回收)、观察者模式(被观察者用弱引用持有观察者列表,避免观察者因为被观察者持有的引用而"续命"、无法正常释放)、树/图结构里子节点指向父节点的反向引用(防止父子之间形成循环引用)。

Q5:把循环引用的例子改成一个方向用 weakref,还会不会造成"内存卡住"的问题?

不会。只要循环中有一个方向换成弱引用,这个引用就不计入引用计数,原本的循环就被打破,变成一条能正常靠引用计数回收的单向链,del 完成之后对象会立刻被销毁,不再需要等待 gc.collect() 介入。


下一篇预告

Day24 讲 Python 性能优化实战------用 cProfile 定位代码里真正的性能瓶颈在哪,而不是凭直觉猜哪里慢。

相关推荐
阿童木写作43 分钟前
跨马翻译:AI批量图片翻译工具,视频字幕翻译与智能抠图一站式解决
大数据·人工智能·python·音视频
长大198843 分钟前
窗口函数用不好反而更慢?SQL Server中OVER子句的4个性能陷阱
后端
大黄评测43 分钟前
MERGE语句有Bug?SQL Server官方不推荐的背后真相与替代方案
后端
长大19881 小时前
TempDB 爆满导致系统卡死?SQL Server TempDB 瓶颈诊断与根治方案
后端
lichenyang4531 小时前
从「房间」到「实时通知」:用 NestJS + Socket.IO 实现团队邀请的完整工程实践
前端·后端
大黄评测1 小时前
为什么你的SQL查询慢?这7个执行计划陷阱90%的人都踩过
后端
沙盘客1 小时前
AFSIM 示例解读(09)· 传感器全家桶 sensor_demos(下):ESM / SAR / 被动测向
c++·经验分享·后端
陈年老古董1 小时前
OpenCV图像处理笔记:图像拼接、答题卡识别与目标提取
人工智能·笔记·python·opencv·计算机视觉