0%

Python_内存管理

参考地址:博客一博客二博客三

Python内存管理是理解Python运行机制的重要一环,故整理下相关内容如下:

前置知识:Python变量与对象

我们在编程过程中,不可避免的使用变量指代对象,其关系为变量通过指针指向了对象;对象有自己的类型,而变量的类型则跟随者对象的类型而变化。关系如图:

变量:通过变量指针引用对象。而变量指针指向具体对象的内存空间;变量类型是动态的,与对象类型一致

对象:类型是已知的,每个对象都包含有头部信息,其中为类型标识符与引用计数

多变量指向同一对象情况

示例代码:

1
2
3
4
5
6
7
8
9
var_1 = 123
var_2 = var_1
print(id(var_1)) # 140711337393728
print(id(var_2)) # 140711337393728
print(id(123)) # 140711337393728

print(type(var_1)) # <class 'int'>
print(type(var_2)) # <class 'int'>
print(type(123)) # <class 'int'>

通过对象被引用的方式(var_2 = var_1),两个变量指向了同一地址,并且变量的类别与被引用对象的类别一致。简单示意图:

多变量引用所指的判断

在Python中判断多个变量的引用地址是否一致采用了内置函数is。先看一段代码再去讲。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 小于等于256的整数
intA = 256
intB = 256
print(id(intA)) # 140711337397984
print(id(intB)) # 140711337397984
intA is intB # True

# 大于256的整数
intA = 257 # 3037955041360
intB = 257 # 3037955041424
print(id(intA))
print(id(intB))
intA is intB # False

# 短字符串
charA = "aaaaaaaaaaa"
charB = "aaaaaaaaaaa"
charA is charB # True

# 长字符串
charA = "very Good"
charB = "very Good"
charA is charB # False

# 容器——列表
listA = []
listB = []
listA is listB # False

我们发现不同的数据类型或者同一数据类型不同“长度”时,多次创建时,其内存地址情况并不一致。这是由于Python会选择缓存常用的对象,使得多次赋值对象时,不需要创建新的对象,例如1~256的数字,短字符串等。基本上都是不可修改的数据类型。

  1. Python缓存了整数(1~256,短字符串),每个变量进行为赋值操作时,不需要创建新的对象,大家引用对象一致。
  2. Python对于未缓存的对象,在进行赋值操作时,会直接生成新的对象,占用新的内存地址。

Python的垃圾回收机制

一句话描述就是:引用计数为主,标记清除与分代回收为辅

引用计数

Python中主要使用引用计数(Reference Counting)进行垃圾回收。每一个对象的核心就是一个结构体PyObject,其内部存在一个引用计数器(ob_refcnt)。当某对象的引用计数值为0时,其内存就可以被释放掉;

1
2
3
4
 typedef struct_object {
int ob_refcnt;
struct_typeobject *ob_type;
} PyObject;

引起引用计数变化的情况

引用计数增加的情况:

  • 对象被创建,a = 2
  • 对象被引用,b=a
  • 对象作为参数传递到函数中,fun(a)
  • 对象作为元素存储在容器中,numList.append(a)

引用计数减少的情况:

  • 对象别名被显示销毁,del a
  • 对象别名赋予了新的对象,b = 3
  • 包含对象的函数运行结束,
  • 对象所在的容器被摧毁或者从容其中删除了对象,numList.remove(a)

获取引用数量的方式

通过sys包中的getrefcount()获取一个名称所引用的对象当前的引用计数,这个函数本身也会造成其引用+1.

1
sys.getrefcount(a)

引用计数的优缺点

优点:

  • 逻辑简单而高效,具备实时性;
  • 垃圾回收随机分配到运行阶段,处理回收时间分散有利于系统稳定;

缺点:

  • 空间浪费:每个对象都存在用来统计的引用的空间,加大了空间负担;
  • 大对象释放缓慢:当需要释放大的对象时,比如字典,需要对引用的所有对象循环嵌套调用;
  • 循环引用问题:引用计数的致命问题。

标记清除

标记清除(Mark and Sweep)算法,是为了解决对象可能产生的循环引用问题。(注意,只有容器对象才会产生循环引用的情况,比如列表、字典、用户自定义类的对象、元组等。而像数字,字符串这类简单类型不会出现循环引用。作为一种优化策略,对于只包含简单类型的元组也不在标记清除算法的考虑之列)。

