Python 内存管理

栏目: Python · 发布时间: 5年前

内容简介:要不这样吧,如果编程语言里有个地方你弄不明白,而正好又有个人用了这个功能,那就开枪把他打死。这比学习新特性要容易些,然后过不了多久,那些活下来的程序员就会开始用 0.9.6 版的 Python,而且他们只需要使用这个版本中易于理解的那一小部分就好了(眨眼)。—— Tim Peters (传奇的核心开发者,“Python 之禅”作者)今天我们来聊一聊 Python 的内存管理。

要不这样吧,如果编程语言里有个地方你弄不明白,而正好又有个人用了这个功能,那就开枪把他打死。这比学习新特性要容易些,然后过不了多久,那些活下来的 程序员 就会开始用 0.9.6 版的 Python,而且他们只需要使用这个版本中易于理解的那一小部分就好了(眨眼)。

—— Tim Peters (传奇的核心开发者,“Python 之禅”作者)

今天我们来聊一聊 Python 的内存管理。

对象引用

变量定义

Python 不像多数的编译语言那样,需要对变量进行显示的声明。变量在第一次赋值时自动声明,变量只有被创建和赋值后才能被使用

>>> k
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    k
NameError: name 'k' is not defined
>>> k = 1
>>> k
1

动态类型

严格来说变量是没有类型的,对象才有类型,变量引用了什么类型的对象,它就是什么类型。

变量的类型无需声明,是在程序运行时动态确定的。

>>> k = 1
>>> type(k)
<class 'int'>
>>> k = "a"
>>> type(k)
<class 'str'>

对象引用

如何理解变量是对对象的引用呢?内置函数 id() 可以返回对象的内存地址,我们来看一个例子

>>> a = 1
>>> b = a
>>> id(1)
4432996208
>>> id(a)
4432996208
>>> id(b)
4432996208

给变量 a 赋值 1 ,又给 b 赋值了 a ,此时 b 并没有创建新对象,而是引向了 a 所指向的对象,换句话说 b 是对象的另一个别名。

Python 内存管理

现在考虑一个问题,如果此时将 a 赋值为 2b 等于多少?

>>> a = 1
>>> b = a
>>> a = 2
>>> b
1

如果明白了上面说的,可以得到 b 依然等于 1 ,因为 ab 都作为对象 1 的引用变量,现在只是将 a 引向其他对象, b 并不受影响。

Python 内存管理

如果这一点可以理解的话,再看一个问题

>>> a = [1, 2, 3]
>>> b = a
>>> a[0] = 4
>>> a
[4, 2, 3]
>>> b

这时输出 b 你觉得会返回什么结果呢?

变量 ab 都引用了列表对象 [1, 2, 3]a[0] = 4 只是将列表对象索引位置 0 的子对象替换为 4 ,变量 ab 对它的引用关系都没有改变,所以 b 也等于 [4, 2, 3]

引用计数

刚才我们讲 b = a 时,提到此时 b 并没有创建新的对象,而是引用了 a 所引用的对象。

每个对象都包含一个头部信息,内容为类型标识符和引用计数器(Reference counter)。当对象被创建时 a = [1, 2, 3] 该对象的引用计数设置为 1,当对象有一个新的引用时,引用计数会自动加 1。

增加引用计数

sys 模块中的 getrefcount() 函数可以获取对象的引用计数。当对象做参数传入该函数时,实际上也创建一个该对象的引用,所以返回值比预期会加 1。

>>> from sys import getrefcount
>>> a = [1, 2, 3]
>>> getrefcount(a)
2
>>> b = a
>>> getrefcount(a)
3

对象的引用计数增加,有下面几种情况

  • 对象被创建
>>> a = 256
>>> getrefcount(256)
114

114 这个数字有些过于超出我们的预期,我先按下不表,稍后的缓存池小节我会讲到。

  • 对象被其他变量引用
>>> b = a
>>> getrefcount(256)
115
  • 对象成为容器对象的一个元素
>>> l = [123, 256]
>>> getrefcount(256)
116
  • 对象作为参数传递给函数(新的本地引用) foo(n)

减少引用计数

有增加,就会有减少。对象的引用不会不休止的增加,下面几种情况引用计数会减少

  • 对象的引用变量被赋值给另一个对象时
>>> a = 256
>>> b = a
>>> getrefcount(256)
120
>>> a = 123
>>> getrefcount(256)
119
  • 对象的引用变量被显示的销毁
>>> del b
>>> getrefcount(256)
118

del 语句会产生两个结果

    • 从现在的变量名称空间中删除 b
    • 对象 256 的引用减少 1
  • 对象被一个容器对象中移除

>>> l = [123, 256]
>>> getrefcount(256)
118
>>> l.remove(256)
>>> getrefcount(256)
117
  • 容器对象本身被销毁
>>> l = [123, 256]
>>> getrefcount(256)
118
>>> del l
>>> getrefcount(256)
117
  • 一个本地引用离开其作用域时,比如 foo(n) 函数执行完毕时

