Python 的内存管理与垃圾回收机制
Python 的内存管理机制:引用计数、垃圾回收、内存池机制
# 变量与对象
关系图如下:
- 变量,通过变量指针引用对象
变量指针指向具体对象的内存空间,取对象的值。
- 对象,类型已知,每个对象都包含一个头部信息(头部信息:类型标识符和引用计数器)
注意:
变量名没有类型,类型属于对象(因为变量引用对象,所以类型随对象),变量引用什么类型的对象,变量就是什么类型的。
In [32]: var1=object
In [33]: var2=var1
In [34]: id(var1)
Out[34]: 139697863383968
In [35]: id(var2)
Out[35]: 139697863383968
2
3
4
5
6
7
PS:id()是 python 的内置函数,用于返回对象的身份,即对象的内存地址。
In [39]: a=123
In [40]: b=a
In [41]: id(a)
Out[41]: 23242832
In [42]: id(b)
Out[42]: 23242832
In [43]: a=456
In [44]: id(a)
Out[44]: 33166408
In [45]: id(b)
Out[45]: 23242832
2
3
4
5
6
7
8
9
10
11
12
- 引用所指判断
通过 is 进行引用所指判断,is 是用来判断两个引用所指的对象是否相同。
整数
In [46]: a=1
In [47]: b=1
In [48]: print(a is b)
True
2
3
4
短字符串
In [49]: c="good"
In [50]: d="good"
In [51]:
print(c is d)
True
2
3
4
5
长字符串
In [52]: e="very good"
In [53]: f="very good"
In [54]: print(e is f)
False
2
3
4
字符串驻留
Python的字符串驻留(Interning)机制是指对于想通的字符串对象,只保存一份,放在一个字符串驻留池中,多个引用指向同一个字符串对象。这样可以节省存储空间,提高程序运行效率。
在Python中,以下情况下会发生字符串驻留:
- 所有长度为0和长度为1的字符串都会驻留。
- 字符串驻留是在编译时完成的,在运行时的不驻留(比如拼接字符串)。
- 字符串包含的字符全部是字母、数字或下划线。例如"abc", "123", "_abc", "abc123"等。
需要注意的是,Python的字符串驻留机制并不是强制的,而是由Python解释器决定的。即使两个字符串内容相同,也可能不会被驻留到同一个内存地址,这取决于Python解释器的实现方式和运行环境。例如在交互式环境和函数内部,相同内容的字符串可能不会被驻留到同一个内存地址。
列表
In [55]: g=[]
In [56]: h=[]
In [57]: print(g is h)
False
2
3
4
由运行结果可知:
Python 缓存了整数和短字符串,因此每个对象在内存中只存有一份,引用所指对象就是相同的,即使使用赋值语句,也只是创造新的引用,而不是对象本身;
Python 没有缓存长字符串、列表及其他对象,可以由多个相同的对象,可以使用赋值语句创建出新的对象。
Python 缓存重用机制 (opens new window)
数据类型 | 是否可以重用 | 生效范围 |
---|---|---|
范围在 [-5, 256] 之间的小整数 | 如果之前在程序中创建过,就直接存入缓存,后续不再创建。 | 全局 |
bool 类型 | ||
字符串类型数据 | ||
大于 256 的整数 | 只要在本代码块内创建过,就直接缓存,后续不再创建。 | 本代码块 |
大于 0 的浮点型小数 | ||
小于 0 的浮点型小数 | 不进行缓存,每次都需要额外创建。 | |
小于 -5 的整数 |
参考:python 的变量缓存机制 - SegmentFault 思否 (opens new window)
# 垃圾回收机制
# 引用计数(reference counting)
在 Python 中,主要通过引用计数(Reference Counting)进行垃圾回收。
typedef struct_object {
int ob_refcnt;
struct_typeobject *ob_type;
} PyObject;
2
3
4
在 Python 中每一个对象的核心就是一个结构体 PyObject,它的内部有一个引用计数器(ob_refcnt)。程序在运行的过程中会实时的更新 ob_refcnt 的值,来反映引用当前对象的名称数量。当某对象的引用计数值为 0,那么它的内存就会被立即释放掉。
每个对象都有指向该对象的引用总数——引用计数
查看对象的引用计数:sys.getrefcount()
。
- 普通引用
In [2]: from sys import getrefcount
In [3]: a=[1,2,3]
In [4]: getrefcount(a)
Out[4]: 2
In [5]: b=a
In [6]: getrefcount(a)
Out[6]: 3
In [7]: getrefcount(b)
Out[7]: 3
2
3
4
5
6
7
8
9
注意:
当使用某个引用作为参数,传递给 getrefcount()时,参数实际上创建了一个临时的引用。因此,getrefcount()所得到的结果,会比期望的多 1。
- 容器对象
Python 的一个容器对象(比如:list、dict 等),可以包含多个对象。
In [12]: a=[1,2,3,4,5]
In [13]: b=a
In [14]: a is b
Out[14]: True
In [15]: a[0]=6
In [16]: a
Out[16]: [6, 2, 3, 4, 5]
In [17]: a is b
Out[17]: True
In [18]: b
Out[18]: [6, 2, 3, 4, 5]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
由上可见,实际上,容器对象中包含的并不是元素对象本身,是指向各个元素对象的引用。
- 引用计数增加
对象被创建
In [39]: getrefcount(123) Out[39]: 6 In [40]: n=123 In [41]: getrefcount(123) Out[41]: 7
1
2
3
4
5对象被引用
In [42]: m=n In [43]: getrefcount(123) Out[43]: 8
1
2
3作为容器对象的一个元素
In [44]: a=[1,12,123] In [45]: getrefcount(123) Out[45]: 9
1
2
3被作为参数传递给函数:foo(x)
- 引用计数减少
对象的别名被显式的销毁
In [46]: del m In [47]: getrefcount(123) Out[47]: 8
1
2
3对象的一个别名被赋值给其他对象
In [48]: n=456 In [49]: getrefcount(123) Out[49]: 7
1
2
3对象从一个窗口对象中移除,或窗口对象本身被销毁
In [50]: a.remove(123) In [51]: a Out[51]: [1, 12] In [52]: getrefcount(123) Out[52]: 6
1
2
3
4
5一个本地引用离开了它的作用域,比如上面的 foo(x)函数结束时,x 指向的对象引用减 1。
引用计数法有其明显的优点,如高效、实现逻辑简单、具备实时性,一旦一个对象的引用计数归零,内存就直接释放了。不用像其他机制等到特定时机。将垃圾回收随机分配到运行的阶段,处理回收内存的时间分摊到了平时,正常程序的运行比较平稳。
但是,引用计数也存在着一些缺点,通常的缺点有:
逻辑简单,但实现有些麻烦。
每个对象需要分配单独的空间来统计引用计数,这无形中加大的空间的负担,并且需要对引用计数进行维护,在维护的时候很容易会出错。
在一些场景下,可能会比较慢。
正常来说垃圾回收会比较平稳运行,但是当需要释放一个大的对象时,比如字典,需要对引用的所有对象循环嵌套调用,从而可能会花费比较长的时间。
循环引用。
这将是引用计数的致命伤,引用计数对此是无解的,因此必须要使用其它的垃圾回收算法对其进行补充。
也就是说,Python 的垃圾回收机制,很大一部分是为了处理可能产生的循环引用,是对引用计数的补充。
- 垃圾回收
当 Python 中的对象越来越多,占据越来越大的内存,启动垃圾回收(garbage collection),将没用的对象清除。
当 Python 的某个对象的引用计数降为 0 时,说明没有任何引用指向该对象,该对象就成为要被回收的垃圾。比如某个新建对象,被分配给某个引用,对象的引用计数变为 1。如果引用被删除,对象的引用计数为 0,那么该对象就可以被垃圾回收。
In [74]: a=[321,123]
In [75]: del a
2
3
执行 del a
后,已经没有任何引用指向之前建立的[321,123],该列表引用计数变为 0,用户不可能通过任何方式接触或者动用这个对象,当垃圾回收启动时,Python 扫描到这个引用计数为 0 的对象,就将它所占据的内存清空。
# 标记-清除(mark and sweep)
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 随即释放之。
上面描述的垃圾回收的阶段,会暂停整个应用程序,等待标记清除结束后才会恢复应用程序的运行。
# 分代回收(generation collection)
上面说过,在解决循环引用对象的垃圾回收中,整个应用程序会被暂停。为了减少应用程序暂停的时间,Python 通过 “分代回收”(Generational Collection) 以空间换时间的方法提高垃圾回收效率。
分代回收的整体思想是:将系统中的所有内存块根据其存活时间划分为不同的集合,每个集合就成为一个“代”,垃圾收集频率随着“代”的存活时间的增大而减小,存活时间通常利用经过几次垃圾回收来度量。
Python 将内存根据对象的存活时间划分为不同的集合,每个集合称为一个代,Python 将内存分为了 3“代”,分别为年轻代(第 0 代)、中年代(第 1 代)、老年代(第 2 代),他们对应的是 3 个链表,它们的垃圾收集频率与对象的存活时间的增大而减小。新创建的对象都会分配在年轻代,年轻代链表的总数达到上限时,Python 垃圾收集机制就会被触发,把那些可以被回收的对象回收掉,而那些不会回收的对象就会被移到中年代去,依此类推,老年代中的对象是存活时间最久的对象,甚至是存活于整个系统的生命周期内。同时,分代回收是建立在标记清除技术基础之上。
Python 默认定义了三代对象集合,索引数越大,对象存活时间越长。
举例: 当某些内存块 M 经过了 3 次垃圾收集的清洗之后还存活时,我们就将内存块 M 划到一个集合 A 中去,而新分配的内存都划分到集合 B 中去。当垃圾收集开始工作时,大多数情况都只对集合 B 进行垃圾回收,而对集合 A 进行垃圾回收要隔相当长一段时间后才进行,这就使得垃圾收集机制需要处理的内存少了,效率自然就提高了。在这个过程中,集合 B 中的某些内存块由于存活时间长而会被转移到集合 A 中,当然,集合 A 中实际上也存在一些垃圾,这些垃圾的回收会因为这种分代的机制而被延迟。
分代回收是基于这样的一个统计事实:
对于程序,存在一定比例的内存块的生存周期比较短;而剩下的内存块,生存周期会比较长,甚至会从程序开始一直持续到程序结束。生存期较短对象的比例通常在 80%~90% 之间。 这种思想简单点说就是:对象存在时间越长,越可能不是垃圾,应该越少去收集。这样在执行标记-清除算法时可以有效减小遍历的对象数,从而提高垃圾回收的速度。
python gc 给对象定义了三种世代(0,1,2),每一个新生对象在 generation zero 中,如果它在一轮 gc 扫描中活了下来,那么它将被移至 generation one,在那里他将较少的被扫描,如果它又活过了一轮 gc,它又将被移至 generation two,在那里它被扫描的次数将会更少。
老年代(3)中的对象是存活时间最久的对象,甚至是存活于整个系统的生命周期内。同时,分代回收是建立在标记清除技术基础之上。
Q:gc 的扫描在什么时候会被触发呢?
- 当 gc 模块的计数器达到阈值的时候
当某一世代中被分配的对象与被释放的对象之差达到某一阈值的时候,就会触发 gc 对某一世代的扫描。 值得注意的是 当某一世代的扫描被触发的时候,比该世代年轻的世代也会被扫描。 也就是说如果世代 2 的 gc 扫描被触发了,那么世代 0,世代 1 也将被扫描,如果世代 1 的 gc 扫描被触发,世代 0 也会被扫描。
当 Python 运行时,会记录其中分配对象(object allocation)和取消分配对象(object deallocation)的次数。当两者的差值高于某个阈值时,垃圾回收才会启动。
该阈值可以通过下面两个函数查看和调整:
import gc
gc.get_threshold() #gc模块中查看阈值的方法
Out[94]: (700, 10, 10)
gc.set_threshold(threshold0[, threshold1[, threshold2]])
2
3
4
下面对 set_threshold()
中的三个参数 threshold0, threshold1, threshold2 进行介绍。
gc 会记录自从上次收集以来新分配的对象数量与释放的对象数量,当两者之差超过 threshold0 的值时,gc 的扫描就会启动,初始的时候只有世代 0 被检查。如果自从世代 1 最近一次被检查以来,世代 0 被检查超过 threshold1 次,那么对世代 1 的检查将被触发。相同的,如果自从世代 2 最近一次被检查以来,世代 1 被检查超过 threshold2 次,那么对世代 2 的检查将被触发。get_threshold()
是获取三者的值,默认值为(700,10,10).
阈值分析: 700 即是垃圾回收启动的阈值;
每 10 次 0 代垃圾回收,会配合 1 次 1 代的垃圾回收;而每 10 次 1 代的垃圾回收,才会有 1 次的 2 代垃圾回收;
手动启动垃圾回收:
In [95]: gc.collect() #手动启动垃圾回收 Out[95]: 2
1
2程序退出的时候
注意
垃圾回收时,Python 不能进行其它的任务,频繁的垃圾回收将大大降低 Python 的工作效率;
Python 只会在特定条件下,自动启动垃圾回收(垃圾对象少就没必要回收)
# 总结
为了解决“引用计数”导致的循环引用问题,引入了“标记清除”方案,而为了解决“标记清除”中的问题,又引入了“分代回收”技术。
# 内存池机制
详情 Python 内存池管理与缓冲池设计_张知临的专栏-CSDN 博客_python 内存池 (opens new window)
Python 中有分为大内存和小内存:(256K 为界限分大小内存)
- 大内存使用 malloc 进行分配
- 小内存使用内存池进行分配
Python 引用了一个内存池(memory pool)机制,即Pymalloc
机制(malloc:n.分配内存),用于对小块内存的申请和释放管理
- 内存池(memory pool)的概念
当创建大量消耗小内存的对象时,频繁调用 new/malloc 会导致大量的内存碎片,致使效率降低。内存池的概念就是预先在内存中申请一定数量的,大小相等的内存块留作备用,当有新的内存需求时,就先从内存池中分配内存给这个需求,不够了之后再申请新的内存。这样做最显著的优势就是能够减少内存碎片,提升效率。
- 内存释放
关于释放内存方面,当一个对象的引用计数变为 0 时,python 就会调用它的析构函数。调用析构函数并不意味着最终一定会调用 free 释放内存空间,如果真是这样的话,那频繁地申请、释放内存空间会使 Python 的执行效率大打折扣。因此在析构时也采用了内存池机制,从内存池申请到的内存会被归还到内存池中,以避免频繁地释放动作。
内存池的实现方式有很多,性能和适用范围也不一样。
- Python 中的内存管理机制——Pymalloc
Python 的内存池(金字塔)
第 3 层:即最上层,对于 python 内置的对象(比如 int,dict 等)都有独立的私有内存池,对象之间的内存池不共享,即 int 释放的内存,不会被分配给 float 使用。
第 1 层和第 2 层:内存池,由 Python 的接口函数 PyMem_Malloc 实现——若请求分配的内存在 1~256 字节之间就使用内存池管理系统进行分配,调用 malloc 函数分配内存,但是每次只会分配一块大小为 256K 的大块内存,不会调用 free 函数释放内存,将该内存块留在内存池中以便下次使用。
第 0 层:大内存——若请求分配的内存大于 256K,malloc 函数分配内存,free 函数释放内存。
第-1,-2 层:操作系统进行操作。
# 总结
总体而言,Python 通过内存池来减少内存碎片化,提高执行效率。主要通过引用计数来完成垃圾回收,通过标记-清除解决容器对象循环引用造成的问题,通过分代回收提高垃圾回收的效率。
# 参考链接
- 对象、类型和引用计数 — Python 3.9.2 文档 (opens new window)
- Python 内存管理机制 - GeaoZhang - 博客园 (opens new window)
- Python 垃圾回收机制 | Sutune (opens new window)
- 聊聊 Python 内存管理 | Andrew's Blog (opens new window)
- 面试必备:Python 内存管理机制 (opens new window)
- https://docs.python.org/2/library/gc.html (opens new window)
- Garbage collection in Python: things you need to know | Artem Golubin (opens new window)
- The Garbage Collector | Yet Another Python Internals Blog (opens new window)
- https://www.quora.com/How-does-garbage-collection-in-Python-work-What-are-the-pros-and-cons (opens new window)