python 垃圾回收机制
python 程序在运行时,需要在内存中开辟出一块空间,用于存放运行时产生的临时变量,计算完成后,再将结果输出到永久性存储器中。但是当数据量过大,或者内存空间管理不善,就很容易出现内存溢出的情况,程序可能会被操作系统终止。
而对于服务器这种用于永不中断的系统来说,内存管理就显得更为重要了,不然很容易引发内存泄漏。
这里的内存泄漏是指程序本身没有设计好,导致程序未能释放已不再使用的内存,或者直接失去了对某段内存的控制,造成了内存的浪费。
一、python 的垃圾回收如何实现
1、引用计数为主(缺点:循环引用无法解决)
2、引用标记清除和分代回收解决引用计数的问题
3、引用计数为主 + 标记清除和分代回收为辅
二、引用计数
在学习 python 的整个过程中,我们一直在强调,python 一切皆对象,也就是说,在 python 中你用到的一切变量,本质上都是类对象。
那么,如何知道一个对象永远都不能再使用了呢?很简单,就是当这个对象的引用计数为 0 时,说明这个对象永不再用,自然它就变成了垃圾需要被回收。
引用计数机制:python 里一切皆对象,它们的核心就是一个结构体:PyObject
typedef struct_object {
int ob_refcnt;
struct_typeobject *ob_type;
}PyObject;
PyObject 是每个对象必有的内容,其中 ob_refcnt 就是作为引用计数。当一个对象有新的引用时,它的 ob_refcnt 就会增加1,当引用它的对象被删除或该对象的引用失效时,它的 ob_refcnt 就会减少 1,一旦对象的引用计数为 0,该对象立即被回收,对象占用的内存空间将被释放。
1、优点:
(1) 简单;
(2) 实时性:一旦没有引用,内存就直接释放了。不用像其他机制等到特定时机。实时性还带来了一个好处:处理回收内存的时间分摊到了平时。
2、缺点:
(1) 需要额外的空间维护引用计数,消耗资源;
(2) 循环引用
import os, sys
import psutil
# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
show_memory_info('after a, b created')
a.append(b)
b.append(a)
print("a reference count: ", sys.getrefcount(a))
print("a reference count: ", sys.getrefcount(b))
func()
show_memory_info('finished')
# 运行结果:
initial memory used: 6.50390625 MB
after a, b created memory used: 781.88671875 MB
a reference count: 3
a reference count: 3
finished memory used: 781.88671875 MB
程序中,a 和 b 相互引用,并且作为局部变量在函数 func 调用结束后,a 和 b 这两个指针从程序意义上已经不存在,但从输出结果中看到,依然有内存占用,这是为什么呢?因为互相引用导致它们的引用计数都不为0。
有读者可能会说,互相引用还是很容易被发现的呀,问题不大。可是,更隐蔽的情况是出现一个引用环,在工程代码比较复杂的情况下,引用环真不一定能被轻易发现。那应该怎么做呢?
事实上,python 本身能够处理这种情况,可以显式调用 gc.coolect() 来启动垃圾回收,例如:
import os, sys, gc
import psutil
# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
show_memory_info('after a, b created')
a.append(b)
b.append(a)
print("a reference count: ", sys.getrefcount(a))
print("a reference count: ", sys.getrefcount(b))
func()
gc.collect()
show_memory_info('finished')
# 运行结果:
initial memory used: 6.578125 MB
after a, b created memory used: 781.34765625 MB
a reference count: 3
a reference count: 3
finished memory used: 6.90625 MB
创建 a 和 b 两个列表之后,a 和 b 两个对象的引用计数为1;
a.append(d)、b.append(a) 引用之后,a 和 b 两个对象的引用计数为2。
为什么最后获取到 a 和 b 的引用计数都为3呢?
使用 sys.getrefcount(a) 可以查看 a 对象的引用计数,但是比正常数大 1,因为调用函数的时候传入 a,这会让 a 的引用计数 +1。
3、示例:
import os, sys, gc
import psutil
# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
func()
show_memory_info('finished')
# 运行结果:
initial memory used: 6.59765625 MB
after a created memory used: 393.90625 MB
finished memory used: 6.82421875 MB
注意:执行此程序之前,需安装 psutil 模块(获取系统信息的模块),可使用 pip 命令直接安装,执行命令为$pip install psutil,如果遇到 Permission denied 安装失败,请加上 sudo 重试。
可以看到,当调用函数 func() 且列表 a 被创建之后,内存占用迅速增加到了 393 MB,而在函数调用结束后,内存则返回正常。这是因为,函数内部申明的列表 a 是局部变量,在函数返回后,局部变量的引用会注释掉,此时列表 a 所指代对象的引用计数为0,python 变回执行垃圾回收。因此之前占用的大量内存就又回来了。
明白这个原理后,稍微修改上面的代码,如下所示:
import os, sys, gc
import psutil
# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
def func():
show_memory_info('initial')
global a
a = [i for i in range(10000000)]
show_memory_info('after a created')
func()
show_memory_info('finished')
# 运行结果:
initial memory used: 6.484375 MB
after a created memory used: 393.85546875 MB
finished memory used: 393.85546875 MB
上面这段代码中,global a 表示将 a 申明为全局变量,则即使函数返回后,列表的引用仍然存在,于是 a 对象就不会被当做垃圾回收掉,依然占用大量内存。
同样,如果生成的列表返回,然后在主程序中接收,那么引用依然存在,垃圾回收也不会被触发,大量内存仍然被占用着。
import os, sys, gc
import psutil
# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
return a
a = func()
show_memory_info('finished')
# 运行结果:
initial memory used: 6.51953125 MB
after a created memory used: 394.41796875 MB
finished memory used: 394.41796875 MB
以上是最常见的几种情况,下面由表及里,深入看一下 python 内部的引用计数机制。先来分析一段代码:
import sys
a = []
# 两次引用,一次来自 a,一次来自 getrefcount
print(sys.getrefcount(a))
def func(a):
# 四次引用,一次来自a,python 的函数调用栈,函数参数,和 getrefcount
print(sys.getrefcount(a))
func(a)
# 两次引用,一次来自 a,一次来自 getrefcount,函数 func 调用已经不存在
print(sys.getrefcount(a))
运行结果:
另一个要注意的是,在函数调用发生的时候,会产生额外的两次引用,一次来自函数栈,另一次是函数参数。
import sys
a = []
print(sys.getrefcount(a)) # 2次
b = a
print(sys.getrefcount(a)) # 3次
c = b
d = b
e = c
f = e
g = d
print(sys.getrefcount(a)) # 8次
运行结果:
分析一下这段代码,a、b、c、d、e、f、g 这些变量全部指代的是同一个对象,而 sys.getrefcount() 函数并不是统计一个指针,而是要统计一个对象被引用的次数,所以最后一共会有 8 次引用。
理解引用这个概念后,引用释放释放是一种非常自然和清晰的思想。相比 C 语言中需要使用 free 去手动释放内存,python 的垃圾回收在这里可以说是省力省心了。
不过还有读者会好奇,如果想手动释放内存,应该怎么做呢?方法同样很简单,只需要先调用 del a 来删除一个对象,然后强制调用 gc.collect() 即可手动启动垃圾回收。例如:
import os, sys, gc
import psutil
# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
del a
gc.collect()
show_memory_info('finished')
print(a)
运行结果:
是不是觉得垃圾回收非常简单呢?这里再问大家也一个问题:引用次数为 0 是垃圾回收启动的充要条件吗?还有没有其他可能性呢?
答:引用计数是其中最简单的实现,引用计数并非充要条件,它只能算作充分非必要条件,至于其他的可能性,循环引用正是其中的一种。
三、标记清除
标记清除(Mark-Sweep)算法是一种基于追踪回收(tracing GC)技术实现的垃圾回收算法。它分为两个阶段:第一阶段是标记阶段,GC 会把所有的 "活动对象" 打上标记,第二阶段是把那些没有标记的对象 "非活动对象" 进行回收。那么GC 又是如何判断哪些是活动对象哪些是非活动对象的呢?
对象之间通过引用(指针)连在一起,构成一个有向图,对象构成这个有向图的节点,而引用关系构成这个有向图的边。从根对象(root object)出发,沿着有向边遍历对象,可达的(reachable)对象标记为活动对象,不可达的对象就是要被清除的非活动对象。根对象就是全局变量、调用栈、寄存器。mark-sweep 在上图中,我们把小红圈视为全局变量,也就是把它作为 root object,从小红圈出发,可达对象被标记为绿色,即为活动对象,未被标记为绿色的对象是非活动对象会被 GC 回收。
标记清除算法作为 python 的辅助垃圾收集技术主要处理的是一些容器对象,比如 list、dict、tuple、instance等,因为对于字符串、数值对象是不可能造成循环引用的问题。python 使用一个双向链表将这些容器对象组织起来。不过,这种简单粗暴的标记清除算法也有明显的缺点:
清除非活动对象前它必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。
四、分代回收
分代回收是一种以空间换时间的操作方式,python 将内存根据对象的存活时间划分为不同的集合,每个集合称为一个代,python 将内存分为了 3 代,分别为年轻代(第0代)、中年代(第1代)、老年代(第2代),它们对应的是 3 个链表,它们的垃圾收集频率随对象的存活时间增大而减小。
新创建的对象都会分配在年轻代,年轻代链表的总数达到上限时,python 垃圾收集机制就会被触发,把那些可以被回收的对象回收掉,而那些不会回收的对象就会被移到中年代中去,以此类推,老年代中的对象是存活时间最久的对象,甚至是存活于整个系统的生命周期内。而每一代启动自动垃圾回收的阙值,则是单独指定的。当垃圾回收器中新增对象减去删除对象达到相应的阙值时,就会对这一代对象启动垃圾回收。
同时,分代回收是建立在标记清除技术基础之上。分代回收同样作为 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):
a、当计数器从(699,3,0)增加到(700,3,0),gc模块就会执行gc.collect(0),即检查一代对象的垃圾,并重置计数器为(0,4,0)
b、当计数器从(699,9,0)增加到(700,9,0),gc模块就会执行gc.collect(1),即检查一、二代对象的垃圾,并重置计数器为(0,0,1)
c、当计数器从(699,9,9)增加到(700,9,9),gc模块就会执行gc.collect(2),即检查一、二、三代对象的垃圾,并重置计数器为(0,0,0)