Python 对象"死"了之后去了哪?------引用计数与分代垃圾回收
Python · 内存管理。这篇文章讲引用计数的原理、循环引用这个老大难、以及分代垃圾回收怎么兜底,还有 sys.getrefcount 和 weakref 这两个实用工具。
一个"内存悄悄涨"的怪现象
有段服务跑了一周,内存从 200MB 慢慢涨到 2GB,最后 OOM 被杀。我查了半天,发现是几处数据结构"互相指着对方",谁也离不开谁,却谁也再也用不上------典型的循环引用。
这让我开始好奇:Python 到底是怎么判断"一个对象没用了,可以回收了"的?为什么大多数时候它收得干干净净,偏偏循环引用就漏了?答案藏在一套引用计数 + 分代回收的机制里。这篇文章把它讲透。
引用计数:最朴素的"记账"
Python 里每个对象头上都挂着一个计数器,叫 ob_refcnt,记录"当前有多少个引用指向我"。规则简单到粗暴:
text
创建对象 → 计数为 1
被赋值/被引用 → 计数 +1
引用消失(变量删除、重新赋值、离开作用域)→ 计数 -1
计数归 0 → 立刻回收,内存还给系统
你可以用 sys.getrefcount 观察它:
python
import sys
a = [] # 创建一个空列表
print(sys.getrefcount(a)) # 2,而不是 1!
b = a # b 也指向它
print(sys.getrefcount(a)) # 3
del b
print(sys.getrefcount(a)) # 2
等等,为什么第一个打印是 2?因为 getrefcount(a) 在把 a 传进函数的那一刻,参数本身又增加了一个临时引用。所以读到的值,永远比"你以为的"多 1------这是新手常踩的小坑。
引用计数的好处是即时 :对象一没人用,立刻回收,绝不拖延。缺点也很明显:每次赋值、删除都要更新计数器 ,有开销;更致命的是------它解决不了循环引用。
循环引用:计数永远归不了零
看这个:
python
class Node:
def __init__(self):
self.ref = None
a = Node()
b = Node()
a.ref = b # b 的计数 +1
b.ref = a # a 的计数 +1
del a
del b # 两个对象从外部都"没人用"了,但内部还互相指着
# 计数都还是 1,永远归不了零 → 内存泄漏
a 和 b 像两个手拉手的人,外面没人叫他们了,但他们互相攥着,谁也走不了。引用计数对此无能为力。这就是开头那个内存悄悄上涨的根源。
分代回收:给循环引用兜底
Python 的解法是加一道分代垃圾回收(generational GC),专门扫描循环引用。它的核心假设是:
活得越久的对象,越可能继续活下去;刚创建的对象,更可能是"短命"的。
于是它把对象按"年龄"分成三代:
text
第 0 代:刚创建的对象(最年轻)
第 1 代:熬过一轮回收的幸存者
第 2 代:老家伙(活得最久)
回收频率也分代:第 0 代扫得最勤,第 1 代次之,第 2 代最懒。 默认阈值是(700, 10, 10)------第 0 代对象数超 700 就触发一次 0 代回收,0 代回收 10 次才触发一次 1 代回收,以此类推。
扫描时,GC 用**"可达性分析"**:从一个"根集合"(栈、全局变量等)出发,能走到标记为"活",走不到的,即使互相引用,也一并回收。于是 a.ref = b; b.ref = a 这种死循环,就被整片端掉了。
你可以用 gc 模块手动干预:
python
import gc
gc.collect() # 手动触发一次全量回收
print(gc.get_threshold()) # (700, 10, 10)
gc.disable() # 关掉自动 GC(高性能场景有人这么做,但需谨慎)
weakref:想引用,又不想"拖累"它
有时候你确实需要引用一个对象,但不想因此阻止它被回收 ------比如缓存里"有就用,没有就算了"。这时用弱引用:
python
import weakref
class Obj:
pass
o = Obj()
r = weakref.ref(o) # 弱引用:不增加引用计数
print(r()) # 返回对象 <...Obj...>
del o # 强引用没了,对象被回收
print(r()) # None
weakref 的原理是绕开引用计数 :弱引用不增加 ob_refcnt,所以对象该回收时照常回收。functools.lru_cache、观察者模式、缓存库背后,都能看到它的身影。
所以,这件事平时要不要管
大多数时候,不用管。引用计数 + 分代回收已经把 99% 的场景料理得妥妥当当,你只管写代码。真正需要你动脑的,是那几个少数时刻:
- 对象互相引用、形成环,且实现了
__del__(析构函数会干扰 GC 的判断,历史上是个坑); - 长时间运行的服务,内存缓慢上涨------八成是哪里藏了个循环引用,或某个全局容器越攒越大;
- 极致性能场景,想关掉自动 GC、改用手动
gc.collect()卡点回收。
看懂这套机制,不是为了天天调它,而是为了在内存出问题时,知道该往哪儿看。等你哪天被一个"怎么也降不下来"的内存曲线折磨过,就会感谢今天把这篇文章读完了。
引用计数、分代回收背后是 Python 对象模型的一部分,想系统理解 Python 底层,B站【408实验室】的《Python 完全自学教程》里有从基础到进阶的完整路径。