Skip to content

Python 垃圾回收机制

Python GC 原理

简介

  • 引用计数 (python 默认):记录该对象当前被引用的次数,每当新的引用指向该对象时,它的引用计数 ob_ref 加 1 ,每当该对象的引用失效时计数 ob_ref 减 1,一旦对象的引用计数为 0,该对象立即被回收
  • 标记清除:第一段给所有活动对象标记,第二段清除非活动对象
  • 分代回收: python 将内存根据对象的存活时间划分为不同的集合,每个集合称为一个代,比如有年轻代、中年代、老年代,年轻代最先被回收

GC 作为现代编程语言的自动内存管理机制,专注于两件事:

  1. 找到内存中无用的垃圾资源
  2. 清除这些垃圾并把内存让出来给其他对象使用。

引用计数

Python 语言默认采用的垃圾收集机制是『引用计数法 Reference Counting』,该算法最早 George E.Collins 在 1960 的时候首次提出,50 年后的今天,该算法依然被很多编程语言使用,『引用计数法』的原理是:每个对象维护一个 ob_ref 字段,用来记录该对象当前被引用的次数,每当新的引用指向该对象时,它的引用计数 ob_ref 加 1,每当该对象的引用失效时计数 ob_ref 减 1,一旦对象的引用计数为 0,该对象立即被回收,对象占用的内存空间将被释放。它的缺点是需要额外的空间维护引用计数,这个问题是其次的,不过最主要的问题是它不能解决对象的“循环引用”,因此,也有很多语言比如 Java 并没有采用该算法做来垃圾的收集机制。

我们先看一个例子

python
class Person:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f'{self.name} 即将被删除')


person = Person('正心')
# print(person)
person_back = person
input('输入任意内容删除 person')
del person
input('输入任意内容结束程序')

# 程序结束的时候会释放所有的内容
# __del__ 是为了理解垃圾回收机制

什么是循环引用 ?A和B相互引用而再没有外部引用 A 与 B 中的任何一个,它们的引用计数虽然都为 1,但显然应该被回收,例子:

python
a = {}  # 对象 A 的引用计数为 1
b = {}  # 对象 B 的引用计数为 1
a['b'] = b  # B的引用计数增1  
b['a'] = a  # A的引用计数增1  
del a  # A的引用减 1,最后A对象的引用为 1
del b  # B的引用减 1, 最后B对象的引用为 1

在这个例子中程序执行完 del 语句后,A、B 对象已经没有任何引用指向这两个对象,但是这两个对象各包含一个对方对象的引用,虽然最后两个对象都无法通过其它变量来引用这两个对象了,这对GC来说就是两个非活动对象或者说是垃圾对象,但是他们的引用计数并没有减少到零。因此如果是使用引用计数法来管理这两对象的话,他们并不会被回收,它会一直驻留在内存中,就会造成了内存泄漏(内存空间在使用完毕后未释放)。为了解决对象的循环引用问题,Python 引入了标记-清除和分代回收两种 GC 机制。

标记清除

『标记清除(Mark—Sweep)』算法是一种基于追踪回收(tracing GC)技术实现的垃圾回收算法。 它分为两个阶段:

  1. 第一阶段是标记阶段,GC会把所有的『活动对象』打上标记
  2. 第二阶段是把那些没有标记的对象『非活动对象』进行回收。

那么 GC 又是如何判断哪些是活动对象哪些是非活动对象的呢 ?

对象之间通过引用(指针)连在一起,构成一个有向图,对象构成这个有向图的节点,而引用关系构成这个有向图的边。 从根对象(root object)出发,沿着有向边遍历对象,可达的(reachable)对象标记为活动对象,不可达的对象就是要被清除的非活动对象。 根对象就是全局变量、调用栈、寄存器。

img.png

在上图中,我们把小黑圈视为全局变量,也就是把它作为 root object,从小黑圈出发,对象 1 可直达,那么它将被标记,对象 2、3 可间接到达也会被标记,而 4 和 5 不可达,那么 1、2、3 就是活动对象,4 和 5 是非活动对象会被 GC 回收。

标记清除算法作为 Python 的辅助垃圾收集技术主要处理的是一些容器对象,比如 list、dict、tuple,instance 等,因为对于字符串、数值对象是不可能造成循环引用问题。Python 使用一个双向链表将这些容器对象组织起来。不过,这种简单粗暴的标记清除算法也有明显的缺点:清除非活动的对象前它必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。

分代回收

分代回收是一种以空间换时间的操作方式,Python 将内存根据对象的存活时间划分为不同的集合,每个集合称为一个代,Python 将内存分为了 3 “代”,分别为年轻代(第 0 代)、中年代(第 1 代)、老年代(第 2 代),他们对应的是 3 个链表,它们的垃圾收集频率与对象的存活时间的增大而减小。新创建的对象都会分配在年轻代,年轻代链表的总数达到上限时,Python 垃圾收集机制就会被触发,把那些可以被回收的对象回收掉,而那些不会回收的对象就会被移到中年代去,依此类推,老年代中的对象是存活时间最久的对象,甚至是存活于整个系统的生命周期内。同时,分代回收是建立在标记清除技术基础之上。分代回收同样作为 Python 的辅助垃圾收集技术处理那些容器对象

