Python 垃圾回收机制
Python 垃圾回收机制
垃圾回收机制(Garbage Collection,简称 GC)是一种自动的内存管理机制,有许多不同的实现算法,Python 的 GC,是以引用计数
为主,标记-清除
和分代回收
为辅。
0 内存泄漏
1、定义
- 这里的泄漏,并不是说你的内存出现了信息安全问题,被恶意程序利用了,而是指程序本身没有设计好,导致程序未能释放已不再使用的内存。
- 内存泄漏也不是指你的内存在物理上消失了,而是意味着代码在分配了某段内存后,因为设计错误,失去了对这段内存的控制,从而造成了内存的浪费。
2、原因
- 所用到的用 C 语言开发的底层模块中出现了内存泄露。
- 代码中用到了全局的
list
、dict
或其它容器,不停的往这些容器中插入对象,而忘记了在使用完之后进行删除回收。 - 代码中有“引用循环”,并且被循环引用的对象定义了
__del__
方法,就会发生内存泄露,因为gc.collect()
方法默认不对重载了__del__
方法的循环引用对象进行回收。
3、内存泄漏与内存溢出的区别
- 内存溢出是指申请内存空间时没有足够的可用内存了,就会抛出 OOM,即内存溢出。
- 内存泄漏是指申请了一块内存空间,使用完后没有释放,由于没有释放,这块内存区域其他类加载的时候无法申请。
1 GC 定义
对象所占用的内存在对象不再使用后会自动被回收。这些工作是由一个叫垃圾回收器(Garbage Collector)的进程完成的。GC 本质上做了三件事情:1. 为新生对象分配内存;2. 垃圾检测;3. 垃圾回收。
2 Python GC 机制
Python 中的垃圾回收是以引用计数
为主,标记-清除
和分代回收
为辅。引用计数最大缺陷就是循环引用
的问题,所以 Python 采用了辅助方法。
注意:
- 垃圾回收时,Python 不能进行其它的任务,频繁的垃圾回收将大大降低 Python 的工作效率;
- Python 只会在特定条件下,自动启动垃圾回收(垃圾对象少就没必要回收);
- 当 Python 运行时,会记录其中分配对象(object allocation)和取消分配对象(object deallocation)的次数。当两者的差值高于某个阈值时,垃圾回收才会启动。
1、引用计数(Reference Counting)
Python 内部使用引用计数,来保持追踪内存中的对象,所有对象都有引用计数。
在 Python 中,每一个对象的核心就是一个结构体 PyObject,它的内部有一个引用计数器 ob_refcnt:
1 |
|
当一个对象有新的引用时,它的 ob_refcnt 就会增加,当引用它的对象被删除,它的 ob_refcnt 就会减少。当引用计数为 0 时,该对象生命就结束了。
引用计数增加的情况:
- 对象被创建:
x = 'spam'
- 用另一个别名被创建:
y = x
- 被作为参数传递给函数:
foo(x)
- 作为容器对象的一个元素:
a = [1, x, '33']
引用计数减少情况
- 一个本地引用离开了它的作用域。比如上面的 foo(x) 函数结束时,x 指向的对象引用减 1。
- 对象的别名被显式的销毁:
del x
或del y
- 对象的一个别名被赋值给其他对象:
x = 789
- 对象从一个窗口对象中移除:
myList.remove(x)
- 窗口对象本身被销毁:
del myList
,或者窗口对象本身离开了作用域。
引用计数机制的优缺点是显而易见的:
优点:
- 简单;
- 实时性:一旦引用计数为0,立即被回收;内存回收的时间分摊到平时。
缺点:
- 需要额外的空间来维护引用计数;
- 执行效率低:引用计数机制所带来的维护引用计数的额外操作,与程序运行过程中所进行的内存分配、释放和引用赋值的次数成正比。
除了上面提到的,引用计数机制还有一个致命缺点,即无法解决循环引用
的问题。
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 |
|
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 其他
Python 中的
gc
模块提供了一些接口给开发者设置 GC 相关的选项。如果循环引用中,两个对象都定义了
__del__
方法,gc 模块不会销毁这两个不可达对象,因为 gc 模块不知道应该先调用哪个对象的__del__
方法(例如,两个对象 a 和 b,如果先销毁 a,则在销毁 b 时,会调用 b 的__del__
方法,该方法中很可能使用了 a,这时会造成异常),所以为了安全起见,gc 模块会把对象放到gc.garbage
中,并把它们称为 uncollectable。很明显,这种情况会造成内存泄漏,要解决的话,只能显式调用其中某个对象的__del__
方法来打破僵局。还有一种情况会造成 Python 中的内存泄漏,即对象一直被全局变量所引用,而我们知道,全局变量的生命周期是非常长的。