浅析Python的垃圾回收机制(Garbage Collection)

 

概要

Python的垃圾回收主要靠的是三板斧,概括地说,就是利用引用计数(Reference Counting)机制来跟踪和回收垃圾;利用标记-清除(Mark-and-Sweep)机制来解决(引用计数机制所不能解决的)容器对象可能产生的循环引用问题;最后再通过分代回收(Generation Collection)机制,以空间换取时间的方法,来提高垃圾回收的效率。

1. 引用计数机制(Reference Counting)

在Python中,万物皆对象。每当一个对象的引用被创建出来时,那么这个对象的引用计数便加1;而每当有一个对象的引用被销毁时,这个对象的引用计数便减1;当一个对象的引用计数为0的时候,说明没有人在使用该对象了,因此,Python会立刻释放掉这个对象所占的内存空间。

下面我们将用一个例子来展示Python的引用计数机制,但在展示这个例子之前,我们需要一个小小的辅助函数

一个小小的辅助函数

为了便于查看某个对象的引用计数,我编写了一个函数,它接受一个内存地址作为输入参数,返回该内存地址被引用的次数(返回类型为ctypes.c_long):

def get_refcount(address):
    import ctypes
    return ctypes.c_long.from_address(address)

使用方法如下:

a = 'Hello'
a_address = id(a)
print(get_refcount(a_address))  # 输出:c_long(1),表明'Hello'仅被引用一次。

注:在上面的函数get_refcount中,我之所以使用内存地址而非变量名作为输入参数,是因为变量名(如:此处的a)只是对象的一个引用,并不是对象本身。对象是存在于内存地址对应的单元中的。所以,此处我们使用内存地址来表示一个对象。

一个例子

p = ['Hello', 'World']
p_address = id(p)
print(get_refcount(p_address))  # 输出:c_long(1)

r = q = p
print(get_refcount(p_address))  # 输出:c_long(3)

r = None
print(get_refcount(p_address))  # 输出:c_long(2)

q = None
p = None
print(get_refcount(p_address))  # 输出:c_long(0)

当我们创建列表['Hello', 'World']的时候,同时也创建了一个引用p来指向这个列表对象,因此这个列表对象的引用计数为1;当我们将引用p的值分别赋给rq的时候,又有两个引用指向了列表对象,因此此时,列表对象的引用计数为3;当我们将引用r指向None的时候,列表对象失去了一个引用,因此此时列表对象的引用计数为2;当我们将qp两个引用都不再指向列表对象的时候,列表对象的引用计数就成了0,此时,Python会立即自动将其所占的内存单元释放掉。

2. 标记-清除机制(Mark-and-Sweep)

问题引入

当我们熟悉了Python的引用计数机制以后,可能会留意到它的一些缺点,其中最致命的一个缺点就是它无法解决容器对象可能存在的循环引用问题,如下:

l1, l2 = {}, {}
l1_address, l2_address = id(l1), id(l2)
print(get_refcount(l1_address))  # 输出:c_long(1)
print(get_refcount(l2_address))  # 输出:c_long(1)

l1['l2'] = l2
print(get_refcount(l1_address))  # 输出:c_long(1)
print(get_refcount(l2_address))  # 输出:c_long(2)

l2['l1'] = l1
print(get_refcount(l1_address))  # 输出:c_long(2)
print(get_refcount(l2_address))  # 输出:c_long(2)

l1, l2 = 'Hello', 'World'
print(get_refcount(l1_address))  # 输出:c_long(1)
print(get_refcount(l2_address))  # 输出:c_long(1)

我们创建了两个空字典,其引用分别为l1l2。然后,我们又分别让l1l2互相引用。再然后,当我们分别将两个字典对象的引用l1l2清除时,通过输出我们发现,两个字典对象的引用计数仍然不为0,而不为0的原因就是两个字典对象间存在互相引用。

此处,我们通过给l1l2赋予新值的方式清除它们对原字典对象的引用,'Hello''World'这两个字符串可以用任何其他值来替换,这里只是随便举了一个例子,你也可以将它们都替换成None,效果是一样的。

因此,虽然已经没有任何外部引用指向两个字典对象,但是由于它们之间存在的互相引用关系,其引用计数均不为0,其所在的内存空间也无法被释放掉,这是一个很严重的问题。为了解决这个问题,Python便在引用计数的基础上,又引入了标记-清除机制。

标记-清除

所谓标记-清除,就是先标记,再清除。标记的是活动对象,清除的是非活动对象。
首先,为了追踪容器对象的引用情况,每个容器对象需要维护两个额外的指针,指针分别指向前后两个容器对象,所有容器对象便组成了一个双向链表,或者,我们也可以将其视为一个有向图。其中,容器对象是有向图的节点,而引用关系是有向图的边。从根对象(root objects)出发,沿着有向边遍历对象,可达的(reachable)对象标记为活动对象,不可达的对象(unreachable objects)就是要被清除的非活动对象。

注1:标记-清除机制将集合中对象的引用计数复制一份副本,改动该对象引用的副本。由于它并不改动真实的引用计数,因此不会影响到对象生命周期的维护。这个计数副本的唯一作用便是寻找可达对象(reachable objects)的集合。

