Python 垃圾回收机制

Python 垃圾回收机制

垃圾回收机制(Garbage Collection,简称 GC)是一种自动的内存管理机制,有许多不同的实现算法,Python 的 GC,是以引用计数为主,标记-清除分代回收为辅。

0 内存泄漏

1、定义

  • 这里的泄漏,并不是说你的内存出现了信息安全问题,被恶意程序利用了,而是指程序本身没有设计好,导致程序未能释放已不再使用的内存。
  • 内存泄漏也不是指你的内存在物理上消失了,而是意味着代码在分配了某段内存后,因为设计错误,失去了对这段内存的控制,从而造成了内存的浪费。

2、原因

  • 所用到的用 C 语言开发的底层模块中出现了内存泄露。
  • 代码中用到了全局的 listdict 或其它容器,不停的往这些容器中插入对象,而忘记了在使用完之后进行删除回收。
  • 代码中有“引用循环”,并且被循环引用的对象定义了 __del__ 方法,就会发生内存泄露,因为 gc.collect() 方法默认不对重载了 __del__ 方法的循环引用对象进行回收。

3、内存泄漏与内存溢出的区别

  • 内存溢出是指申请内存空间时没有足够的可用内存了,就会抛出 OOM,即内存溢出。
  • 内存泄漏是指申请了一块内存空间,使用完后没有释放,由于没有释放,这块内存区域其他类加载的时候无法申请。

1 GC 定义

对象所占用的内存在对象不再使用后会自动被回收。这些工作是由一个叫垃圾回收器(Garbage Collector)的进程完成的。GC 本质上做了三件事情:1. 为新生对象分配内存;2. 垃圾检测;3. 垃圾回收。

2 Python GC 机制

Python 中的垃圾回收是以引用计数为主,标记-清除分代回收为辅。引用计数最大缺陷就是循环引用的问题,所以 Python 采用了辅助方法。

注意:

  1. 垃圾回收时,Python 不能进行其它的任务,频繁的垃圾回收将大大降低 Python 的工作效率;
  2. Python 只会在特定条件下,自动启动垃圾回收(垃圾对象少就没必要回收);
  3. 当 Python 运行时,会记录其中分配对象(object allocation)和取消分配对象(object deallocation)的次数。当两者的差值高于某个阈值时,垃圾回收才会启动。

1、引用计数(Reference Counting)

Python 内部使用引用计数,来保持追踪内存中的对象,所有对象都有引用计数。

在 Python 中,每一个对象的核心就是一个结构体 PyObject,它的内部有一个引用计数器 ob_refcnt

1
2
3
4
5
// object.h
struct _object {
Py_ssize_t ob_refcnt; # 引用计数值
struct PyTypeObject *ob_type;
} PyObject;

当一个对象有新的引用时,它的 ob_refcnt 就会增加,当引用它的对象被删除,它的 ob_refcnt 就会减少。当引用计数为 0 时,该对象生命就结束了。

引用计数增加的情况:

  1. 对象被创建:x = 'spam'
  2. 用另一个别名被创建:y = x
  3. 被作为参数传递给函数:foo(x)
  4. 作为容器对象的一个元素:a = [1, x, '33']

引用计数减少情况

  1. 一个本地引用离开了它的作用域。比如上面的 foo(x) 函数结束时,x 指向的对象引用减 1。
  2. 对象的别名被显式的销毁:del xdel y
  3. 对象的一个别名被赋值给其他对象:x = 789
  4. 对象从一个窗口对象中移除:myList.remove(x)
  5. 窗口对象本身被销毁:del myList,或者窗口对象本身离开了作用域。

引用计数机制的优缺点是显而易见的:

优点:

  1. 简单;
  2. 实时性:一旦引用计数为0,立即被回收;内存回收的时间分摊到平时。

缺点:

  1. 需要额外的空间来维护引用计数;
  2. 执行效率低:引用计数机制所带来的维护引用计数的额外操作,与程序运行过程中所进行的内存分配、释放和引用赋值的次数成正比。

除了上面提到的,引用计数机制还有一个致命缺点,即无法解决循环引用的问题。

1
2
3
4
5
6
a = [1, 2] # 对象[1, 2]的引用计数为1
b = [3, 4] # 对象[3, 4]的引用计数为1
a.append(b) # 对象[3, 4]的引用计数为2
b.append(a) # 对象[1, 2]的引用计数为2
del a # 对象[1, 2]的引用计数为1
del b # 对象[3, 4]的引用计数为1