当对象的最后一个引用被移除时,该对象的引用计数减少为 0,这会导致该对象 无法被访问 。该对象就会成为垃圾回收机制的回收对象,而任何对该对象的追踪或调试都将增加它的引用计数,这也将推迟该对象被回收的时间。

缓存池

刚才我抛出了一个问题,对象 256 刚被创建出来,引用计数就是 114 ,有点诡异。

其实 Python 内部存在一个缓存池,缓存池内的对象在内存中只存在一份。所有符合缓存规则的对象,如果该对象不存在,创建后会进入缓存池,以后再次调用只是增加引用计数,如果该对象已经存在,那么针对它的所有调用都只是增加它的引用计数,即不会增加新的内存地址。这有一个很明显的好处,缓存池中的对象都是程序中最常用到的,缓存池的机制将在一定程度上减少内存的消耗。

现在可以回答刚才的问题了, 256 对象身处缓存池中,所以我们以为 a = 256 是对它的创建,但其实只是某一个引用而已。

Python 的缓存池会包含三部分 布尔值小整数守规矩字符串

布尔值

布尔值比较好说,就两个值,任何情况下它都会被缓存

>>> a = True
>>> b = True
>>> a is b
True

小整数

这个小整数的范围为 [-5, 256],我们想办法对此做一些验证。Python 中判断两个对象的值是否相等时可以使用 == ,而判断两个对象是否为同一内存地址时需要使用 is

>>> a = 256
>>> b = 256
>>> a is b
True
>>> a = 257
>>> b = 257
>>> a is b
False

257 超出了整数缓存池的范围,所以每次赋值都会创建一个新对象,即内存地址不同,我们可以通过 id() 函数查看

>>> id(a)
4597659056
>>> id(b)
4592739216

所以一般情况下你看到的拥有两个相同值的变量,往往他们引向的是两个不同的对象。

守规矩字符串

字符串的缓存逻辑比较复杂,我将符合规则的字符串称为 守规矩字符串 ,规则有下面几种情况

  • 长度为 1 时
>>> a = "@"
>>> b = "@"
>>> a is b
True
  • 长度大于 1,并且只含有大小写字母、数字、下划线时
>>> a = "wo_ai_ni_zhongguo"
>>> b = "wo_ai_ni_zhongguo"
>>> a is b
True
  • 某些乘法时

    • 乘数为 1
      • 字符串长度等于 1
>>> a = "@" * 1
>>> b = "@" * 1
>>> a is b
True
      • 字符串长度大于 1,且只含有大小写字母、数字、下划线时
>>> a = "wo_ai_ni_zhongguo" * 1
>>> b = "wo_ai_ni_zhongguo" * 1
>>> a is b
True
    • 乘数大于 1,仅含大小写字母,数字,下划线,总长度<=20
>>> a = "wxnacy" * 3
>>> b = "wxnacy" * 3
>>> a is b
True

面对这么多的规则,完全靠记忆的话,在开发中难免有些畏手畏脚,并且通常我们涉及到的字符串都比较复杂,那么这时我们怎么优化内存的使用呢?

sys 模块中有一个函数 insern() ,可以将任何形式的字符驻留在缓存池中,同时也可以将该字符从缓存池中取出

>>> from sys import intern
>>> a = intern("12345!@#$%^qwertZXCV" * 100)
>>> b = intern("12345!@#$%^qwertZXCV" * 100)
>>> a is b
True
>>> c = "12345!@#$%^qwertZXCV" * 100
>>> a is c
False

每次使用都要调用该函数,才能保持字符串的内存地址一致,刚才我们提到的那些守规矩字符串内部也是这样实现的。我有理由相信,在任何需要字符串复用的地方都可以使用该函数,这将大大的优化程序的内存消耗

相同代码块

你以为到这就完了吗,再等等,还差点。

刚才我们的实验都是在不同代码块中进行的,在 Python 交互模式下,每个独立命令都是一个单独的代码块。而在相同的代码块下,缓存池的规则将简单很多。

任何的整数和布尔类型在相同代码块中都满足缓存机制

>>> def foo():
...     a = 1234567
...     b = 1234567
...     print(a is b)
...
...
>>> foo()
True

字符串依然要单拎出来说下

  • 非乘法得到的字符串都满足缓存机制
>>> def foo():
...     a = "1234567!@#$%^&QWERTY"
...     b = "1234567!@#$%^&QWERTY"
...     print(a is b)
...
...
>>> foo()
True
  • 乘法时,乘数为 1 时都满足缓存机制
>>> def foo():
...     a = "1234567!@#$%^&QWERTY" * 1
...     b = "1234567!@#$%^&QWERTY" * 1
...     print(a is b)
...
...
>>> foo()
True
  • 乘数大于 1 时,仅含大小写字母,数字,下划线,且总长度小于等于 20 满足缓存机制
>>> def foo():
...     a = "wxnacy" * 3
...     b = "wxnacy" * 3
...     print(a is b)
...
...
>>> foo()
True

