Python 对象的垃圾回收详解

Python 垃圾回收器官方文档: https://docs.python.org/zh-cn/3.8/library/gc.html?highlight=gc#module-gc

在 Python 中,每当一个变量名被赋一个新的对象,如果原来的对象没有被其他的变量名或对象所引用的话,那么之前的那个对象占用的空间就会被回收。这种自动回收对象空间的技术叫作垃圾回收。

a = 3
a = 'spam'

例如上面的代码,开始时 a = 3, 当它被赋值为'spam'时, 对象 3 马上被回收(假设没被其它对象引用)。

Python 是这样来实现这一功能的:它在每个对象中保留了一个计数器,计数器记录当前指向该对象的引用的数目 。一旦(并精确在同一时间)这个计数器被设置为零,这个对象的内存空间就会自动回收。

我们从三个方面来详解一下 Python 的垃圾回收机制。

一,引用计数

Python垃圾回收主要以引用计数为主,分代回收为辅。引用计数法的原理是每个对象维护一个 ob_ref,用来记录当前对象被引用的次数,也就是来追踪到底有多少引用指向了这个对象,当发生以下四种情况的时候,该对象的引用计数器 +1

1. 对象被创建    a=14

2. 对象被引用    b=a

3. 对象被作为参数,传到函数中    func(a)

4. 对象作为一个元素,存储在容器中: List={a,"a","b",2}

与上述情况相对应,当发生以下四种情况时,该对象的引用计数器 -1

1. 当该对象的别名被显式销毁时,del a

2. 当该对象的引别名被赋予新对象  a=26

3. 一个对象离开它的作域,例如 func 函数执行完毕时,函数里面的局部变量的引用计数器就会减一(但是全局变量不会)

4. 将该元素从容器中删除时,或者容器被销毁时。

当指向该对象的内存的引用计数器为0的时候,该内存将会被Python虚拟机销毁。

下面来补充一下它的源码分析:

Python里面每一个东西都是对象,他们的核心是一个结构体Py_Object,所有Python对象的头部包含了这样一个结构PyObject

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

看一个比较具体点的例子,int型对象的定义:

// intobject.h
typedef struct {
    PyObject_HEAD
    long ob_ival;
} PyIntObject;

简而言之,PyObject是每个对象必有的内容,其中ob_refcnt就是做为引用计数。当一个对象有新的引用时,它的ob_refcnt就会增加,当引用它的对象被删除,它的ob_refcnt就会减少。当引用计数为0时,该对象生命就结束了。

#define Py_INCREF(op)   ((op)->ob_refcnt++) //增加计数
#define Py_DECREF(op) \ //减少计数
    if (--(op)->ob_refcnt != 0) \
        ; \
    else \
        __Py_Dealloc((PyObject *)(op))

引用计数法有很明显的优点:

1. 高效

2. 运行期没有停顿,实时性:一旦没有引用,内存就直接释放了。不用像其他机制等到特定时机。实时性还带来一个好处:处理回收内存的时间分摊到了平时。

3. 对象有确定的生命周期

4. 易于实现

原始的引用计数法也有明显的缺点:

1. 维护引用计数消耗资源,维护引用计数的次数和引用赋值成正比, 而不像mark and sweep等基本与回收的内存数量有关。

2. 无法解决循环引用的问题。A和B相互引用而再没有外部引用A与B中的任何一个,它们的引用计数都为1,但显然应该被回收。循环引用示例:

list1 = []
list2 = []
list1.append(list2)
list2.append(list1)

为了解决这两个弱点,Python又引入了以下两种GC机制

二,标记-清除

针对循环引用的情况:我们有一个“孤岛”或是一组未使用的、互相指向的对象,但是谁都没有外部引用。换句话说,我们的程序不再使用这些节点对象了,所以我们希望Python的垃圾回收机制能够足够智能去释放这些对象并回收它们占用的内存空间。但是这不可能,因为所有的引用计数都是1而不是0。Python的引用计数算法不能够处理互相指向自己的对象。你的代码也许会在不经意间包含循环引用并且你并未意识到。事实上,当你的Python程序运行的时候它将会建立一定数量的“浮点数垃圾”,Python的GC不能够处理未使用的对象因为应用计数值不会到零。

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

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

Python 对象的垃圾回收详解_第1张图片

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

标记清除算法作为Python的辅助垃圾收集技术主要处理的是一些容器对象,比如list、dict、tuple,instance等,因为对于字符串、数值对象是不可能造成循环引用问题。Python使用一个双向链表将这些容器对象组织起来。不过,这种简单粗暴的标记清除算法也有明显的缺点:清除非活动的对象前它必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。

“标记-清除”法是为了解决循环引用问题。可以包含其他对象引用的容器对象(如list, dict, set,甚至class)都可能产生循环引用,为此,在申请内存时,所有容器对象的头部又加上了 PyGC_Head 来实现“标记-清除”机制。任何一个Python对象都分为两部分:PyObject_HEAD + 对象本身数据

// 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;

在为对象申请内存的时候,可以明显看到,实际申请的内存数量已经加上了 PyGC_Head 的大小:

// gcmodule.c
PyObject *_PyObject_GC_Malloc(size_t basicsize)
{
    PyObject *op;
    PyGC_Head *g = (PyGC_Head *)PyObject_MALLOC(
                sizeof(PyGC_Head) + basicsize);    # 注意这里的sizeof(PyGC_Head)
    if (g == NULL) 
        return PyErr_NoMemory();

    ......

    op = FROM_GC(g);
    return op;
}

