Python 垃圾回收机制(GC)

在 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 函数执行结束
对象销毁
对象销毁

注意:

  1. 在进行标记-清除时,Python 会暂停整个程序的执行(Stop The World),因为需要根据对象之间的引用关系来判断可达性
  2. Python 首先通过引用计数机制实时回收不再被引用的对象(始终工作)。对于引用计数无法处理的循环引用对象,则依赖垃圾回收器(gc 模块)使用标记-清除算法定期扫描并回收(条件触发)

5. 分代机制

常规的 “标记-清除” 算法会遍历所有对象进行可达性分析和清理,当对象数量很多时代价非常高。为了优化性能,Python 引入了分代垃圾回收机制,将对象按生命周期长短分为三代:

  • 第 0 代(新生代):高频率扫描的对象
  • 第 1 代(中生代):中频率扫描的对象
  • 第 2 代(老年代):低频率扫描的对象

注意:

  • 分代回收时通常只处理特定的一代,从而避免频繁扫描整个内存,提高 GC 效率
  • 如果触发 1 代对象扫描,则会对 0 代和 1 代进行扫描;如果触发 2 代扫描,则进行全对象扫描

它的工作流程如下:

  1. 新创建的对象默认被放入第 0 代,当新增对象数量 ≥ 700 时,触发 0 代垃圾回收
  2. 如果对象在第 0 代回收后仍然存活(即没被回收掉),就会被晋升到第 1 代
  3. 当第 0 代对象进行 10 回收后,触发 1 次 1 代回收
  4. 当第 1 代中的对象经过回收后仍存活,会晋升到第2代
  5. 当第 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 效率。

未经允许不得转载:一亩三分地 » Python 垃圾回收机制(GC)
评论 (0)

1 + 7 =