• Python 垃圾回收


    ​ 现在的高级语言如java,c#等,都采用了垃圾收集机制,而不再是c,c++里用户自己管理维护内存的方式。自己管理内存极其自由,可以任意申请内存,但如同一把双刃剑,为大量内存泄露,悬空指针等bug埋下隐患。 对于一个字符串、列表、类甚至数值都是对象,且定位简单易用的语言,自然不会让用户去处理如何分配回收内存的问题。

    python里也同java一样采用了垃圾收集机制,不过不一样的是: python采用的是引用计数机制为主,标记-清除和分代收集两种机制为辅的策略。

    ​ 在这里我们先不讲这些,先讲下Python对小整数、单个字母和字符串的处理。

    小整数对象池

    整数在程序中的使用非常广泛,Python为了优化速度,使用了小整数对象池, 避免为整数频繁申请和销毁内存空间。

    Python 对小整数的定义是 [-5, 257) 这些整数对象是提前建立好的,不会被垃圾回收。在一个 Python 的程序中,所有位于这个范围内的整数使用的都是同一个对象.

    同理,单个字母也是这样的

    但是当定义2个相同的字符串时,引用计数为0,触发垃圾回收

    In [16]: a = 1
    
    In [17]: b = 1
    
    In [18]: id(a)
    Out[18]: 1687738160
    
    In [19]: id(b)
    Out[19]: 1687738160
    
    In [20]: a = 'a'
    
    In [21]: b = 'a'
    
    In [22]: id(a)
    Out[22]: 58533632
    
    In [23]: id(b)
    Out[23]: 58533632
    

    大整数对象池

    每一个大整数,均创建一个新的对象。这个就不举例子了。

    intern机制

    a1 = "HelloWorld"
    a2 = "HelloWorld"
    a3 = "HelloWorld"
    a4 = "HelloWorld"
    a5 = "HelloWorld"
    a6 = "HelloWorld"
    a7 = "HelloWorld"
    a8 = "HelloWorld"
    a9 = "HelloWorld"
    
    

    python会不会创建9个对象呢?在内存中会不会开辟9个”HelloWorld”的内存空间呢? 想一下,如果是这样的话,我们写10000个对象,比如a1=”HelloWorld”…..a1000=”HelloWorld”, 那他岂不是开辟了1000个”HelloWorld”所占的内存空间了呢?如果真这样,内存不就爆了吗?所以python中有这样一个机制——intern机制,让他只占用一个”HelloWorld”所占的内存空间。靠引用计数去维护何时释放

    总结:

    • 小整数[-5,257)共用对象,常驻内存
    • 单个字符共用对象,常驻内存
    • 单个单词,不可修改,默认开启intern机制,共用对象,引用计数为0,则销毁
    • 字符串(含有空格),不可修改,没开启intern机制,不共用对象,引用计数为0,销毁
    • 大整数不共用内存,引用计数为0,销毁
    • 数值类型和字符串类型在 Python 中都是不可变的,这意味着你无法修改这个对象的值,每次对变量的修改,实际上是创建一个新的对象

    下来我们具体讲下垃圾回收机制

    引用计数机制

    python里每一个东西都是对象,它们的核心就是一个结构体:PyObject

    typedef struct_object {
        int ob_refcnt;
        struct_typeobject *ob_type;
    } PyObject;
    
    

    PyObject是每个对象必有的内容,其中ob_refcnt就是做为引用计数。当一个对象有新的引用时,它的ob_refcnt就会增加,当引用它的对象被删除,它的ob_refcnt就会减少

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

    当引用计数为0时,该对象生命就结束了。

    引用计数机制的优点:
    • 简单
    • 实时性:一旦没有引用,内存就直接释放了。不用像其他机制等到特定时机。实时性还带来一个好处:处理回收内存的时间分摊到了平时。
    引用计数机制的缺点:
    • 维护引用计数消耗资源

    • 循环引用

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

    list1与list2相互引用,如果不存在其他对象对它们的引用,list1与list2的引用计数也仍然为1,所占用的内存永远无法被回收,这将是致命的。 对于如今的强大硬件,缺点1尚可接受,但是循环引用导致内存泄露,注定python还将引入新的回收机制。(标记清除和分代收集)

    GC系统作用

    GC系统所承担的工作远比"垃圾回收"多得多。实际上,它们负责三个重要任务。它们

    • 为新生成的对象分配内存
    • 识别垃圾对象
    • 从垃圾对象那回收内存。

    ​ 如果将应用程序比作人的身体:所有你所写的那些优雅的代码,业务逻辑,算法,应该就是大脑。以此类推,垃圾回收机制应该是那个身体器官呢?

    我认为垃圾回收就是应用程序那颗跃动的心。像心脏为身体其他器官提供血液和营养物那样,垃圾回收器为你的应该程序提供内存和对象。如果心脏停跳,过不了几秒钟人就完了。如果垃圾回收器停止工作或运行迟缓,像动脉阻塞,你的应用程序效率也会下降,直至最终死掉。

    Python 的对象分配

    我们用Pyhon来创建一个Node对象:

    这里写图片描述
    当创建对象时Python立即向操作系统请求内存。(Python实现了一套自己的内存分配系统,在操作系统堆之上提供了一个抽象层。)

    当我们创建第二个对象的时候,再次像OS请求内存:

    这里写图片描述

    看起来够简单吧,在我们创建对象的时候,Python会花些时间为我们找到并分配内存。


    下面我们来具体的讲一下

    如我们创建Python Node对象

    img

    在内部,创建一个对象时,Python总是在对象的C结构体里保存一个整数,称为 引用数。期初,Python将这个值设置为1:

    img

    值为1说明分别有个一个指针指向或是引用这三个对象。假如我们现在创建一个新的Node实例,JKL:

    这里写图片描述

    与之前一样,Python设置JKL的引用数为1。然而,请注意由于我们改变了n1指向了JKL,不再指向ABC,Python就把ABC的引用数置为0了。 此刻,Python垃圾回收器立刻挺身而出!每当对象的引用数减为0,Python立即将其释放,把内存还给操作系统:

    这里写图片描述
    上面Python回收了ABC Node实例使用的内存。

    Python的这种垃圾回收算法被称为引用计数。是George-Collins在1960年发明的,恰巧与John McCarthy发明的可用列表算法在同一年出现。就像Mike-Bernstein在6月份哥谭市Ruby大会杰出的垃圾回收机制演讲中说的: "1960年是垃圾收集器的黄金年代..."

    现在来看第二例子。加入我们让n2引用n1:

    这里写图片描述

    上图中左边的DEF的引用数已经被Python减少了,垃圾回收器会立即回收DEF实例。同时JKL的引用数已经变为了2 ,因为n1和n2都指向它。


    引用计数的缺点:

    • 首先,它不好实现。Python不得不在每个对象内部留一些空间来处理引用数。这样付出了一小点儿空间上的代价。但更糟糕的是,每个简单的操作(像修改变量或引用)都会变成一个更复杂的操作,因为Python需要增加一个计数,减少另一个,还可能释放对象。
    • 第二点,它相对较慢。虽然Python随着程序执行GC很稳健,但这并不一定更快。Python不停地更新着众多引用数值。特别是当你不再使用一个大数据结构的时候,比如一个包含很多元素的列表,Python可能必须一次性释放大量对象。减少引用数就成了一项复杂的递归过程了。
    • 最后,它不是总奏效的。引用计数不能处理环形数据结构--也就是含有循环引用的数据结构。

    Python中的循环数据结构以及引用计数

    循环引用

    ​ 通过上面的介绍,我们知道在Python中,每个对象都保存了一个称为引用计数的整数值,来追踪到底有多少引用指向了这个对象。无论何时,如果我们程序中的一个变量或其他对象引用了目标对象,Python将会增加这个计数值,而当程序停止使用这个对象,则Python会减少这个计数值。一旦计数值被减到零,Python将会释放这个对象以及回收相关内存空间。

    从六十年代开始,计算机科学界就面临了一个严重的理论问题,那就是针对引用计数这种算法来说,如果一个数据结构引用了它自身,即如果这个数据结构是一个循环数据结构,那么某些引用计数值是肯定无法变成零的。为了更好地理解这个问题,让我们举个例子。下面的代码展示了一些上周我们所用到的节点类:

    这里写图片描述

    我们有一个"构造器"(在Python中叫做 init ),在一个实例变量中存储一个单独的属性。在类定义之后我们创建两个节点,ABC以及DEF,在图中为左边的矩形框。两个节点的引用计数都被初始化为1,因为各有两个引用指向各个节点(n1和n2)。

    现在,让我们在节点中定义两个附加的属性,next以及prev:

    这里写图片描述

    Python中你可以在代码运行的时候动态定义实例变量或对象属性。我们设置 n1.next 指向 n2,同时设置 n2.prev 指回 n1。现在,我们的两个节点使用循环引用的方式构成了一个双向链表。同时请注意到 ABC 以及 DEF 的引用计数值已经增加到了2。这里有两个指针指向了每个节点:首先是 n1 以及 n2,其次就是 next 以及 prev。

    现在,假定我们的程序不再使用这两个节点了,我们将 n1 和 n2 都设置为null(Python中是None)。

    这里写图片描述

    好了,Python会像往常一样将每个节点的引用计数减少到1,所以不会被回收。

    标记-清除和分代收集

    标记-清除

    ​ 请注意在以上刚刚说到的例子中,我们以一个不是很常见的情况结尾:我们有一个“孤岛”或是一组未使用的、互相指向的对象,但是谁都没有外部引用。换句话说,我们的程序不再使用这些节点对象了,所以我们希望Python的垃圾回收机制能够足够智能去释放这些对象并回收它们占用的内存空间。但是这不可能,因为所有的引用计数都是1而不是0。Python的引用计数算法不能够处理互相指向自己的对象。

    “标记-清除”就是为了解决循环引用的问题。可以包含其他对象引用的容器对象(比如:list,set,dict,class,instance)都可能产生循环引用。

    我们必须承认上面的事实,如果两个对象的引用计数都为1,但是仅仅存在他们之间的循环引用,那么这两个对象都是需要被回收的,也就是说,它们的引用计数虽然表现为非0,但实际上有效的引用计数为0。我们必须先将循环引用摘掉,那么这两个对象的有效计数就现身了。假设两个对象为A、B,我们从A出发,因为它有一个对B的引用,则将B的引用计数减1;然后顺着引用达到B,因为B有一个对A的引用,同样将A的引用减1,这样,就完成了循环引用对象间环摘除。

    但是这样就有一个问题,假设对象A有一个对象引用C,而C没有引用A,如果将C计数引用减1,而最后A并没有被回收,显然,我们错误的将C的引用计数减1,这将导致在未来的某个时刻出现一个对C的悬空引用。这就要求我们必须在A没有被删除的情况下复原C的引用计数,如果采用这样的方案,那么维护引用计数的复杂度将成倍增加。

    原理:“标记-清除”采用了更好的做法,我们并不改动真实的引用计数,而是将集合中对象的引用计数复制一份副本,改动该对象引用的副本。对于副本做任何的改动,都不会影响到对象生命走起的维护。

    这个计数副本的唯一作用是寻找root object集合(该集合中的对象是不能被回收的)。当成功寻找到root object集合之后,首先将现在的内存链表一分为二,一条链表中维护root object集合,成为root链表,而另外一条链表中维护剩下的对象,成为unreachable链表。之所以要剖成两个链表,是基于这样的一种考虑:现在的unreachable可能存在被root链表中的对象,直接或间接引用的对象,这些对象是不能被回收的,一旦在标记的过程中,发现这样的对象,就将其从unreachable链表中移到root链表中;当完成标记后,unreachable链表中剩下的所有对象就是名副其实的垃圾对象了,接下来的垃圾回收只需限制在unreachable链表中即可。

    那么GC又是如何判断哪些是活动对象哪些是非活动对象的呢?

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

    这里写图片描述

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

    分代回收

    背景:分代的垃圾收集技术是在上个世纪80年代初发展起来的一种垃圾收集机制,一系列的研究表明:无论使用何种语言开发,无论开发的是何种类型,何种规模的程序,都存在这样一点相同之处。即:一定比例的内存块的生存周期都比较短,通常是几百万条机器指令的时间,而剩下的内存块,起生存周期比较长,甚至会从程序开始一直持续到程序结束。

    从前面“标记-清除”这样的垃圾收集机制来看,这种垃圾收集机制所带来的额外操作实际上与系统中总的内存块的数量是相关的,当需要回收的内存块越多时,垃圾检测带来的额外操作就越多,而垃圾回收带来的额外操作就越少;反之,当需回收的内存块越少时,垃圾检测就将比垃圾回收带来更少的额外操作。为了提高垃圾收集的效率,采用“空间换时间的策略”。

    原理:将系统中的所有内存块根据其存活时间划分为不同的集合,每一个集合就成为一个“代”,垃圾收集的频率随着“代”的存活时间的增大而减小。也就是说,活得越长的对象,就越不可能是垃圾,就应该减少对它的垃圾收集频率。那么如何来衡量这个存活时间:通常是利用几次垃圾收集动作来衡量,如果一个对象经过的垃圾收集次数越多,可以得出:该对象存活时间就越长。

    在Python中的零代(Generation Zero

    ​ Python使用一种不同的链表来持续追踪活跃的对象。而不将其称之为“活跃列表”,Python的内部C代码将其称为零代(Generation Zero)。每次当你创建一个对象或其他什么值的时候,Python会将其加入零代链表:

    这里写图片描述

    从上边可以看到当我们创建ABC节点的时候,Python将其加入零代链表。请注意到这并不是一个真正的列表,并不能直接在你的代码中访问,事实上这个链表是一个完全内部的Python运行时。 相似的,当我们创建DEF节点的时候,Python将其加入同样的链表:

    这里写图片描述

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

    检测循环引用

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

    为了便于理解,来看一个例子:

    这里写图片描述

    从上面可以看到 ABC 和 DEF 节点包含的引用数为1.有三个其他的对象同时存在于零代链表中,蓝色的箭头指示了有一些对象正在被零代链表之外的其他对象所引用。(接下来我们会看到,Python中同时存在另外两个分别被称为一代和二代的链表)。这些对象有着更高的引用计数因为它们正在被其他指针所指向着。

    接下来你会看到Python的GC是如何处理零代链表的。

    这里写图片描述

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

    Python中的GC阈值

    ​ Python什么时候会进行这个标记过程?随着你的程序运行,Python解释器保持对新创建的对象,以及因为引用计数为零而被释放掉的对象的追踪。从理论上说,这两个值应该保持一致,因为程序新建的每个对象都应该最终被释放掉。

    当然,事实并非如此。因为循环引用的原因,并且因为你的程序使用了一些比其他对象存在时间更长的对象,从而被分配对象的计数值与被释放对象的计数值之间的差异在逐渐增长。一旦这个差异累计超过某个阈值,则Python的收集机制就启动了,并且触发上边所说到的零代算法,释放“浮动的垃圾”,并且将剩下的对象移动到一代列表。

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

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

    弱代假说

    来看看代垃圾回收算法的核心行为:垃圾回收器会更频繁的处理新对象。一个新的对象即是你的程序刚刚创建的,而一个来的对象则是经过了几个时间周期之后仍然存在的对象。Python会在当一个对象从零代移动到一代,或是从一代移动到二代的过程中提升(promote)这个对象。

    为什么要这么做?这种算法的根源来自于弱代假说(weak generational hypothesis)。这个假说由两个观点构成:首先是年亲的对象通常死得也快,而老对象则很有可能存活更长的时间。

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

    n1 = Node("ABC")
    

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

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

    gc模块

    导致引用计数+1的情况

    • 对象被创建,例如a=23
    • 对象被引用,例如b=a
    • 对象被作为参数,传入到一个函数中,例如func(a)
    • 对象作为一个元素,存储在容器中,例如list1=[a,a]

    导致引用计数-1的情况

    • 对象的别名被显式销毁,例如del a
    • 对象的别名被赋予新的对象,例如a=24
    • 一个对象离开它的作用域,例如f函数执行完毕时,func函数中的局部变量(全局变量不会)
    • 对象所在的容器被销毁,或从容器中删除对象

    触发垃圾回收的情况:

    1. 调用gc.collect(),
    2. 当gc模块的计数器达到阀值的时候。
    3. 程序退出的时候

    查看一个对象的引用计数

    import sys
    a = "hello world"
    sys.getrefcount(a)
    
    

    可以查看a对象的引用计数,但是比正常计数大1,因为调用函数的时候传入a,这会让a的引用计数+1

    gc模块常用功能解析

    gc模块提供一个接口给开发者设置垃圾回收的选项。上面说到,采用引用计数的方法管理内存的一个缺陷是循环引用,而gc模块的一个主要功能就是解决循环引用的问题。

    常用函数:
    函数 说明
    gc.set_debug(flags) 设置gc的debug日志,一般设置为gc.DEBUG_LEAK
    gc.collect([generation]) 显式进行垃圾回收,可以输入参数,0代表只检查第一代的对象,1代表检查一,二代的对象,2代表检查一,二,三代的对象,如果不传参数,执行一个full collection,也就是等于传2。 返回不可达(unreachable objects)对象的数目
    gc.get_threshold() 获取的gc模块中自动执行垃圾回收的频率。
    gc.set_threshold(threshold0[, threshold1[, threshold2]) 设置自动执行垃圾回收的频率。
    gc.get_count() 获取当前自动执行垃圾回收的计数器,返回一个长度为3的列表

    gc模块的自动垃圾回收机制

    必须要import gc模块,并且is_enable()=True才会启动自动垃圾回收。

    这个机制的主要作用就是发现并处理不可达的垃圾对象

    垃圾回收=垃圾检查+垃圾回收

    在Python中,采用分代收集的方法。把对象分为三代,一开始,对象在创建的时候,放在一代中,如果在一次一代的垃圾检查中,改对象存活下来,就会被放到二代中,同理在一次二代的垃圾检查中,该对象存活下来,就会被放到三代中。

    gc模块里面会有一个长度为3的列表的计数器,可以通过gc.get_count()获取。

    例如(488,3,0),其中488是指距离上一次一代垃圾检查,Python分配内存的数目减去释放内存的数目,注意是内存分配,而不是引用计数的增加。例如:

    print gc.get_count() # (590, 8, 0)
    a = ClassA()
    print gc.get_count() # (591, 8, 0)
    del a
    print gc.get_count() # (590, 8, 0)
    
    

    3是指距离上一次二代垃圾检查,一代垃圾检查的次数,同理,0是指距离上一次三代垃圾检查,二代垃圾检查的次数。

    gc模快有一个自动垃圾回收的阀值,即通过gc.get_threshold函数获取到的长度为3的元组,例如(700,10,10) 每一次计数器的增加,gc模块就会检查增加后的计数是否达到阀值的数目,如果是,就会执行对应的代数的垃圾检查,然后重置计数器

    例如,假设阀值是(700,10,10):

    当计数器从(699,3,0)增加到(700,3,0),gc模块就会执行gc.collect(0),即检查一代对象的垃圾,并重置计数器为(0,4,0)
    当计数器从(699,9,0)增加到(700,9,0),gc模块就会执行gc.collect(1),即检查一、二代对象的垃圾,并重置计数器为(0,0,1)
    当计数器从(699,9,9)增加到(700,9,9),gc模块就会执行gc.collect(2),即检查一、二、三代对象的垃圾,并重置计数器为(0,0,0)
    
    

    注意点

    gc模块唯一处理不了的是循环引用的类都有__del__方法,所以项目中要避免定义__del__方法

    import gc
    
    class ClassA():
        pass
        # def __del__(self):
        #     print('object born,id:%s'%str(hex(id(self))))
    
    gc.set_debug(gc.DEBUG_LEAK)
    a = ClassA()
    b = ClassA()
    
    a.next = b
    b.prev = a
    
    print "--1--"
    print gc.collect()
    print "--2--"
    del a
    print "--3--"
    del b
    print "--3-1--"
    print gc.collect()
    print "--4--"
    
    

    运行结果:

    --1--
    0
    --2--
    --3--
    --3-1--
    gc: collectable <ClassA instance at 0x21248c8>
    gc: collectable <ClassA instance at 0x21248f0>
    gc: collectable <dict 0x2123030>
    gc: collectable <dict 0x2123150>
    4
    --4--
    
    

    如果把__del__打开,运行结果为:

    --1--
    0
    --2--
    --3--
    --3-1--
    gc: uncollectable <ClassA instance at 0x6269b8>
    gc: uncollectable <ClassA instance at 0x6269e0>
    gc: uncollectable <dict 0x61bed0>
    gc: uncollectable <dict 0x6230c0>
    4
    --4--
    
  • 相关阅读:
  • 原文地址:https://www.cnblogs.com/yangliguo/p/8178176.html
  • 最新文章
  • 热门文章
一二三 - 开发者的网上家园