在 Python 中,我们会创建很多对象(如数字、列表、字典、类实例等),这些对象都需要占用一定的内存存储。当对象不再使用的时候,需要及时释放,否则就会导致程序占用的内存越来越多,当某一时刻程序没有更多的内存可以使用,就会引发内存泄漏、程序崩溃。
有些同学可能会想,我在写 Python 的时候,并没有手动释放对象内存,也没有出现问题?
这是因为 Python 内部提供一套垃圾回收机制(Garbage Collection),它能够自动对不需要的对象进行内存回收,避免我们手动释放,从而让 Python 使用更加简单、更加安全。
今天,我们就探讨下这套机制的运行原理,让大家了解它是如何进行对象内存管理的。
1. 垃圾对象
在理解 Python 的垃圾回收机制时,第一个要理解的核心概念是”垃圾对象“。所谓垃圾对象是指
在程序中不再被任何变量引用的对象。
这些对象已经 “失去了使用价值”,程序再也无法访问它们,因此可以被安全地销毁,从而回收其所占的内存资源。
class Demo(): def __del__(self): print('对象销毁') def test(): # 创建一个 Demo 对象,该对象由 a 变量引用 a = Demo() # 同一个 Demo 对象又被 b 变量引用 b = a # test 函数执行结束后,a 和 b 作为局部变量被销毁 # 此时,Demo 对象不再被变量引用,成为垃圾对象 if __name__ == '__main__': test() num1 = 10 num2 = 20 ret = num1 + num2 print('ret =', ret)
2. 引用计数
为了能够跟踪对象被引用的次数,在 Python 中,每个对象结构都维护一个引用计数字段,用于记录该对象被引用的次数。我们可以通过 sys.getrefcount 函数来获得某个对象被引用的次数。当对象的引用计数为 0 时,该对象就会被自动回收。
注意:getrefcount(obj)
返回值会比实际引用计数多 1,因为传参时会临时增加一次引用。
import sys class Demo(): def __del__(self): print('对象销毁') def test(): a = Demo() # 注意:此处输出 2,新创建的 Demo 对象初始引用计数为 1(由变量 a 引用),用 sys.getrefcount(a) 时,函数参数会临时增加一次引用,因此输出 2。 print('Demo 对象引用计数:', sys.getrefcount(a)) b = a # 此处 b 又引用对象,会导致 Demo 对象引用计数增加 1 print('Demo 对象引用计数:', sys.getrefcount(a)) # 此处断开了 a 和 Demo 对象之间的引用,会导致引用计数减 1 del a print('Demo 对象引用计数:', sys.getrefcount(b)) # 当 Demo 对象的引用计数为 0 时,该对象就会被自动释放,此时会自动调用对象的 __del__ 函数 # 例如:当 test 程序执行结束,Demo 对象的最后一个引用 b 被销毁,此时引用计数为 0 print('test 函数执行结束') if __name__ == '__main__': test() input()
程序执行结果:
Demo 对象引用计数: 2 Demo 对象引用计数: 3 Demo 对象引用计数: 2 test 函数执行结束 对象销毁
3. 循环引用
通过引用计数机制,Python 管理对象变得简单。但是,引用计数自身也存在一个致命问,即:循环引用,它会导致某些不再使用的对象引用计数无法为 0,导致对象无法正确释放。例如:
import sys class Demo: def __init__(self): self.ref = None def __del__(self): print('对象销毁') def test(): a = Demo() b = Demo() # 循环引用发生处 a.ref = b b.ref = a print('a 的引用计数:', sys.getrefcount(a)) print('b 的引用计数:', sys.getrefcount(b)) # 当程序结束,a 和 b 离开作用域后,对象仍互相引用,虽然没有外部变量引用它们,但因为彼此引用,它们的引用计数都为 1,因此无法被引用计数机制回收。 # 即:函数结束后,会导致循环引用出现 print('test 函数执行结束') if __name__ == '__main__': test() # 暂停程序,观察 test 函数中是否正确清理对象 input()
程序执行结果:
a 的引用计数: 3 b 的引用计数: 3 test 函数执行结束
通过程序我们发现,当 test 结束时,创建的两个 Demo 对象并没有正确销毁,这就是引用计数无法解决的循环引用问题。
注意:循环引用不仅发生在对象之间互相引用时,还可能发生在更复杂的引用链中(如 A→B→C→A)。
4. 标记清除
循环引用导致了程序中会出现一些用不到,引用计数也无法销毁的顽固垃圾对象,为了能够正确销毁这些对象,Python GC 再次引入标记-清除算法来对付它们。它的原理如下:
在 Python 中,对象之间通过引用构成了一个对象图。这张图从一组根对象(如全局变量、当前栈的局部变量等)为起点,沿着引用关系遍历图中所有可达对象。所有未被遍历到的对象就是不可达对象,说明程序中已经无法再访问它们,因此可以被安全清理。
简言之:通过对象之间的引用关系,标记出所有可达对象,清除未被标记的对象。
对于上面出现的循环引用问题,我们可以手动启动 GC,使用标记清除来销毁哪些顽固的垃圾对象:
import sys class Demo: def __init__(self): self.ref = None def __del__(self): print('对象销毁') def test(): a = Demo() b = Demo() a.ref = b b.ref = a print('a 的引用计数:', sys.getrefcount(a)) print('b 的引用计数:', sys.getrefcount(b)) print('test 函数执行结束') if __name__ == '__main__': test() c = Demo() c.ref = Demo() # 假设在此时机,Python 从根对象开始扫描所有对象 # 发现: # 1. 在 main 中创建的两个 Demo 对象可以从根对象跟踪到,标记为可达(保留) # 2. 在 test 函数中的两个 Demo 对象无法从根对象扫描到,标记为不可达对象(清除) # 那么,如何启动标记清除? 通过 gc.collect() 函数即可 # 注意:该函数一般不用手动调用,GC 会在合适的时机自动调用 import gc gc.collect() # 暂停程序,观察 test 函数中是否正确清理对象 input()
程序执行结果:
a 的引用计数: 3 b 的引用计数: 3 test 函数执行结束 对象销毁 对象销毁
注意:
- 在进行标记-清除时,Python 会暂停整个程序的执行(Stop The World),因为需要根据对象之间的引用关系来判断可达性
- Python 首先通过引用计数机制实时回收不再被引用的对象(始终工作)。对于引用计数无法处理的循环引用对象,则依赖垃圾回收器(gc 模块)使用标记-清除算法定期扫描并回收(条件触发)
5. 分代机制
常规的 “标记-清除” 算法会遍历所有对象进行可达性分析和清理,当对象数量很多时代价非常高。为了优化性能,Python 引入了分代垃圾回收机制,将对象按生命周期长短分为三代:
- 第 0 代(新生代):高频率扫描的对象
- 第 1 代(中生代):中频率扫描的对象
- 第 2 代(老年代):低频率扫描的对象
注意:
- 分代回收时通常只处理特定的一代,从而避免频繁扫描整个内存,提高 GC 效率
- 如果触发 1 代对象扫描,则会对 0 代和 1 代进行扫描;如果触发 2 代扫描,则进行全对象扫描
它的工作流程如下:
- 新创建的对象默认被放入第 0 代,当新增对象数量 ≥ 700 时,触发 0 代垃圾回收
- 如果对象在第 0 代回收后仍然存活(即没被回收掉),就会被晋升到第 1 代
- 当第 0 代对象进行 10 回收后,触发 1 次 1 代回收
- 当第 1 代中的对象经过回收后仍存活,会晋升到第2代
- 当第 1 代对象进行 10 回收后,触发 1 次 2 代回收
通过这种分代机制,可以避免每次扫描时对象的数量,提高 GC 的效率。
import gc if __name__ == '__main__': # (700, 10, 10) # 第 0 代阈值(700):当 新增对象数量 ≥ 700 时,触发 0 代垃圾回收。 # 第 1 代阈值(10):当 0 代回收发生 10 次 后,触发一次 1 代回收。 # 第 2 代阈值(10):当 1 代回收发生 10 次 后,触发一次 2 代回收。 print(gc.get_threshold()) # 修改 GC 触发的阈值 # gc.set_threshold(1000, 15, 5)
6. 内容总结
Python 垃圾回收机制是一种自动内存管理技术,用于检测和清理不再使用的对象,防止内存泄漏。该机制主要基于引用计数,标记-清除、分代回收机制。
Python 垃圾对象是指不再被任何变量引用的对象,即引用计数为 0 的对象。引用计数是 Python 最基础的垃圾回收方式,每个对象内部维护一个计数器,记录当前被引用的次数。当引用计数归零时,对象会被立即回收。
然而,引用计数存在循环引用问题,即两个或多个对象互相引用,导致它们的引用计数始终 ≥1,无法被回收。为了解决这个问题,Python 引入了标记清除(Mark-and-Sweep)算法,该算法可以检测并清理循环引用的垃圾对象,但缺点是全局扫描开销较大。
为了减少 GC 的扫描开销,Python 采用了分代回收,该方法将对象按存活时间分为三代,在不同的时机触发对不同代的对象扫描,减少了对长期存活对象的扫描次数,提高 GC 效率。