注2:“可达”既包含了直接可达,也包含了间接可达。

注3:根对象(root objects)指的是从堆内存外部可以直接访问到的对象。更多信息参见此处:Quora链接

3. 分代回收机制(Generation Collection)

问题引入

标记-清除机制有一个很明显的缺点,就是效率低下。在清除掉非活动的对象前,必须对整个在使用中的内存进行扫描,哪怕只有小部分对象属于活动对象,也必须扫描所有对象。而且,这个标记-清除的过程不能被打断,一旦被打断就要重新开始,从头再来。
因此,为了提高垃圾收集的效率,我们可以采用“以空间换时间的策略”,于是便有了分代回收分代回收其实并不是一个“新机制”,只是对上述的标记-清除机制的一个改进。

分代回收

分代回收就是把对象分成好几代来进行垃圾回收,在Python中,这个数字是,也就是说,对象被分成了三代来进行垃圾回收。我们可以分别将这三代命名为:菜鸟,普通人,高手。一开始,对象在刚被创建的时候,放在一代中(此时它只是一个菜鸟),如果在一次一代的垃圾检查中,该对象存活下来,就会被放到二代中(此时,菜鸟升级成了普通人),同样,如果在一次二代的垃圾检查中,该对象存活下来,就会被放到三代中(此时,普通人升级成了高手)。

通过Python提供的gc模块,我们可以获取很多关于垃圾回收的有用信息:

import gc
print(gc.get_count())  # 该函数获取当前自动执行垃圾回收的计数器,返回一个长度为3的元组。
print(gc.get_threshold())  # 该函数获取当前垃圾回收计数器的阈值,默认返回为(700, 10, 10),其中700为一代的阈值,10为二代和三代的阈值。

在我的电脑上,函数gc.get_count()返回的元组是(159, 3, 0)
其中,159代表距离上一次一代垃圾检查,Python分配内存的数目减去回收内存的数目;3是指距离上一次二代垃圾检查,一代垃圾检查的次数;0是指距离上一次三代垃圾检查,二代垃圾检查的次数。
在Python中,三代对象触发垃圾回收的阈值分别为700,10,10;
由此可知:
当计数器从(699,3,0)增加到(700,3,0),gc模块就会执行gc.collect(0),即检查一代对象的垃圾,并重置计数器为(0,4,0)
当计数器从(699,9,0)增加到(700,9,0),gc模块就会执行gc.collect(1),即检查一、二代对象的垃圾,并重置计数器为(0,0,1)
当计数器从(699,9,9)增加到(700,9,9),gc模块就会执行gc.collect(2),即检查一、二、三代对象的垃圾,并重置计数器为(0,0,0)
某一代的计数值,只有在达到了对应阈值之后,该代以及该代的前面几代(如果有的话)的垃圾回收才会被触发,并且计数器会按照以上规则重置。

注1:这三代,其实在计算机内部实现的时候就是三个双向链表。

注2:通过这种方法,你的代码中长期被使用的对象,会从一代链表转移到二代再转移到三代,从“菜鸟”一步步成长为“高手”。

注3:Python处理一代最为频繁,其次是二代,然后是三代。

弱代假说(Weak Generational Hypothesis)

你可能在想,为什么对象在一次垃圾检查中存活下来,就要被放在下一代的对象中呢?
答案就是基于“弱代假说”。所谓的弱代假说,一句话概括就是:通常情况下,越年轻的对象越容易死掉,越老的对象存活得越久
稍加思考,就会发现其实这个假说是很有道理的。因为如果一个对象在第一次垃圾回收中没有被回收掉,很大可能性就是这个对象并不是一个为临时用途创建的对象。于是我们将其加入下一代的对象,就可以节省垃圾回收的时间。(下一代的对象被访问到的频率远低于上一代,而且下一代的对象相比于上一代对象,是“垃圾”的可能性很小,所以我们没必要浪费太多时间来考察这些对象。)

垃圾检查(或回收)的触发时机

上面的“标记-清除”的触发时机和分代回收的触发时机是类似的,所以我只在此一处阐述。
在垃圾回收过程中,是有一个阈值存在的,一旦这个阈值达到了,垃圾回收便会被触发。 在程序运行的过程中,Python解释器可能会不断地创建新的对象从而分配内存,释放不再被使用的对象从而回收内存,从理论上说,分配内存的数目和回收内存的数目应该差不多,因为程序创建的每个对象都应该最终被释放掉。但实际上,由于循环引用的存在,以及一些非循环引用但生命周期非常长的对象的存在,这两个数目的差值会越来越大。一旦这个差值(分配内存数目 - 回收内存数目)累计超过了某个阈值,那么Python的垃圾回收就被触发了。

参考

本文的撰写参考了以下几篇博文:
Visualizing Garbage Collection in Ruby and Python
Generational GC in Python and Ruby
Python垃圾回收(GC)三层心法,你了解到第几层?
3.1.1. Mark-sweep collection
Python垃圾回收机制详解