举例来说,从list对象的创建中,有如下主要逻辑:

// listobject.c
PyObject *PyList_New(Py_ssize_t size)
{
    PyListObject *op;
    ......
    op = PyObject_GC_New(PyListObject, &PyList_Type);
    ......
    _PyObject_GC_TRACK(op);  # _PyObject_GC_TRACK就将对象链接到了第0代对象集合中
    return (PyObject *) op;
}

Python 的内部C代码用一个称为“零代(Generation Zero)”的链表来追踪活跃对象,每次当你创建活跃对象时或其他什么值的时候,Python会将其加入零代链表

Python 对象的垃圾回收详解_第2张图片

从上边可以看到当我们创建ABC节点的时候,Python将其加入零代链表。

Python 对象的垃圾回收详解_第3张图片

此时,零代包含了两个节点对象。(他还将包含Python创建的每个其他值,与一些Python自己使用的内部值。)

随后,Python 会循环遍历零代列表上每个对象,检查列表中每个互相引用的对象,根据规则减掉其引用计数。在这个过程中,Python会一个接一个的统计内部引用的数量以防过早地释放对象。

Python 对象的垃圾回收详解_第4张图片

通过识别内部引用,Python能够减少许多零代链表对象的引用计数。在上图的第一行中你能够看见ABC和DEF的引用计数已经变为零了,这意味着收集器可以释放它们并回收内存空间了。剩下的活跃的对象则被移动到一个新的链表:一代链表

Python 中的 GC 阈值

python中什么时候进行这个标记过程?

随着程序运行,Python解释器保持对新创建的对象因引用计数为零而被释放掉的对象追踪。从理论上说,这两个值应该保持一致,因为程序新建的每个对象都应该最终被释放掉。

但在实际情况中,由于循环或程序使用了一些比其他对象存在时间更长的对象,被分配对象的计数值与被释放对象的计数值之间的差异在逐渐增长。一旦差异累计超过某个阈值,则Python的收集机制就启动了,并且触发上边所说到的零代算法,释放“浮动的垃圾”,并且将剩下的对象移动到一代列表。

随着时间的推移,程序所使用的对象逐渐从零代列表移动到一代列表。而Python对于一代列表中对象的处理遵循同样的方法,一旦被分配计数值与被释放计数值累计到达一定阈值,Python会将剩下的活跃对象移动到二代列表

通过这种方法,你的代码所长期使用的对象,那些你的代码持续访问的活跃对象,会从零代链表转移到一代再转移到二代。通过不同的阈值设置,Python可以在不同的时间间隔处理这些对象。Python处理零代最为频繁,其次是一代然后才是二代。

弱代假说(weak generational hypothesis)

代垃圾回收算法的核心行为:垃圾回收器会更频繁的处理新对象。一个新的对象即是你的程序刚刚创建的,而一个老的对象则是经过了几个时间周期之后仍然存在的对象。

python会在当一个对象从零代移动到一代,或是从一代移动到二代的过程中提升(promote)这个对象。这么做的缘故是弱代假说

弱代假说由两个观点构成:年轻的对象通常死得也快,而老对象则很可能存活更长时间。

假定现在用Python创建一个对象:

n1 = Node("ABC")

根据假说,我的代码很可能仅仅会使用"ABC"很短的时间。这个对象也许仅仅只是一个方法中的中间结果,并且随着方法的返回这个对象就将变成垃圾了。大部分的新对象都是如此般地很快变成垃圾。然而,偶尔程序会创建一些很重要的,存活时间比较长的对象。

通过频繁的处理零代链表中的新对象,Python的垃圾收集器将把时间花在更有意义的地方:它处理那些很快就可能变成垃圾的新对象。同时只在很少的时候,当满足阈值的条件,收集器才回去处理那些老变量。

三,分代回收

先给出 gc 的逻辑:

分配内存
-> 发现超过阈值了
-> 触发垃圾回收
-> 将所有可收集对象链表放到一起
-> 遍历, 计算有效引用计数
-> 分成 有效引用计数=0 和 有效引用计数 > 0 两个集合
-> 大于0的, 放入到更老一代
-> =0的, 执行回收
-> 回收遍历容器内的各个元素, 减掉对应元素引用计数(破掉循环引用)
-> 执行-1的逻辑, 若发现对象引用计数=0, 触发内存回收
-> python底层内存管理机制回收内存
 

Python中, 引入了分代收集, 总共三个”代”. Python 中, 一个代就是一个链表, 所有属于同一”代”的内存块都链接在同一个链表中用来表示“代”的结构体是 gc_generation, 包括了当前代链表表头、对象数量上限、当前对象数量:

#define NUM_GENERATIONS 3
#define GEN_HEAD(n) (&generations[n].head)

/* linked lists of container objects */
static struct gc_generation generations[NUM_GENERATIONS] = {
    /* PyGC_Head,               threshold,  count */
    {{{GEN_HEAD(0), GEN_HEAD(0), 0}},   700,        0},
    {{{GEN_HEAD(1), GEN_HEAD(1), 0}},   10,     0},
    {{{GEN_HEAD(2), GEN_HEAD(2), 0}},   10,     0},
};

分代回收总结:

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

 

参考文章:

Python垃圾回收机制详解

python垃圾回收机制(Garbage collection)

 

 

 

 

你可能感兴趣的:(Python内置方法与语法,Python垃圾回收)