扫二维码与项目经理沟通
我们在微信上24小时期待你的声音
解答本文疑问/技术咨询/运营咨询/技术建议/互联网交流
众所周知,Python 是一门面向对象语言,在 Python 的世界一切皆对象。所以一切变量的本质都是对象的一个指针而已。
创新互联建站专业为企业提供洪江管理区网站建设、洪江管理区做网站、洪江管理区网站设计、洪江管理区网站制作等企业网站建设、网页设计与制作、洪江管理区企业网站模板建站服务,十年洪江管理区做网站经验,不只是建网站,更提供有价值的思路和整体网络服务。
Python 运行过程中会不停的创建各种变量,而这些变量是需要存储在内存中的,随着程序的不断运行,变量数量越来越多,所占用的空间势必越来越大,如果对变量所占用的内存空间管理不当的话,那么肯定会出现 out of memory。程序大概率会被异常终止。
因此,对于内存空间的有效合理管理变得尤为重要,那么 Python 是怎么解决这个问题的呢。其实很简单,对不不可能再使用到的内存进行回收即可,像 C 语言中需要程序员手动释放内存就是这个道理。但问题是如何确定哪些内存不再会被使用到呢?这就是我们今天要说的垃圾回收了。
目前垃圾回收比较通用的解决办法有三种,引用计数,标记清除以及分代回收。
引用计数也是一种最直观,最简单的垃圾收集技术。在 Python 中,大多数对象的生命周期都是通过对象的引用计数来管理的。其原理非常简单,我们为每个对象维护一个 ref 的字段用来记录对象被引用的次数,每当对象被创建或者被引用时将该对象的引用次数加一,当对象的引用被销毁时该对象的引用次数减一,当对象的引用次数减到零时说明程序中已经没有任何对象持有该对象的引用,换言之就是在以后的程序运行中不会再次使用到该对象了,那么其所占用的空间也就可以被释放了了。
我们来看看下面的例子。
函数 print_memory_info 用来获取程序占用的内存空间大小,在 foo 函数中创建一个包含一百万个整数的列表。从打印结果我们可以看出,创建完列表之后程序耗用的内存空间上升到了 55 MB。而当函数 foo 调用完毕之后内存消耗又恢复正常。
这是因为我们在函数 foo 中创建的 list 变量是局部变量,其作用域是当前函数内部,一旦函数执行完毕,局部变量的引用会被自动销毁,即其引用次数会变为零,所占用的内存空间也会被回收。
为了验证我们的想法,我们对函数 foo 稍加改造。代码如下:
稍加改造之后,即使 foo 函数调用结束其所消耗的内存也未被释放。
主要是因为我们将函数 foo 内部产生的列表返回并在主程序中接收之后,这样就会导致该列表的引用依然存在,该对象后续仍有可能被使用到,垃圾回收便不会回收该对象。
那么,什么时候对象的引用次数才会增加呢。下面四种情况都会导致对象引用次数加一。
同理,对象引用次数减一的情况也有四种。
引用计数看起来非常简单,实现起来也不复杂,只需要维护一个字段保存对象被引用的次数即可,那么是不是就代表这种算法没有缺点了呢。实则不然,我们知道引用次数为零的对象所占用的内存空间肯定是需要被回收的。那引用次数不为零的对象呢,是不是就一定不能回收呢?
我们来看看下面的例子,只是对函数 foo 进行了改造,其余未做更改。
我们看到,在函数 foo 内部生成了两个列表 list_a 和 list_b,然后将两个列表分别添加到另外一个中。由结果可以看出,即使 foo 函数结束之后其所占用的内存空间依然未被释放。这是因为对于 list_a 和 list_b 来说虽然没有被任何外部对象引用,但因为二者之间交叉引用,以至于每个对象的引用计数都不为零,这也就造成了其所占用的空间永远不会被回收的尴尬局面。这个缺点是致命的。
为了解决交叉引用的问题,Python 引入了标记清除算法和分代回收算法。
显然,可以包含其他对象引用的容器对象都有可能产生交叉引用问题,而标记清除算法就是为了解决交叉引用的问题的。
标记清除算法是一种基于对象可达性分析的回收算法,该算法分为两个步骤,分别是标记和清除。标记阶段,将所有活动对象进行标记,清除阶段将所有未进行标记的对象进行回收即可。那么现在的为问题变为了 GC 是如何判定哪些是活动对象的?
事实上 GC 会从根结点出发,与根结点直接相连或者间接相连的对象我们将其标记为活动对象(该对象可达),之后进行回收阶段,将未标记的对象(不可达对象)进行清除。前面所说的根结点可以是全局变量,也可以是调用栈。
标记清除算法主要用来处理一些容器对象,虽说该方法完全可以做到不误杀不遗漏,但 GC 时必须扫描整个堆内存,即使只有少量的非可达对象需要回收也需要扫描全部对象。这是一种巨大的性能浪费。
由于标记清除算法需要扫描整个堆的所有对象导致其性能有所损耗,而且当可以回收的对象越少时性能损耗越高。因此 Python 引入了分代回收算法,将系统中存活时间不同的对象划分到不同的内存区域,共三代,分别是 0 代,1 代 和 2 代。新生成的对象是 0 代,经过一次垃圾回收之后,还存活的对象将会升级到 1 代,以此类推,2 代中的对象是存活最久的对象。
那么什么时候触发进行垃圾回收算法呢。事实上随着程序的运行会不断的创建新的对象,同时也会因为引用计数为零而销毁大部分对象,Python 会保持对这些对象的跟踪,由于交叉引用的存在,以及程序中使用了长时间存活的对象,这就造成了新生成的对象的数量会大于被回收的对象数量,一旦二者之间的差值达到某个阈值就会启动垃圾回收机制,使用标记清除算法将死亡对象进行清除,同时将存活对象移动到 1 代。 以此类推,当二者的差值再次达到阈值时又触发垃圾回收机制,将存活对象移动到 2 代。
这样通过对不同代的阈值做不同的设置,就可以做到在不同代使用不同的时间间隔进行垃圾回收,以追求性能最大。
事实上,所有的程序都有一个相识的现象,那就是大部分的对象生存周期都是相当短的,只有少量对象生命周期比较长,甚至会常驻内存,从程序开始运行持续到程序结束。而通过分代回收算法,做到了针对不同的区域采取不同的回收频率,节约了大量的计算从而提高 Python 的性能。
除了上面所说的差值达到一定阈值会触发垃圾回收之外,我们还可以显示的调用 gc.collect() 来触发垃圾回收,最后当程序退出时也会进行垃圾回收。
本文介绍了 Python 的垃圾回收机制,垃圾回收是 Python 自带的功能,并不需要程序员去手动管理内存。
其中引用计数法是最简单直接的,但是需要维护一个字段且针对交叉引用无能为力。
标记清除算法主要是为了解决引用计数的交叉引用问题,该算法的缺点就是需要扫描整个堆的所有对象,有点浪费性能。
而分代回收算法的引入则完美解决了标记清除算法需要扫描整个堆对象的性能浪费问题。该算法也是建立在标记清除基础之上的。
最后我们可以通过 gc.collect() 手动触发 GC 的操作。
题外话,如果你看过 JVM 的垃圾回收算法之后会发现 Python 的垃圾回收算法与其是如出一辙的,事实再次证明,程序语言设计时是会相互参考的。
python采用的是 引用计数 机制为主, 标记-清除 和 分代收集(隔代回收) 两种机制为辅的策略。
python里每一个东西都是对象,它们的核心就是一个结构体:PyObject
PyObject是每个对象必有的内容,其中ob_refcnt就是做为引用计数。当一个对象有新的引用时,它的ob_refcnt就会增加,当引用它的对象被删除,它的ob_refcnt就会减少
引用计数为0时,该对象生命就结束了。
引用计数机制的优点:
1、简单
2、实时性:一旦没有引用,内存就直接释放了,不用像其他机制得等到特定时机。实时性还带来一个好处:处理回收内存的时间分摊到了平时。
引用计数机制的缺点:
1、维护引用计数消耗资源
2、循环引用
案例:
循环引用导致内存泄露
有三种情况会触发垃圾回收:
gc模块提供一个接口给开发者设置垃圾回收的选项。上面说到,采用引用计数的方法管理内存的一个缺陷是循环引用,而gc模块的一个主要功能就是解决循环引用的问题。
常用函数 :
gc实践案例
必须要import gc模块,并且is_enable()=True才会启动自动垃圾回收。
这个机制的主要作用就是发现并处理不可达的垃圾对象。
在Python中,采用分代收集的方法。把对象分为三代,一开始,对象在创建的时候,放在一代中,如果在一次一代的垃圾检查中,该对象存活下来,就会被放到二代中,同理在一次二代的垃圾检查中,该对象存活下来,就会被放到三代中。
gc模块里面会有一个长度为3的列表的计数器,可以通过 gc.get_count() 获取。
gc模快有一个自动垃圾回收的阀值,即通过 gc.get_threshold 函数获取到的长度为3的元组,例如 (700,10,10)
每一次计数器的增加,gc模块就会检查增加后的计数是否达到阀值的数目,如果是,就会执行对应的代数的垃圾检查,然后重置计数器
注意:
如果循环引用中,两个对象都定义了 __del__ 方法,gc模块不会销毁这些不可达对象,因为gc模块不知道应该先调用哪个对象的 __del__ 方法,所以为了安全起见,gc模块会把对象放到 gc.garbage 中,但是不会销毁对象。
标记清除(Mark—Sweep)』算法是一种基于追踪回收(tracing GC)技术实现的垃圾回收算法。它分为两个阶段:第一阶段是标记阶段,GC会把所有的『活动对象』打上标记,第二阶段是把那些没有标记的对象『非活动对象』进行回收。那么GC又是如何判断哪些是活动对象哪些是非活动对象的呢?
对象之间通过引用(指针)连在一起,构成一个有向图,对象构成这个有向图的节点,而引用关系构成这个有向图的边。从根对象(root object)出发,沿着有向边遍历对象,可达的(reachable)对象标记为活动对象,不可达的对象就是要被清除的非活动对象。根对象就是全局变量、调用栈、寄存器。 mark-sweepg 在上图中,我们把小黑圈视为全局变量,也就是把它作为root object,从小黑圈出发,对象1可直达,那么它将被标记,对象2、3可间接到达也会被标记,而4和5不可达,那么1、2、3就是活动对象,4和5是非活动对象会被GC回收。
标记清除算法作为Python的辅助垃圾收集技术主要处理的是一些容器对象,比如list、dict、tuple,instance等,因为对于字符串、数值对象是不可能造成循环引用问题。Python使用一个双向链表将这些容器对象组织起来。不过,这种简单粗暴的标记清除算法也有明显的缺点:清除非活动的对象前它必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。
Python中的垃圾回收机制总体上有三种,
引用计数
Python语言默认采用的垃圾收集机制是『引用计数法 Reference Counting』,该算法最早George E. Collins在1960的时候首次提出,50年后的今天,该算法依然被很多编程语言使用,『引用计数法』的原理是:每个对象维护一个ob_ref字段,用来记录该对象当前被引用的次数,每当新的引用指向该对象时,它的引用计数ob_ref加1,每当该对象的引用失效时计数ob_ref减1,一旦对象的引用计数为0,该对象立即被回收,对象占用的内存空间将被释放。它的缺点是需要额外的空间维护引用计数,这个问题是其次的,不过最主要的问题是它不能解决对象的“循环引用”,因此,也有很多语言比如Java并没有采用该算法做来垃圾的收集机制。
在这个例子中程序执行完del语句后,A、B对象已经没有任何引用指向这两个对象,但是这两个对象各包含一个对方对象的引用,虽然最后两个对象都无法通过其它变量来引用这两个对象了,这对GC来说就是两个非活动对象或者说是垃圾对象,但是他们的引用计数并没有减少到零。因此如果是使用引用计数法来管理这两对象的话,他们并不会被回收,它会一直驻留在内存中,就会造成了内存泄漏(内存空间在使用完毕后未释放)。为了解决对象的循环引用问题,Python引入了标记-清除和分代回收两种GC机制。
标记清除
『标记清除(Mark—Sweep)』算法是一种基于追踪回收(tracing GC)技术实现的垃圾回收算法。它分为两个阶段:第一阶段是标记阶段,GC会把所有的『活动对象』打上标记,第二阶段是把那些没有标记的对象『非活动对象』进行回收。那么GC又是如何判断哪些是活动对象哪些是非活动对象的呢?
对象之间通过引用(指针)连在一起,构成一个有向图,对象构成这个有向图的节点,而引用关系构成这个有向图的边。从根对象(root object)出发,沿着有向边遍历对象,可达的(reachable)对象标记为活动对象,不可达的对象就是要被清除的非活动对象。根对象就是全局变量、调用栈、寄存器。
在上图中,我们把小黑圈视为全局变量,也就是把它作为root object,从小黑圈出发,对象1可直达,那么它将被标记,对象2、3可间接到达也会被标记,而4和5不可达,那么1、2、3就是活动对象,4和5是非活动对象会被GC回收。
标记清除算法作为Python的辅助垃圾收集技术主要处理的是一些容器对象,比如list、dict、tuple,instance等,因为对于字符串、数值对象是不可能造成循环引用问题。Python使用一个双向链表将这些容器对象组织起来。不过,这种简单粗暴的标记清除算法也有明显的缺点:清除非活动的对象前它必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。
分代回收
分代回收是一种以空间换时间的操作方式,Python将内存根据对象的存活时间划分为不同的集合,每个集合称为一个代,Python将内存分为了3“代”,分别为年轻代(第0代)、中年代(第1代)、老年代(第2代),他们对应的是3个链表,它们的垃圾收集频率与对象的存活时间的增大而减小。新创建的对象都会分配在年轻代,年轻代链表的总数达到上限时,Python垃圾收集机制就会被触发,把那些可以被回收的对象回收掉,而那些不会回收的对象就会被移到中年代去,依此类推,老年代中的对象是存活时间最久的对象,甚至是存活于整个系统的生命周期内。同时,分代回收是建立在标记清除技术基础之上。分代回收同样作为Python的辅助垃圾收集技术处理那些容器对象。
Python垃圾回收机制--完美讲解! 东皇Amrzs
Python中的垃圾回收机制
概述:引用计数为主,标记清除,分代回收为辅
1引用计数
python程序中创建的所有的对象都是放在一个双向环状循环链表refchain上的
如下对象被创建时,在C语言底层实际结构
name='string'
c语言内底部创建成 [上一个对象,下一个对象,类型,引用个数]
age=18
c语言内底部创建成[上一个对象,下一个对象,类型,引用个数,val=18]
hobby=['篮球', '撸铁',‘玩’]
c语言内底部创建成[上一个对象,下一个对象,类型,引用个数,item=元素, 元素个数]
当python程序运行时,会根据数据类型的不同找到其对应的结构体,根据结构体中的字段来进行创建相关的数据,然后将对象添加到refchain双向链表中
每个对象中有ob_refcnt就是应用计数器,默认为1,当有其他变量引用对象时,引用计数器就会+1
当引用计数器为0时,意味着没人使用这个对象了,这个对象就是垃圾,就会回收
回收步骤 :1对象从refchain链表移除 2将对象销毁,内存回收
2 标记清除
为什么要标记清除 :为了解决引用计数器循环引用的不足,循环引用可能导致内存泄漏
实现:在python的底层,再维护一个链表,链表中专门放那些可能存在循环引用的对象(list/tuple/dict/set)
在python内部,某种情况下触发,回去扫描可能存在循环引用链表中的每个元素,检查是否是循环引用,如果有,则让双方的引用计数器-1,如果是0,则垃圾回收
3 分代回收
为什么要分代回收: 不知道什么情况下触发扫描,可能存在循环引用的链表扫描代价大,每次扫描很久
将可能存在循环引用的对象维护成3个链表
0代:0代中对象个数达到700个扫描一次
1代:0代扫描10次,则1代扫描1次
2代:1代扫描10次,则2代扫描1次
过程:当我们创建了一个对象a=1,这个对象只会加到refchain链表中,而当我们创建了一个可能存在循环引用的对象b=[]一个列表时,这个对象不但会加到refchain链表中,还会加到分带回收的0代链表中,当0代链表中对象达到700个,GC开始扫描,如果是循环引用,那就自减1,减完以后,如果是垃圾,那就自动回收,如果不是垃圾,那就将这些对象升级到1代链表中,就这样扫描一遍,此时0代链表也会记录自己扫描了1次,等到下次0代链表的对象又达到了700个,继续上述步骤,就这样执行了10次,才会触发执行扫描1代链表,1代链表和2代链表中的操作和0代中一样。
4 小结(面试可以这么说)
在python中,维护了一个refchain的双向循环环状链表,这个链表中存储程序创建的所有对象,每种类型的对象中都有一个0b_refcnt引用计数器的值,默认为1,当引用计数器变为0时会进行垃圾回收(对象销毁,refchain中移除)
但是,在python 中,对于那些可以有多个元素组成的对象可能会存在循环引用的问题,为了解决这个问题,python又引入了标记清除和分代回收,在其内部维护了四个链表
refchain
0代 700个对象触发
1代 0代十次执行一次1代
2代 1代十次执行一次2代
当 每个链表达到阈值时,就会触发扫描链表进行标记清除操作,有循环则各自-1,为0时,直接回收,销毁,清除
But, 在上面的垃圾回收机制的步骤中,python提供了优化机制
缓存
小整数对象池
为了避免重复创建和销毁一些常用对象,维护了一个小整数对象池
-5~257的地址内存是一定的,这些对象是pyhton事先帮我们创建好了
free_list(会有大小限制)
当一个对象的引用计数为0时,按理说应该回收,但是python没有回收,而是把这个对象放到了一个free_list中当缓存,以后再去使用时,不在重新开辟内存,而是直接使用free_list
比如现在一个对象V=3.14 ,我现在把他del V, 代表引用计数为0 了,但是这块地址我不会回收,而是放到free_list中,然后我又创建了一个新的对象v1=999,这个对象不会开辟一块新内存,而是直接从free_list中去获取对象,然后把对象内部的数据进行初始化成999,再放到refchain中去,需要注意的是,free_list有大小限制,如果free_list链表满了,当一个对象的引用计数为0时,会直接回收这块地址,而不会放到free_list中进行缓存
float: 维护了free_list长度为100
int:不是基于free_list, 而是维护一个small_list保持常见的数据(小数据池),重复使用不会开辟新的内存
str: 内存将所有的ascii字符缓存起来,以后使用的时候不会反复创建
list: 维护了free_list长度为80
tuple:根据元素个数来维护free_list长度
dict:维护了free_list长度为80
我们在微信上24小时期待你的声音
解答本文疑问/技术咨询/运营咨询/技术建议/互联网交流