Python - 垃圾回收机制

Python 主要通过引用计数(Reference Counting)的方式进行垃圾回收。在引用计数的基础之上,辅助以”标记-清除”(Mark and Sweep)解决容器对象可能产生的循环引用的问题。还可以通过”分代回收”(Generation Collection)以空间换取时间进一步提高垃圾回收的效率。

引用计数

在 Python 中,大多数对象的生命周期都是通过引用计数来管理的。是一种最简单直观的垃圾回收机制。

引用计数机制:当一个对象被创建/引用/复制时,对象的引用计数加 1。当一个对象的引用被销毁时,引用计数减 1。当对象的引用计数减为 0 时,就意味着该对象已经没有被其他对象使用,其占用的内存可以被收回。看下面的例子,调用 gc.collect() 方法时,发现不起作用,可见垃圾收集对引用计数不起作用(只对循环引用起作用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> import gc
>>> import sys
>>> gc.set_debug(gc.DEBUG_STATS|gc.DEBUG_LEAK)
>>>
>>> a = []
>>> b = []
>>> a.append(b)
>>> del a
>>> del b
>>> gc.collect()
gc: collecting generation 2...
gc: objects in each generation: 4 0 4118
gc: done, 0.0007s elapsed.
0

引用计数在每次分配和释放内存的释放加入管理引用计数的动作,而且随着内存分配存量增加,引用计数的管理复杂度也随着线型增加。更大的问题是,当容器类型的对象互相之间有循环引用(交叉引用)时,引用计数无法将互相引用的两个对象的内存释放。我们看下面的例子:

1
2
3
4
5
6
7
8
9
10
>>> a = []
>>> b = []
>>> a.append(b)
>>> b.append(a)
>>> a
>>> [[[...]]]
>>> b
>>> [[[...]]]
>>> del a
>>> del b

为了解决这个问题,Python 引入了其他两种垃圾回收机制辅助解决引用计数的这一缺陷:”标记-清除” 和 “分代回收”两种收集技术。

标记-清除

通过 Python 的 gc 模块,我们看下使用”标记-清除”后,Python 可以回收互相引用的两个对象,可见垃圾收集循环引用起作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> import gc
>>> import sys
>>> gc.set_debug(gc.DEBUG_STATS|gc.DEBUG_LEAK)
>>> a = []
>>> b = []
>>> a.append(b)
>>> b.append(a)
>>> del a
>>> del b
>>> gc.collect()
gc: collecting generation 2...
gc: objects in each generation: 29 4093 0
gc: collectable <list 0x7f74408ba320>
gc: collectable <list 0x7f7440892dd0>
gc: done, 2 unreachable, 0 uncollectable, 0.0007s elapsed.
2

“标记-清除”是为了解决循环引用的问题。可以包含其他对象引用的容器对象(比如:list,set,dict,class,instance)都可能产生循环引用。

我们必须承认一个事实,如果两个对象的引用计数都为1,但是仅仅存在他们之间的循环引用,那么这两个对象都是需要被回收的,也就是说,它们的引用计数虽然表现为非0,但实际上有效的引用计数为0。我们必须先将循环引用摘掉,那么这两个对象的有效计数就现身了。假设两个对象为A、B,我们从A出发,因为它有一个对B的引用,则将B的引用计数减1;然后顺着引用达到B,因为B有一个对A的引用,同样将A的引用减1,这样,就完成了循环引用对象间环摘除。

但是这样就有一个问题,假设对象A有一个对象引用C,而C没有引用A,如果将C计数引用减1,而最后A并没有被回收,显然,我们错误的将C的引用计数减1,这将导致在未来的某个时刻出现一个对C的悬空引用。这就要求我们必须在A没有被删除的情况下复原C的引用计数,如果采用这样的方案,那么维护引用计数的复杂度将成倍增加。

原理:”标记-清除”采用了更好的做法,我们并不改动真实的引用计数,而是将集合中对象的引用计数复制一份副本,改动该对象引用的副本。对于副本做任何的改动,都不会影响到对象生命周期的维护。

这个计数副本的唯一作用是寻找root object集合(该集合中的对象是不能被回收的)。当成功寻找到root object集合之后,首先将现在的内存链表一分为二,一条链表中维护root object集合,成为root链表,而另外一条链表中维护剩下的对象,成为unreachable链表。之所以要剖成两个链表,是基于这样的一种考虑:现在的unreachable可能存在被root链表中的对象直接或间接引用的对象,这些对象是不能被回收的。一旦在标记的过程中,发现这样的对象,就将其从unreachable链表中移到root链表中;当完成标记后,unreachable链表中剩下的所有对象就是名副其实的垃圾对象了,接下来的垃圾回收只需限制在unreachable链表中即可。

寻找root object集合的算法留待补充…………

分代回收

原理:将系统中的所有内存块根据其存活时间划分为不同的集合,每一个集合就成为一个“代”,垃圾收集的频率随着“代”的存活时间的增大而减小。也就是说,活得越长的对象,就越不可能是垃圾,就应该减少对它的垃圾收集频率。那么如何来衡量这个存活时间:通常是利用几次垃圾收集动作来衡量,如果一个对象经过的垃圾收集次数越多,可以得出:该对象存活时间就越长。

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

分代回收同样作为Python的辅助垃圾收集技术处理那些容器对象。