上面的代码中,对象 [1, 2] 和 [3, 4] 已经没有了来自外界的引用,这意味着不会再有人使用它们(无法通过其它变量来引用这两个对象),但是它们彼此之间依然有相互的引用,因此引用计数均为 1,也就导致它们的内存永远不能被回收。

这一点是致命的,它与手动进行内存管理所产生的内存泄漏无异(因此,也有很多语言比如 Java 并没有采用引用计数来实现 GC)。为了弥补引用计数的缺陷,Python 中引入了其它的 GC 机制。

2、标记-清除(Mark-Sweep)

可以包含其它对象引用的容器对象,如 list、set、dict、class、instance,都可能产生循环引用,标记-清除可以解决这个问题。

标记-清除是一种基于追踪(Tracing)回收技术实现的垃圾回收算法。它分为两个阶段:第一阶段是标记,把所有的『活动对象』打上标记,第二阶段是回收,对那些没有标记的『非活动对象』进行回收。那么,如何区分活动对象和非活动对象呢?

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

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

标记-清除的过程实际比上面说的还要复杂一下,具体来讲,首先找到 root object 集合,然后在内存中建立两条链表,一条链表中维护 root object 集合,称为 root 链表,而另外一条链表中维护剩下的对象,称为 unreachable 链表。在标记的过程中,如果发现 unreachable 链表中存在被 root 链表中的对象,直接或间接引用的对象,就将其从 unreachable 链表中移到 root 链表中;当完成标记后,unreachable 链表中剩下的所有对象就是名副其实的垃圾对象了,接下来的垃圾回收只需限制在unreachable 链表中即可。

标记-清除算法在申请内存时,所有容器对象的头部又加上了 PyGC_Head 来实现标记-清除机制。任何一个 Python 对象都分为两部分:PyObject_HEAD + 对象本身数据,源码如下:

1
2
3
4
5
6
7
8
9
// objimpl.h
typedef union _gc_head {
struct {
union _gc_head *gc_next;
union _gc_head *gc_prev;
Py_ssize_t gc_refs;
} gc;
long double dummy; /* force worst-case alignment */
} PyGC_Head;

3、分代回收(Generation Collection)

分代回收是基于这样的一个统计事实,对于程序,存在一定比例的内存块的生存周期比较短;而剩下的内存块,生存周期会比较长,甚至会从程序开始一直持续到程序结束。生存期较短对象的比例通常在 80%~90% 之间。因此,简单地认为:对象存在时间越长,越可能不是垃圾,应该越少去收集。这样在执行标记-清除算法时可以有效减小遍历的对象数,从而提高垃圾回收的速度,是一种以空间换时间的方法策略

  • Python 将所有的对象分为 0,1,2 三代;
  • 所有的新建对象都是 0 代对象;
  • 当某一代对象经历过垃圾回收,依然存活,就被归入下一代对象。

根据弱代假说(即越年轻的对象越容易死掉,而老的对象通常会存活更久),新生的对象被放入第 0 代,如果该对象在第 0 代的一次 GC 中活了下来,那么它就被移动到第 1 代,类似地,如果某第 1 代对象在第 1 代的一次 GC 中活了下来,它就被移动到第 2 代。

当某一代中被分配的对象与被释放的对象之差达到某一阈值时,就会触发当前一代的 GC 扫描。当某一代被扫描时,比它年轻的一代也会被扫描,因此,第 2 代的 GC 扫描发生时,第 0,1 代的 GC 扫描也会发生,即为全代扫描。

从上面的叙述可以看出,老年代中的对象是存活时间最久的对象,甚至是存活于整个系统的生命周期内。

3 其他

  1. Python 中的gc模块提供了一些接口给开发者设置 GC 相关的选项。

  2. 如果循环引用中,两个对象都定义了__del__方法,gc 模块不会销毁这两个不可达对象,因为 gc 模块不知道应该先调用哪个对象的__del__方法(例如,两个对象 a 和 b,如果先销毁 a,则在销毁 b 时,会调用 b 的__del__方法,该方法中很可能使用了 a,这时会造成异常),所以为了安全起见,gc 模块会把对象放到gc.garbage中,并把它们称为 uncollectable。很明显,这种情况会造成内存泄漏,要解决的话,只能显式调用其中某个对象的__del__方法来打破僵局。

  3. 还有一种情况会造成 Python 中的内存泄漏,即对象一直被全局变量所引用,而我们知道,全局变量的生命周期是非常长的。

相关参考

  1. Python中的垃圾回收机制
  2. Python 垃圾回收机制
  3. 24 | 带你解析 Python 垃圾回收机制

Python 垃圾回收机制
https://codingcat.cc/coding/python-gc.html
作者
Kai Sun
发布于
2021年7月17日
许可协议