上一篇我们提到,引用计数是Python内存管理的基础,但它有一个致命短板——无法处理循环引用。今天,我们就用实战代码,还原循环引用的问题,再拆解Python如何通过“标记-清除”算法和“分代垃圾回收”,解决这个难题。
循环引用,就是两个(或多个)对象互相引用,即使没有其他任何引用指向它们,它们的引用计数也永远不会降到0,导致内存无法回收(内存泄漏)。让我们看个例子:
import sysimport weakrefclass Node: name = None child = None def __init__(self, Name): self.name = Name # 节点名称 def __repr__(self): # 自定义打印格式,方便观察对象 return f"Node: {self.name}"# 创建两个节点对象a = Node('a') # a的引用计数 = 1b = Node('b') # b的引用计数 = 1# 创建弱引用(弱引用不增加引用计数,上一篇讲过)a_ref = weakref.ref(a)b_ref = weakref.ref(b)# 互相引用,形成循环引用a.child = b # a引用b,b的引用计数 = 2b.child = a # b引用a,a的引用计数 = 2# 查看此时a的引用计数(+1是因为getrefcount的临时引用)print("创建循环引用后", f"{sys.getrefcount(a_ref())=}") # 输出3(实际引用计数2 + 临时引用1)# 删除a和b的引用del a # a的引用计数 = 1(还被b.child引用)del b # b的引用计数 = 1(还被a.child引用)# 此时a和b已被删除,但循环引用还在,查看引用计数print("删除a和b后", f"{sys.getrefcount(a_ref())=}") # 输出2(实际引用计数1 + 临时引用1)print("删除a和b后", f"{sys.getrefcount(b_ref())=}") # 输出2(实际引用计数1 + 临时引用1)
运行结果会发现:即使删除了a和b,它们的引用计数依然不为0,内存无法被回收——这就是循环引用导致的内存泄漏。我们可以用gc模块,查看到底还有哪些引用指向a:
import gc # 查看所有引用a的对象print(f"引用a的对象:{gc.get_referrers(a_ref())}")
输出结果会显示:a.child引用着b,b.child引用着a,这两个互相引用的关系,就是导致内存泄漏的根源。
为了解决循环引用的问题,Python在引用计数的基础上,增加了“标记-清除(Mark-and-Sweep)”算法,作为垃圾回收的补充。这个算法的核心逻辑很简单,分为两步:标记阶段:从“根对象”(Python确定一定存在的对象,比如全局变量、当前函数的局部变量)出发,遍历所有可访问到的对象,给这些对象打上“可达”标记;清除阶段:遍历所有对象,将没有被打上“可达”标记的对象(也就是无法通过根对象访问到的对象,即使有循环引用,也会被判定为垃圾)回收,释放它们的内存。
标记-清除算法虽然能解决循环引用,但遍历所有对象的效率很低——如果程序中存在大量对象,每次遍历都会消耗大量资源。Python基于“分代假设”(两个核心规律),优化出了“分代垃圾回收”机制:大多数对象“寿命很短”(比如函数中的临时变量,执行完就没用了);老对象很少引用新对象。
基于这个假设,Python将内存中的对象分为3代,不同代的对象,采用不同的回收频率,既保证效率,又不遗漏垃圾。我们可以用gc模块,查看Python默认的分代回收阈值:
import gc# 查看分代回收阈值,默认输出(700, 10, 10)print(f"GC分代阈值:{gc.get_threshold()}")
三个数字,对应三代对象的回收频率:第0代(Generation 0):新创建的对象(未经历过垃圾回收)。每创建700个对象(触发阈值),就执行一次第0代的垃圾回收(标记-清除);第1代(Generation 1):经历过1次第0代回收后存活下来的对象。每执行10次第0代回收,就执行一次第1代回收;第2代(Generation 2):经历过多次回收后存活下来的对象(老对象)。每执行10次第1代回收,就执行一次第2代回收。
回到之前的循环引用案例,默认情况下,标记-清除算法不会立即执行(因为没有达到700个对象的阈值),所以a和b的内存不会被立即回收。我们可以手动触发垃圾回收,强制回收循环引用的对象:
import sysimport weakrefimport gcclass Node: child = Nonea = Node()b = Node()b_ref = weakref.ref(b)# 形成循环引用a.child = bb.child = a# 删除a和b的引用del adel b# 查看删除后的引用计数(此时还未回收)print("删除a和b后", f"{sys.getrefcount(b_ref())=}") # 输出2# 手动触发垃圾回收(参数2表示回收0、1、2三代对象)n_collected = gc.collect(2) # 查看回收的对象数量print(f"手动回收后,回收的对象数量:{n_collected}") # 输出2(a和b被回收)# 再次查看引用计数(此时对象已被回收,b_ref()返回None)try: print(f"回收后,b的引用计数:{sys.getrefcount(b_ref())}")except TypeError: print("回收后,b对象已被释放,无法获取引用计数")
运行结果会显示:手动触发垃圾回收后,a和b两个对象被成功回收,内存得到释放——这就是分代垃圾回收的实战用法。
还有个需要注意的点:全局解释器锁(GIL),和垃圾回收密切相关。
我们知道,GIL是Python的一个核心机制,它保证了同一时刻,只有一个线程能执行Python字节码。而在垃圾回收中,GIL的作用至关重要:
引用计数的操作(增加/减少),必须是“原子操作”(要么全部执行,要么全部不执行),否则多线程环境下,可能导致引用计数错乱,进而引发内存泄漏或内存 corruption(内存损坏)。
GIL 恰好保证了这一点:同一时刻,只有一个线程能操作引用计数,避免了多线程竞争导致的问题,确保垃圾回收的安全性和正确性。
简单说:没有GIL,Python的引用计数和垃圾回收机制,在多线程环境下会彻底失效。