HackPython 致力于有趣有价值的编程教学
简介
Python 垃圾回收机制是很多 Python 岗位面试官喜欢提的一点????,虽然 Python 具有垃圾自动回收的机制,但在一些大型项目中有些资源是不能等到它自动回收的,而需要手动将使用完的资源回收释放,从而让程序尽可能的耗尽服务器的所有资源,这在游戏开发中很重要,服务器是需要成本的????。
Python 中垃圾回收机制 (Garbage Collection, GC) 主要使用「引用计数」进行垃圾回收,通过「标记 - 清理」解决「容器对象」产生循环引用的问题,在通过「分代回收」以空间换时间的方式来提高垃圾回收的效率。
下面分别从「引用计数」、「标记 - 清理」以及「分代回收」来讨论一下 Python 中的 GC。
引用计数
从 CPython 源码中,Python 对象的核心是 PyObject 这个结构体????,该结构体内存通过 ob_refcnt 实现变量的引用计数,PyObject 结构体如下:
typedef struct_object {
int ob_refcnt;
struct_typeobject *ob_type;
} PyObject;
程序在运行的过程中会实时的更新 ob_refcnt 的值,来反映引用当前对象的名称数量。当某对象的引用计数值为 0, 那么它的内存就会被立即释放掉,即被垃圾回收。
以下情况是导致引用计数加一的情况:
????1. 对象被创建,例如 a=2333 ????2. 对象被引用,b=a ????3. 对象被作为参数,传入到一个函数中 ????4. 对象作为一个元素,存储在容器中
下面的情况则会导致引用计数减一:
????1. 对象别名被显示销毁 del ????2. 对象别名被赋予新的对象 ????3. 一个对象离开他的作用域 ????4. 对象所在的容器被销毁或者是从容器中删除对象
可以通过 sys 包中的 getrefcount () 来获取一个名称所引用的对象当前的引用计数 (注意,这里 getrefcount () 本身会使得引用计数加一)
「引用计数」这种方式很容易从逻辑层面去理解,简单而言就是有人用旧留着,没人用就回收,但这种方式是比较耗费资源的,毕竟计数也需要占用内存,而且该方法无法解决「容器对象」循环引用的问题,如下:
a=[1,2] # 计数为 1
b=[2,3] # 计数为 1
a.append(b) # 计数为 2
b.append(a) # 计数为 2
DEL a # 计数为 1
DEL b # 计数为 1
循环引用导致变量计数永不为 0,造成引用计数无法将其删除。
标记 - 清除
Python 中使用标记 - 清除的方式来解决循环引用导致的问题。
只有容器对象才会产生循环引用的情况,比如列表、字典、用户自定义类的对象、元组等。而像数字,字符串这类简单类型不会出现循环引用。
「标记 - 清除」作为一种优化策略,对于只包含简单类型的元组也不在标记清除算法的考虑之列,简单来看,「标记 - 清除」算法在进行垃圾回收时分成了两步,分别是:
????A)标记阶段,遍历所有的对象,如果是可达的(reachable),也就是还有对象引用它,那么就标记该对象为可达;????B)清除阶段,再次遍历对象,如果发现某个对象没有标记为可达,则就将其回收。
下面看图来理解 标记 - 清除 ,图片出自 聊聊 Python 内存管理
在标记清除算法中,为了追踪容器对象,需要每个容器对象维护两个额外的指针,用来将容器对象组成一个双端链表,指针分别指向前后两个容器对象,方便插入和删除操作????。python 解释器 (Cpython) 维护了两个这样的双端链表,一个链表存放着需要被扫描的容器对象,称为 Object to Scan,另一个链表存放着临时不可达对象,称为 Unreachable。
上图中 link1,link2,link3 组成一个引用环,此外 link1 还被变量 A 引用,看图中 link1 被几个箭头指着就知道了,其中 refcount 记录当前对象的引用计数,而 gcref 在一开始,gcref 只是 refcount 的副本,所以 gcref 的初始值等于 refcount。
gc 启动的时候,会逐个遍历”Object to Scan” 链表中的容器对象,并且将当前对象所引用的所有对象的 gcref 减一????。这一步操作就相当于解除了循环引用对引用计数的影响。如 link4 是自己引用了自己造成了循环引用,此时 link4 的 gcref 为 0.
接着,gc 会再次扫描所有的容器对象,如果对象的 gcref 值为 0,且引用该对象的对象其 gcref 也为 0 ,那么这个对象就被标记为 GCTENTATIVELYUNREACHABLE,并且被移至”Unreachable” 链表中????。下图中的 link3 和 link4 就是这样一种情况。
如果对象的 gcref 不为 0,那么这个对象就会被标记为 GCREACHABLE????。同时当 gc 发现有一个节点是可达的,那么他会递归式的将从该节点出发可以到达的所有节点标记为 GCREACHABLE, 这就是下图中 link2 和 link3 所碰到的情形????。除了将所有可达节点标记为 GCREACHABLE 之外,如果该节点当前在”Unreachable” 链表中的话,还需要将其移回到”Object to Scan” 链表中,下图就是 link3 移回之后的情形。
第二次遍历的所有对象都遍历完成之后,存在于”Unreachable” 链表中的对象就是真正需要被释放的对象。如上图所示,此时 link4 存在于 Unreachable 链表中,gc 随即释放之。
上面描述的垃圾回收的阶段,会暂停整个应用程序,等待标记清除结束后才会恢复应用程序的运行????。
分代回收
引用计数 + 标记 - 清除 的方式实现了 Python 垃圾回收,但整个过程比较慢,而且在 标记 - 清除 过程中还需要暂停整个程序,为了减少应用程序暂停使用,Python 利用分代回收 (Generational Collection) 以空间换时间的方式来提高垃圾回收效率????。
分代回收是基于这样的一个统计事实,对于程序,存在一定比例的内存块的生存周期比较短;而剩下的内存块,生存周期会比较长,甚至会从程序开始一直持续到程序结束。生存期较短对象的比例通常在 80%~90% 之间,这种思想简单点说就是:对象存在时间越长,越可能不是垃圾,应该越少去收集????。这样在执行标记 - 清除算法时可以有效减小遍历的对象数,从而提高垃圾回收的速度。
python gc 给对象定义了三种世代 (0,1,2), 每一个新生对象在 generation zero 中,如果它在一轮 gc 扫描中活了下来,那么它将被移至 generation one, 在那里他将较少的被扫描,如果它又活过了一轮 gc, 它又将被移至 generation two,在那里它被扫描的次数将会更少????。
当某一世代被分配的对象与被释放的对象之差达到某一阈值的时候,就会触发 gc 对某一世代的扫描。值得注意的是当某一世代的扫描被触发的时候,比该世代年轻的世代也会被扫描????。也就是说如果世代 2 的 gc 扫描被触发了,那么世代 0, 世代 1 也将被扫描,如果世代 1 的 gc 扫描被触发,世代 0 也会被扫描。
该阈值可以通过下面两个函数查看和调整:
import gc
gc.get_threshold() # (threshold0, threshold1, threshold2).
gc.set_threshold(threshold0[, threshold1[, threshold2]])
gc 会记录自从上次收集以来新分配的对象数量与释放的对象数量,当两者之差超过 threshold0 的值时,gc 的扫描就会启动,初始的时候只有世代 0 被检查。如果自从世代 1 最近一次被检查以来,世代 0 被检查超过 threshold1 次,那么对世代 1 的检查将被触发。相同的,如果自从世代 2 最近一次被检查以来,世代 1 被检查超过 threshold2 次,那么对世代 2 的检查将被触发。
结尾
本节中简单的讨论了 Python 中的垃圾回收机制,那是否有某些手段可以比较直观的看出当前项目中 Python GC 的使用情况,从而可以直观的判断项目对内存的使用是否合理呢?这些内容会尝试在浅析「垃圾回收机制」下篇中讨论????,最后欢迎学习 HackPython 的教学课程并感觉您的阅读与支持。
????????