一、简介
储备知识
堆区与栈区
在定义变量时,变量名与变量值都是需要存储的,分别对应内存中的两块区域:堆区与栈区。
1、变量名与值内存地址的关联关系存放于栈区
2、变量值存放于堆区,内存管理回收的则是堆区的内容
直接引用与间接引用
直接引用指的是从栈区出发直接引用到的内存地址。
x = 1 # 直接引用1
间接引用指的是从栈区出发引用到堆区后,再通过进一步引用才能到达的内存地址。
x = 1
list1 = [x,2,3] # 间接引用1
python的垃圾回收机制,以引用计数为主,标记清除和分代回收为辅。
1. 引用计数
关于引用计数,我们需要了解一下python的对象。
我们目前使用的基本都是cpython,cpython对象的核心则是一个c语言的结构体,
typedef struct_object {
int ob_refcnt;
struct_typeobject *ob_type;
} PyObject;
不懂结构体没有关系,你只需要知道ob_refcnt记录了对象的引用次数就好了。当一个对象有新的引用时,ob_refcnt加1,引用它的对象被删除时,ob_refcnt减1,当ob_refcnt=0时,这个对象的生命就结束了,这时,垃圾回收机制就会启动将这个对象回收。
class Pyobj:
def __del__(self):
print("对象被销毁")
print("1")
obj = Pyobj()
obj = 6 # 让变量obj指向其他对象
print("2")
当obj = 6 这行被执行时,__del__方法会被执行,print("对象被销毁") 会先于print("2") 执行。
程序输出结果
1
对象被销毁
2
理论与实践完美结合,下图从内存层面上展示了这一过程
引用计数的优点
- 简单
- 实时性高,只要引用计数为0,对象就会被销毁,内存被释放,回收内存的的时间平摊到了平时
引用计数的缺点
- 为了维护引用计数消耗了很多资源
- 循环引用,循环引用导致内存泄漏,例如下面的代码
下面是一个循环引用的例子
list1 = []
list2 = []
list1.append(list2)
list2.append(list1)
list1 和 list2的引用计数永远大于0,除非手动操作,他们不可能被GC回收,但如果你手动将其释放回收,那么GC机制岂不是形同虚设?针对这种情况,python引入了标记清除和分代回收机制作为补充。
2. 标记清除
引用计数,并不能解决所有的问题,一旦出现了循环引用,那么,这些对象的引用次数永远都是大于0的,但是这些对象都是不可用的垃圾数据。下面的代码展示了一种循环引用的情况。
import gc
class DictA(dict):
def __del__(self):
print('DictA对象被销毁')
class DictB(dict):
def __del__(self):
print('DictB对象被销毁')
为了便于观察循环引用导致的内存泄漏问题,我定义了两个类,DictA,和DictB两个类,他们均继承了字典类。
下面的代码,先为你演示没有循环引用的情况
a = DictA()
b = DictB()
a = 1
b = 1
print('ok')
下图是内存层面的对象示意图。
a = 1 这样代码被执行时,变量a指向了内存中的1, 那么原来所指向的那字典对象的引用计数就变成了0,因此对象被销毁,程序运行的结果是是
DictA对象被销毁
DictB对象被销毁
ok
注意,ok是最后被输出的
下面的代码,演示有循环引用的情况
a = DictA()
b = DictB()
a['b'] = b # 循环引用
b['a'] = a
a = 1
b = 1
print('ok')
由于存在循环引用,因此,内存中DictA对象的引用计数是2,当a = 1被执行时,引用计数减少为1,但仍然大于0,不会被回收,DictB的对象同样如此,下图是存在循环引用时的内存对象示意图
程序的执行结果为
ok
DictA对象被销毁
DictB对象被销毁
对象销毁的信息是在print('ok')以后才被输出的,这说明,当a = 1被执行时,原来a所指向的那个字典对象并没有被销毁。
标记清除的原理
标记清除可以处理这种循环引用的情况,它分为两个阶段
第1阶段,标记阶段
GC会把所有活动对象打上标记,这些活动的对象就如同一个点,他们之间的引用关系构成边,最终点个边构成了一个有向图,如下图所示
第2阶段,搜索清除阶段
从根对象(root)出发,沿着有向边遍历整个图,不可达的对象就是需要清理的垃圾对象(如上图的红色点,就是不可达的)。这个根对象就是全局对象,调用栈,寄存器。
在上图中,从root出发后,可以到达 1 2 3 4,而5, 6, 7均不能到达,其中6和7互相引用,这3个对象都会被回收。
3. 分代回收
分代回收建立标记清除的基础之上,是一种以空间换时间的操作方式。标记清除可以回收循环引用的垃圾,但是,回收的频次是需要控制的,如果时时刻刻做标记清除,可以想象,python的程序会慢成什么样子。
分代回收,根据内存中对象的存活时间将他们分为3代,新生的对象放入到0代,如果一个对象能在第0代的垃圾回收过程中存活下来,GC就会将其放入到1代中,如果1代里的对象在第1代的垃圾回收过程中存活下来,则会进入到2代。
分代回收的触发机制
import gc
print(gc.get_threshold())
上面的代码执行结果是(700, 10, 10)
- 当分配对象的个数减去释放对象的个数的差值大于700时,就会产生一次0代回收
- 比如我一次性申请了一千个对象,然后一段时间后发现之销毁了100个,那么此时差值为900,就会再产生一个0代回收,以此类推
- 10次0代回收会导致一次1代回收
- 10次1代回收会导致一次2代回收
对于第0代的对象来说,他们很可能就被使用一次,因此需要经常被回收。
经过一轮一轮的回收后,能够活着成为第2代的对象,必然是那些使用频繁的对象,而且他们已经存活很久的时间了,大概率的,还会存活很久,因此,2代回收的就不那么频繁,
你可以通过设置这三个阈值,来改变分代回收的触发条件
import gc
gc.set_threshold(600, 10, 5)
print(gc.get_threshold())
经过了上面的设置,0代和2代的回收会更加频繁