python垃圾回收机制为什么标记能解决循环引用问题_Python 进阶:浅析「垃圾回收机制」(上篇)...

HackPython 致力于有趣有价值的编程教学

简介

Python 垃圾回收机制是很多 Python 岗位面试官喜欢提的一点🙃,虽然 Python 具有垃圾自动回收的机制,但在一些大型项目中有些资源是不能等到它自动回收的,而需要手动将使用完的资源回收释放,从而让程序尽可能的耗尽服务器的所有资源,这在游戏开发中很重要,服务器是需要成本的🤨。

Python 中垃圾回收机制 (Garbage Collection, GC) 主要使用「引用计数」进行垃圾回收,通过「标记 - 清理」解决「容器对象」产生循环引用的问题,在通过「分代回收」以空间换时间的方式来提高垃圾回收的效率。

下面分别从「引用计数」、「标记 - 清理」以及「分代回收」来讨论一下 Python 中的 GC。

引用计数

从 CPython 源码中,Python 对象的核心是 PyObject 这个结构体😗,该结构体内存通过 ob_refcnt 实现变量的引用计数,PyObject 结构体如下:

typedefstruct_object{

intob_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 也会被扫描。

该阈值可以通过下面两个函数查看和调整:

importgc

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 的使用情况,从而可以直观的判断项目对内存的使用是否合理呢?这些内容会尝试在浅析「垃圾回收机制」下篇中讨论😏。

推荐阅读

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值