引用计数和对象销毁

CPython 的实现中,对象会包括一个引用计数器。当对象被赋值给一个变量时,这个计数器会递增;当变量被删除时,这个计数器会递减。当引用计数器的值为0时,表示我们的程序不再需要这个对象并且可以销毁这个对象。对于简单对象,当执行删除对象的操作时会调用 __del__() 方法。

对于包含循环引用的复杂对象,引用计数器有可能永远也不会归零,这样就很难让 __del__() 被调用。

我们用下面的一个类来看看这个过程中到底发生了什么。

python
class RectAngle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def __del__(self):
        print("被删除 {0}".format(id(self)))

我们可以像下面这样创建和删除这个对象。

python
>>> p = RectAngle(4, 5)
>>> del p
被删除
2643407508256

我们先创建,然后删除了Noisy 对象,几乎是立刻就看到了__del__() 方法中输出的消息。这也就是说当变量x 被删除后,引用计数器正确地归零了。一旦变量被删除,就没有任何地方引用Noisy 实例,所以它也可以被清除。

下面是浅复制中一种常见的情形。

python
>>> ln = [RectAngle(4, 5), RectAngle(4, 6)]
>>> ln2= ln.copy()
>>> del ln
>>>

Python没有响应 del 语句。这说明这些 Noisy 对象的引用计数器还没有归零,肯定还有其他地方引用了它们,下面的代码验证了这一点。

python
>>> del ln2
被删除 2643407508592
被删除 2643407508480

循环引用和垃圾回收

下面是一种常见的循环引用的情形。一个父类包含一个子类的集合,同时集合中的每个子类实例又包含父类的引用。

下面我们用这两个类来看看循环引用。

python
class Parent:
    def __init__(self, *children):
        self.children = list(children)
        for child in self.children:
            child.parent = self

    def __del__(self):
        print("删除 {} {}".format(self.__class__.__name__, id(self)))


class Child:
    def __del__(self):
        print("删除 {} {}".format(self.__class__.__name__, id(self)))

一个Parentinstance 包括一个children 的列表。

每一个Child 的实例都有一个指向Parent 类的引用。当向Parent 内部的集合中插入新的Child 实例时,这个引用就会被创建。

我们故意把这两个类写得比较复杂,所以下面让我们看看当试图删除对象时,会发生什么。

python
>>> p = Parent(Child(), Child())
>>> id(p)
2643407508984
>>> del p
>>>

Parent 和它的两个初始Child 实例都不能被删除,因为它们之间互相引用。

下面,我们创建一个没有Child 集合的Parent 实例。

python
>>> p = Parent()
>>> id(p)
2643407509096
>>> del p
删除
Parent
2643407509096
>>>

和我们预期的一样,这个Parent 实例成功地被删除了。

许多基本的特殊方法,它们是我们在设计任何类时的基本特性。这些方法已经包含在每个类中,只是它们的默认行为不一定能满足我们的需求。

我们几乎总是需要重载__repr__()__str__() 。这些方法的默认实现不是非常有用。

我们几乎不需要重载__bool__() 方法,除非我们想自定义集合。这是第6章“创建容器和集合”的主题。

我们常常需要重载比较运算符。默认的实现只适合于比较简单不可变对象,但是不适用于比较可变对象。我们不一定要重写所有的比较运算符

另外两个较为特殊的方法__new__()__del__() 有更特殊的用途。大多数情况下,使用__new__() 来扩展不可变类型。

基本的特殊方法和__init__() 方法几乎会出现在我们定义的所有类中。其他的特殊方法则有更特殊的用途,它们分为6个不同的类别。

  • 属性访问 :这些特殊方法实现的是表达式中object.attribute 的部分,它通常用在一个赋值语句的左操作数以及del 语句中。
  • 可调用对象 :一个实现了将函数作为参数的特殊方法,很像内置的len() 函数。
  • 集合 :这些特殊方法实现了集合的很多特性,包括sequence[index]mapping[index]set | set
  • 数字 :这些特殊方法提供了算术运算符和比较运算符。我们可以用这些方法扩展Python支持的数值类型。
  • 上下文 :有两个特殊方法被我们用来实现可以和with 语句一起使用的上下文管理器。
  • 迭代器 :有一些特殊方法定义了一个迭代器。没有必要一定要使用这些方法,因为生成器函数很好地实现了这种特性。但是,我们可以了解如何实现自定义的迭代器。

案例

python
class Person:
    def __init__(self, name):
        self.name = name

    def __del__(self):
        print(f'{self.name} 即将被删除')


arr = []


def create_person():
    person = Person('正心')
    # 没有删除 person
    input('输入任意内容结束函数')
    # return person
    arr.append(person)


# arg = create_person()
create_person()
del arr
input('输入任意内容结束程序')