python作为一门解释型语言,以代码简洁易懂著称。我们可以直接对名称赋值,而不必声明类型。名称类型的确定、内存空间的分配与释放都是由python解释器在运行时进行的。python这一自动管理内存功能极大的减小了程序员负担,这也是成就python自身的重要原因之一。所以,这一篇文章我们就聊一聊python的内存管理。
内存管理机制
Python的内存管理内存总共分为4层(Layer0-3):
第一层Layer1的仅仅是对malloc的简单包装,raw memory,目的是为了兼容各个操作系统,因为不同的操作系统调用malloc的时候可能会有不同的行为结果;第二层Layer2是内存管理机制的核心,其中gc就是在这一层发挥至关重要的作用。第三层,是对象缓冲池,如python对一些对象的直接操作,包括int,list等。 对于可能被经常使用、而且是immutable的对象,如bool类型,元祖类型,小的整数、长度较短的字符串等,python会缓存在layer3,直接供python调用,避免频繁创建和销毁。
>>> a,b=1234567890123,1234567890123
>>> a is b
True
>>> a,b=(1,2,3,'a'),(1,2,3,'a')
>>> a is b
False
>>> a,b=('a'),('a')
>>> a is b
True
当一个对象逻辑上不被使用了,但并没有被释放,那么就存在内存泄露,很有可能会造成程序效率低下甚至崩溃; Python分配内存的时候又分为大内存和小内存。大小以256字节为界限,对于大内存使用Malloc进行分配,而对于小内存则使用内存池进行分配。由于小内存的分配和释放是频繁的,因此内存池的使用大大提高了python的执行效率。
引用计数
Python中,主要通过引用计数(Reference Counting)进行垃圾回收。
typedef struct_object {
int ob_refcnt;
struct_typeobject *ob_type;
} PyObject;
在Python中每一个对象的核心就是一个结构体PyObject,它的内部有一个引用计数器(ob_refcnt)。程序在运行的过程中会实时的更新ob_refcnt的值,来反映引用当前对象的名称数量。当某对象的引用计数值为0,那么它的内存就会被立即释放掉。
以下情况是导致引用计数加一的情况:
-
对象被创建,例如a=2
-
对象被引用,b=a
-
对象被作为参数,传入到一个函数中
-
对象作为一个元素,存储在容器中
下面的情况则会导致引用计数减一:
-
对象别名被显示销毁 del
-
对象别名被赋予新的对象
-
一个对象离开他的作用域
-
对象所在的容器被销毁或者是从容器中删除对象
我们还可以通过sys包中的getrefcount()来获取一个名称所引用的对象当前的引用计数(注意,这里getrefcount()本身会使得引用计数加一)
sys.getrefcount(a)
引用计数法有其明显的优点,如高效、实现逻辑简单、具备实时性,一旦一个对象的引用计数归零,内存就直接释放了。不用像其他机制等到特定时机。将垃圾回收随机分配到运行的阶段,处理回收内存的时间分摊到了平时,正常程序的运行比较平稳。但是,引用计数也存在着一些缺点,通常的缺点有:
-
逻辑简单,但实现有些麻烦。每个对象需要分配单独的空间来统计引用计数,这无形中加大的空间的负担,并且需要对引用计数进行维护,在维护的时候很容易会出错。
-
在一些场景下,可能会比较慢。正常来说垃圾回收会比较平稳运行,但是当需要释放一个大的对象时,比如字典,需要对引用的所有对象循环嵌套调用,从而可能会花费比较长的时间。
-
循环引用。这将是引用计数的致命伤,引用计数对此是无解的,因此必须要使用其它的垃圾回收算法对其进行补充。
也就是说,Python 的垃圾回收机制,很大一部分是为了处理可能产生的循环引用,是对引用计数的补充。
标记清除解决循环引用
Python采用了“标记-清除”(Mark and Sweep)算法,解决容器对象可能产生的循环引用问题。(注意,只有容器对象才会产生循环引用的情况,比如列表、字典、用户自定义类的对象、元组等。而像数字,字符串这类简单类型不会出现循环引用。作为一种优化策略,对于只包含简单类型的元组也不在标记清除算法的考虑之列)
跟其名称一样,该算法在进行垃圾回收时分成了两步,分别是:
-
A)标记阶段,遍历所有的对象,如果是可达的(reachable),也就是还有对象引用它,那么就标记该对象为可达;
-
B)清除阶段,再次遍历对象,如果发现某个对象没有标记为可达,则就将其回收。
如下图所示,在标记清除算法中,为了追踪容器对象,需要每个容器对象维护两个额外的指针,用来将容器对象组成一个双端链表,指针分别指向前后两个容器对象,方便插入和删除操作。python解释器(Cpython)维护了两个这样的双端链表,一个链表存放着需要被扫描的容器对象,另一个链表存放着临时不可达对象。在图中,这两个链表分别被命名为”Object to Scan”和”Unreachable”。图中例子是这么一个情况:link1,link2,link3组成了一个引用环,同时link1还被一个变量A(其实这里称为名称A更好)引用。link4自引用,也构成了一个引用环。从图中我们还可以看到,每一个节点除了有一个记录当前引用计数的变量ref_count还有一个gc_ref变量,这个gc_ref是ref_count的一个副本,所以初始值为ref_count的大小。
gc启动的时候,会逐个遍历”Object to Scan”链表中的容器对象,并且将当前对象所引用的所有对象的gc_ref减一。(扫描到link1的时候,由于link1引用了link2,所以会将link2的gc_ref减一,接着扫描link2,由于link2引用了link3,所以会将link3的gc_ref减一…..)像这样将”Objects to Scan”链表中的所有对象考察一遍之后,两个链表中的对象的ref_count和gc_ref的情况如下图所示。这一步操作就相当于解除了循环引用对引用计数的影响。
接着,gc会再次扫描所有的容器对象,如果对象的gc_ref值为0,那么这个对象就被标记为GC_TENTATIVELY_UNREACHABLE,并且被移至”Unreachable”链表中。下图中的link3和link4就是这样一种情况。
如果对象的gc_ref不为0,那么这个对象就会被标记为GC_REACHABLE。同时当gc发现有一个节点是可达的,那么他会递归式的将从该节点出发可以到达的所有节点标记为GC_REACHABLE,这就是下图中link2和link3所碰到的情形。
除了将所有可达节点标记为GC_REACHABLE之外,如果该节点当前在”Unreachable”链表中的话,还需要将其移回到”Object to Scan”链表中,下图就是link3移回之后的情形。
第二次遍历的所有对象都遍历完成之后,存在于”Unreachable”链表中的对象就是真正需要被释放的对象。如上图所示,此时link4存在于Unreachable链表中,gc随即释放之。
上面描述的垃圾回收的阶段,会暂停整个应用程序,等待标记清除结束后才会恢复应用程序的运行。
分代回收
在循环引用对象的回收中,整个应用程序会被暂停,为了减少应用程序暂停的时间,Python 通过“分代回收”(Generational Collection)以空间换时间的方法提高垃圾回收效率。
分代回收是基于这样的一个统计事实,对于程序,存在一定比例的内存块的生存周期比较短;而剩下的内存块,生存周期会比较长,甚至会从程序开始一直持续到程序结束。生存期较短对象的比例通常在 80%~90% 之间,这种思想简单点说就是:对象存在时间越长,越可能不是垃圾,应该越少去收集。这样在执行标记-清除算法时可以有效减小遍历的对象数,从而提高垃圾回收的速度。
python gc给对象定义了三种世代(0,1,2),每一个新生对象在generation zero中,如果它在一轮gc扫描中活了下来,那么它将被移至generation one,在那里他将较少的被扫描,如果它又活过了一轮gc,它又将被移至generation two,在那里它被扫描的次数将会更少。
gc的扫描在什么时候会被触发呢?答案是当某一世代中被分配的对象与被释放的对象之差达到某一阈值的时候,就会触发gc对某一世代的扫描。值得注意的是当某一世代的扫描被触发的时候,比该世代年轻的世代也会被扫描。也就是说如果世代2的gc扫描被触发了,那么世代0,世代1也将被扫描,如果世代1的gc扫描被触发,世代0也会被扫描。
该阈值可以通过下面两个函数查看和调整:
gc.get_threshold() # (threshold0, threshold1, threshold2).
gc.set_threshold(threshold0[, threshold1[, threshold2]])
下面对set_threshold()中的三个参数threshold0, threshold1, threshold2进行介绍。gc会记录自从上次收集以来新分配的对象数量与释放的对象数量,当两者之差超过threshold0的值时,gc的扫描就会启动,初始的时候只有世代0被检查。如果自从世代1最近一次被检查以来,世代0被检查超过threshold1次,那么对世代1的检查将被触发。相同的,如果自从世代2最近一次被检查以来,世代1被检查超过threshold2次,那么对世代2的检查将被触发。get_threshold()是获取三者的值,默认值为(700,10,10).
内存泄漏
像Java程序一样,虽然Python本身也有垃圾回收的功能,但是同样也会产生内存泄漏的问题。 对于一个用 python 实现的,长期运行的后台服务进程来说,如果内存持续增长,那么很可能是有了“内存泄露”。
1、内存泄露的原因
对于 python 这种支持垃圾回收的语言来说,怎么还会有内存泄露? 概括来说,有以下三种原因:
-
所用到的用 C 语言开发的底层模块中出现了内存泄露。
-
代码中用到了全局的 list、 dict 或其它容器,不停的往这些容器中插入对象,而忘记了在使用完之后进行删除回收
-
代码中有“引用循环”,并且被循环引用的对象定义了del方法,就会发生内存泄露。
为什么循环引用的对象定义了del方法后collect就不起作用了呢?
gc模块最常使用的方法就是gc.collect()方法,使用collect方法对循环引用的对象进行垃圾回收。 如果我们在类中重载了del方法。del方法定义了在用del语句删除对象时除了释放内存空间以外的操作。 一般而言,在使用了del语句的时候解释器首先会看要删除对象的引用计数,如果为0,那么就释放内存并执行del方法。 在这里,首先del语句出现时本身引用计数就不为0(因为有循环引用的存在),所以解释器不释放内存; 再者,执行collect方法时应该会清除循环引用所产生的无效引用计数从而达到del的目的,对于这两个循环引用对象而言, python无法判断调用它们的del方法时会不会要用到对方那个对象,比如在进行b.del()时可能会用到b._a也就是a,如果在那之前a已经被释放,那么就彻底GG了。 为了避免这种情况,collect方法默认不对重载了del方法的循环引用对象进行回收,而它们俩的状态也会从unreachable转变为uncollectable。由于是uncollectable的,自然就不会被collect处理,所以就进入了garbage列表。
也就是说:
如果循环引用中,两个对象都定义了del方法,gc模块不会销毁这两个不可达对象, 因为gc模块不知道应该先调用哪个对象的del方法 比如,两个对象a和b,如果先销毁a,再销毁b时,如果此时b.del方法中使用了a,那么这时会造成异常, 所以为了安全起见,gc模块会把这种重构了del的循环引用对象放到gc.garbage中,并把它们的状态从unreachable(不可达)转变为uncollectable(不可收集) 要解决这种情况造成内存泄漏的话,只能显式调用其中某个对象的del方法。
2、内存泄露的诊断思路
无论是哪种方式的内存泄露,最终表现的形式都是某些 python 对象在不停的增长;因此,首先是要找到这些异常的对象。
3、诊断步骤
用到的工具: gc 模块和 objgraph 模块
gc模块 是Python的垃圾收集器模块,gc使用标记清除算法回收垃圾
objgraph 是一个用于诊断内存问题的工具
1、 在服务程序的循环逻辑中,选择出一个诊断点 2、 在诊断点,插入如下诊断语句
import gc
import objgraph
### 强制进行垃圾回收
gc.collect()
### 打印出对象数目最多的 50 个类型信息
objgraph.show_most_common_types(limit=50)
4、检查统计信息,找到异常对象
运行加入诊断语句的服务程序,并将打印到屏幕上的统计信息重定向到日志中。运行一段时间后,就可以来分析日志,看看哪些对象在不停的增长。
比如,排查结果可能是: 一个多线程程序,多个线程作为生产者,一个线程作为消费者,通过将一个 tuple 对象送入异步队列进行通信。 由于消费者的处理速度跟不上生产者的速度,又没有进行同步, 导致异步队列中的对象越来越多。
内存溢出
1、内存溢出原因
-
内存中加载的数据量过于庞大,如一次从数据库取出过多数据
-
集合类中有对对象的引用,使用完后未清空,产生了堆积,使得JVM不能回收
-
代码中存在死循环或循环产生过多重复的对象实体
-
使用的第三方软件中的BUG
-
启动参数内存值设定的过小
2、内存溢出的解决方案
第一步,修改JVM启动参数,直接增加内存(-Xms,-Xmx参数一定不要忘记加)
第二步,检查错误日志,查看“OutOfMemory”错误前是否有其 它异常或错误
第三步,对代码进行走查和分析,找出可能发生内存溢出的位置
重点排查以下几点:
-
检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。
-
检查代码中是否有死循环或递归调用。
-
检查是否有大循环重复产生新对象实体。
-
检查List、MAP等集合对象是否有使用完后,未清除的问题。List、MAP等集合对象会始终存有对对象的引用,使得这些对象不能被GC回收。
第四步,使用内存查看工具动态查看内存使用情况
内存泄漏和内存溢出的区别
内存溢出是指向JVM申请内存空间时没有足够的可用内存了,就会抛出OOM即内存溢出。
内存泄漏是指,向JVM申请了一块内存空间,使用完后没有释放,由于没有释放,这块内存区域其他类加载的时候无法申请,
同时当前类又没有这块内存空间的内存地址了也无法使用,相当于丢了一块内存,这就是内存泄漏。
值得注意的是内存泄漏最终会导致内存溢出,很好理解,内存丢了很多最后当然内存不够用了。
总结
总体来说,在Python中,主要通过引用计数进行垃圾回收;通过 “标记-清除” 解决容器对象可能产生的循环引用问题;通过 “分代回收” 以空间换时间的方法提高垃圾回收效率。
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/282478.html