算法

  1. 标记阶段,遍历所有对象,如果对象可达(表明存在对象引用它),则标记对象可达;
  2. 清除阶段,再次遍历所有对象,对于不可达的对象,进行回收;

如下图所示,在标记清除算法中,为了追踪容器对象,需要每个容器对象维护两个额外的指针,用来将容器对象组成一个双端链表,指针分别指向前后两个容器对象,方便插入和删除操作。python解释器(Cpython)维护了两个这样的双端链表,一个链表存放着需要被扫描的容器对象,另一个链表存放着临时不可达对象。在图中,这两个链表分别被命名为”Object to Scan”和”Unreachable”。图中例子是这么一个情况:link1,link2,link3组成了一个引用环,同时link1还被一个变量A(其实这里称为名称A更好)引用。link4自引用,也构成了一个引用环。从图中我们还可以看到,每一个节点除了有一个记录当前引用计数的变量ref_count还有一个gc_ref变量,这个gc_refref_count的一个副本,所以初始值为ref_count的大小。
img

gc启动的时候,会逐个遍历”Object to Scan”链表中的容器对象,并且将当前对象所引用的所有对象的gc_ref减一。(扫描到link1的时候,由于link1引用了link2,所以会将link2的gc_ref减一,接着扫描link2,由于link2引用了link3,所以会将link3的gc_ref减一…..)像这样将”Objects to Scan”链表中的所有对象考察一遍之后,两个链表中的对象的ref_countgc_ref的情况如下图所示。这一步操作就相当于解除了循环引用对引用计数的影响。
img

接着,gc会再次扫描所有的容器对象,如果对象的gc_ref值为0,那么这个对象就被标记为GC_TENTATIVELY_UNREACHABLE,并且被移至”Unreachable”链表中。下图中的link3和link4就是这样一种情况。
img

如果对象的gc_ref不为0,那么这个对象就会被标记为GC_REACHABLE。同时当gc发现有一个节点是可达的,那么他会递归式的将从该节点出发可以到达的所有节点标记为GC_REACHABLE,这就是下图中link2和link3所碰到的情形。
img

除了将所有可达节点标记为GC_REACHABLE之外,如果该节点当前在”Unreachable”链表中的话,还需要将其移回到”Object to Scan”链表中,下图就是link3移回之后的情形。
img
第二次遍历的所有对象都遍历完成之后,存在于”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也会被扫描。

该阈值可以通过下面两个函数查看和调整:

1
2
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).

总结

总体来说,在Python中,主要通过引用计数进行垃圾回收;通过 “标记-清除” 解决容器对象可能产生的循环引用问题;通过 “分代回收” 以空间换时间的方法提高垃圾回收效率。

内存池机制

Python中对于大内存(> 256 字节)与小内存(1 ~ 256字节)的分配机制是不同的。

为什么引入内存池

解决多次调用malloc导致大量内存碎片的问题。

内存池就是预先申请一定数量的,大小相同的内存块,当出现新的内存需求,可以选择从内存池分配内存;

大内存的分配机制:malloc

大内存—–若请求分配的内存大于256K,malloc函数分配内存,free函数释放内存。

小内存的分配机制:内存池

内存池,有Python的接口函数PyMem_Malloc实现—–若请求分配的内存在1~256字节之间就使用内存池管理系统进行分配,调用malloc函数分配内存,但是每次只会分配一块大小为256K的大块内存,不会调用free函数释放内存,将该内存块留在内存池中以便下次使用。

Python解释器的解释

  • python的对象管理主要位于Level+1~Level+3层
  • Level+3层:对于python内置的对象(比如int,dict等)都有独立的私有内存池,对象之间的内存池不共享,即int释放的内存,不会被分配给float使用
  • Level+2层:当申请的内存大小小于256KB时,内存分配主要由 Python 对象分配器(Python’s object allocator)实施
  • Level+1层:当申请的内存大小大于256KB时,由Python原生的内存分配器进行分配,本质上是调用C标准库中的malloc/realloc等函数.

关于释放内存方面,当一个对象的引用计数变为0时,Python就会调用它的析构函数。调用析构函数并不意味着最终一定会调用free来释放内存空间,如果真是这样的话,那频繁地申请、释放内存空间会使Python的执行效率大打折扣。因此在析构时也采用了内存池机制,从内存池申请到的内存会被归还到内存池中,以避免频繁地申请和释放动作.