除了上面说的类型,其它的类型如数组、字典等都不会进入缓存池

>>> a = [1, 2, 3]
>>> b = [1, 2, 3]
>>> a is b
False

垃圾回收

我们给变量分配的内存,其实是在借用系统资源,有借就要有还。还资源的步骤就是垃圾回收时进行的,Python 解释器将会承担内存分配回收的复杂任务,我们只需要关心我们的业务流程,以及尽量的了解垃圾回收的过程,并写出更好的代码。

引用计数为 0 的

前面我们提到,对象的引用会将它的引用计数加 1,而移除对它的引用会自动减 1。当对象的引用计数为 0 时,它将成为一个待回收的垃圾内存。

垃圾回收器会寻找这些引用计数为 0 的对象,垃圾回收时 Python 无法进行其它的任务,这个过程会将降低工作效率,所以当垃圾内存很少时没有必要频繁的执行垃圾回收。

当Python运行时,会记录其中分配对象(object allocation)和取消分配对象(object deallocation)的次数。当两者的差值高于某个阈值时,垃圾回收才会启动。

查看阈值

>>> import gc
>>> gc.get_threshold()
(700, 10, 10)

返回值解释

我们也可以手动启动垃圾回收

>>> gc.collect()
9

分代回收

Python 将所有的对象分为 0、1、2 代,所有新建的对象为 0 代,每经历一次垃圾回收,依然存活的对象,会归入下一代对象。

分代(generation)回收策略基于这样的假设,在程序运行过程中,存活时间越久的对象,越不容易在后面的程序中变成垃圾。对于经历了几次垃圾回收依然存活的对象,出于信任和效率的考虑,垃圾回收器会减少对它的扫描频率。

循环引用

相信有过开发经验的同学都会碰见循环引用的情况,两个对象相互引用,会构成 引用环(reference cycle)

>>> a = []
>>> b = [a]
>>> a.append(b)
>>> del a
>>> del b

在引用环中,即使删除了 ab 对象,但是仍然有部分对象,从此以后无法使用,引用计数也不为 0,这将给垃圾回收造成很大的麻烦。

我们来模拟一下这个无法使用、引用计数也不为 0 的对象。

>>> getrefcount("wxnacy")
2
>>> a = ["wxnacy"]
>>> getrefcount("wxnacy")
3
>>> b = [a]
>>> getrefcount("wxnacy")
3
>>> a.append(b)
>>> getrefcount("wxnacy")
3
>>> del a
>>> del b
>>> getrefcount("wxnacy")
3

相信看过前面章节后,很容易可以理解这个流程。我以短字符串 wxnacy 为参考对象,执行函数 getrefcount("wxnacy") 获取初始引用计数 2 (字符串本身被创建引用一次,getrefcount 函数本地引用一次),容器对象 a 引用加 1,直到 del a 前没有增加引用,而这一步删除容器对象的操作,本应该减少一次对 wxnacy 的引用次数,然后最后再次获取引用次数时,依然为 3, wxnacy 对象就将作为那个无法被使用,但是引用计数又不为 0 的对象遗漏在内存中。

为了回收这样的对象,Python 复制了每个对象的引用计数,记做 gc_ref ,假设,每个对象 i,该计数为 gc_ref_i 。Python 会遍历所有的对象 i。对于每个对象 i 引用的对象 j,将相应的 gc_ref_j 减 1。

在结束遍历后, gc_ref 不为 0 的对象,和这些对象引用的对象,以及更下游引用的对象,需要被保留。而其它的对象则被垃圾回收。

>>> gc.collect()
2

手动执行垃圾回收发现回收了两个对象,这说明这个简单的引用环就有两个无法被回收的对象,实际开发中这样的对象将是一个可怕的数字。

深入了解一门语言,理解内存管理机制是很有必要的。搞清楚内存分配和垃圾回收的过程,将会是提供程序性能的重要一步。现在再回过头来去看以前的代码,就会发现很多有风险的代码隐藏在其中,而现在能写出优秀的代码,也将是理所当然的。

参考文献


以上所述就是小编给大家介绍的《Python 内存管理》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

深入理解LINUX内核(第三版)

深入理解LINUX内核(第三版)

(美)博韦,西斯特 / 陈莉君;张琼声;张宏伟 / 中国电力出版社 / 2007-10-01 / 98.00元

为了彻底理解是什么使得Linux能正常运行以及其为何能在各种不同的系统中运行良好,你需要深入研究内核最本质的部分。内核处理CPU与外界间的所有交互,并且决定哪些程序将以什么顺序共享处理器时间。它如此有效地管理有限的内存,以至成百上千的进程能高效地共享系统。它熟练地统筹数据传输,这样CPU 不用为等待速度相对较慢的硬盘而消耗比正常耗时更长的时间。 《深入理解Linux内核,第三版》指导你对内核......一起来看看 《深入理解LINUX内核(第三版)》 这本书的介绍吧!

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

URL 编码/解码
URL 编码/解码

URL 编码/解码