Python中的内存管理
内存是一本空白的书
可以将计算机的内存想像成一本用来写短篇小说的空书。它的页面上还没有任何的内容,但是到了后面,不同的作者将会出现。每个作者都想要一些空间来书写它们的内容。
由于它们不允许书写在不属于它们的页,因此它们必须小心写内容在页面上,在它们开始写作之前,它们会咨询负责管理这本书的“经理”,然后由经理来决定允许它们写书的位置。
由于这本书已经存在了很长的时间,所以其中许多故事都已经不再适用。当没有人阅读或引用这些故事时,它们会被移除来为新故事腾出空间。
从本质上来讲,计算机内存就像是这本空书。事实上,调用固定长度的连续内存页块是很常见的。
作者就是需要将数据存储在内存中的不同应用程序或者进程。经理,决定作者在书中可以写的内容,扮演各种记忆管理者的角色,删除旧故事以便为新故事腾出空间的人是垃圾收集者。
内存管理:从硬件到软件
内存管理是应用程序读写数据的过程。内存管理器确定应用程序数据的放置位置。由于内存是有限的,就像我们的书中的页面,管理者必须找到一些可用空间并将其提供给应用程序。这种提供存储空间的过程通常称为内存分配。
另一方面,当不再需要数据时,可以删除或者释放数据。但释放到哪里?
在计算机的某个地方,有一个物理设备在运行Python程序时存储数据。在对象实际到达硬件之前,Python代码经历了许多抽象层。
硬件上方的主要层之一(例如RAM或硬盘驱动器)是操作系统。它执行(或拒绝)读写内存的请求。
在操作系统之上,有一些应用程序,其中一个是CPython。Python代码的内存管理由Python应用程序处理。Python的程序中用于内存管理的算法和结构是本文的重点。
CPython
默认的Python解释器,CPython,实际上是用C语言编写的。
我们还需要在计算机上执行解释型代码。CPython可以满足这些需求。它将Python代码转换为指令,随后在虚拟机上运行。
- **注意:**虚拟机就像物理计算机,但它们是用软件实现的。它们通常处理类似于汇编指令基本指令。
Python是一种解释性编程语言。您的Python代码实际上被编译为更多计算机可读的指令,称为字节码。运行代码时,这些指令将由虚拟机解释。
见过.pyc
文件或__pycache__
文件夹吗?这是由虚拟机解释的字节码。
值得注意的是,除了CPython之外还有其他解释器。例如Jython编译为Java字节码以在Java虚拟机上运行。
本文将重点介绍由Python的默认解释器CPython完成的内存管理。
好的,所以CPython是用C语言编写的,它解释了Python字节码。这与内存管理有什么关系?那么,用于内存管理的算法和结构存在于CPython代码中,在C中。要理解Python的内存管理,你必须对CPython本身有一个基本的了解。
CPython是用C语言编写的,它本身不支持面向对象的编程。因此,CPython代码中有相当多的有趣设计。
Python中的所有内容都是一个对象,甚至类似于int
和str
。这在CPython的实现级别上确实如此。在CPyhton中,有一个自定义的结构体叫做 PyObject
,CPython中的每个对象都使用它。
注意: C中的 struct
或结构体是一种自定义数据类型,它将不同的数据类型组合在一起。要与面向对象的语言进行比较,它就像一个具有属性而没有方法的类。
PyObject
是Python中的所有对象的父对象,它只包含两个东西:
- **ob_refcnt:**引用计数
- **ob_type:**指向另一种类型的指针
引用计数用于垃圾回收(GC)。然后你有一个指向实际对象类型的指针。该对象类型只是结构体用于描述Python对象(如a dict
或int
)的另一种类型。
每个对象都有自己特定于对象的内存分配器,它知道如何获取内存来存储该对象。每个对象还有一个特定于对象的内存释放器,一旦不再需要就会“释放”内存。
但是,所有关于分配和释放内存的讨论都有一个重要因素。内存是计算机上的共享资源,如果两个不同的进程尝试同时写入同一位置,则可能会发生错误。
垃圾收集
假设现在内存中的一些数据已经存放了过长时间,并有一段时间没有进程或线程去访问并引用了,那么这时候就需要释放它以此来腾出空间。
如果内存中要释放的的是Python中的对象,则代表着这个对象的引用计数已经降至0。还记得上面有说到Python总每个对象都有一个引用计数和一个指向数据类型的指针。
引用计数增加的原因有几个。例如,如果将引用计数分配给另一个变量,则引用计数将会增加:
numbers = [1, 2, 3]
# Reference count = 1
more_numbers = numbers
# Reference count = 2
如果将对象作为参数来传递,引用计数也会增加:
total = sum(numbers)
当将对象放入列表中时,引用变量也会增加:
matrix = [numbers, numbers, numbers]
可以使用sys模块来检查对象当前的引用计数。
import sys
a = []
b = a
sys.getrefcount(a) # 查看空列表的引用次数。
要注意的是,当使用sys.getrefcount()方法的时候,对象的引用计数也会加1。
在任何情况下,如果仍然需要在代码中挂起对象,则需要其引用计数大于0。一旦它降低到0,该对象就有一个特定的释放函数,它被调用来“释放”内存,以便其他对象可以使用它。
但是“释放”内存是什么意思,其他对象如何使用呢?
CPython的内存管理
如上所述,从物理硬件到CPython都有一些抽象层。操作系统抽象了物理内存并创建应用程序可以访问的虚拟内存层。
特定于操作系统的虚拟内存管理器为Python进程划分出一块内存。下图中较暗的灰色框现在由Python进程拥有。
Python将内存的一部分用于内部使用和非对象内存。另一部分专用于对象存储(int
,dict
等)
CPython有一个对象分配器,负责在对象存储的内存区域内分配内存。这个对象分配器是大多数操作发生的地方。每次新对象需要分配或删除空间时都会调用它。
通常,添加和删除Python对象里的数据,例如list
和int
,一次关于int
的操作不涉及太多数据。因此,分配器的设计经过调整,可以同时处理少量数据。它也尝试在不得不需要之前不给其分配内存。
CPython源代码中的注释将对象分配器描述为“一种用于小块的快速专用内存分配器,用于通用malloc之上。”在本例中,malloc
是C语言用于内存分配的库函数。
现在来看看CPython的内存分配策略。首先,先看看3个主要部分以及它们如何相互关联。
Arenas是最大的内存块,并在内存中的页面边界上对齐。页面边界是操作系统使用的固定长度连续内存块的边缘。Python假设系统的页面大小为256k字节。
在Arenas内的Pool,它是一个虚拟内存页(4k字节)。这些就像之前类比的书中的页面。这些Pools被分割成较小的内存块。
给定的Pool中的所有块都具有相同的“大小等级”。在给定一定量的请求数据的情况下,一个大小等级定义特定的块大小。
Request in bytes | Size of allocated block | Size class idx |
---|---|---|
1-8 | 8 | 0 |
9-16 | 16 | 1 |
17-24 | 24 | 2 |
25-32 | 32 | 3 |
… | … | … |
505-512 | 512 | 63 |
举个例子,如果请求的字节数为42个字节,则将数据存入48字节大小的块中。
Pool
Pools由单一大小等级的块组成。每个Pool都为同一大小等级的其他Pool维护一个双向链表。通过这种方式,即使跨不同的Pool,算法也可以轻松找到给定块大小等级的可用空间。
一个usedpools
列表跟踪为每个大小等级提供数据空间的所有pool。当请求给定的块大小时,算法会在此usedpools
列表中检查该块大小的pool列表。
Pool本身必须在3个状态之一:used
,full
或empty
。一个used
状态的Pool有可用的块存储数据。一个full
状态的Pool的内存块都被已分配并包含着数据。一个empty
的Pool没有存储的数据,并且在需要时可被分配任何大小等级的内存块。
一个freepools
列表跟踪的所有Pool的empty
状态。但是什么时候空Pool被使用?
假设您的代码需要一个8字节的内存块。如果usedpools
8字节等级中没有Pool,freepools
则初始化新池以存储8字节块。然后,此新池将添加到usedpools
列表中,以便可以将其用于将来的请求。
假设full
内存池释放了一些块,因为不再需要内存。该Pool将被添加回usedpools
其大小等级的列表中。
现在可以看到内存池如何使用算法自由地在这些状态(甚至内存大小等级)之间移动。
Block
如上图所示,内存池包含指向其“free”内存块的指针。它的工作方式略有细微差别。根据源代码中的注释,这个分配器“在所有级别(Arena,Pool,Block)都不会触及任何内存,直到实际需要它为止”。
这意味着内存池可以在3个区域中拥有块。这些状态可以定义如下:
- **untouched:**未分配的内存部分
- **free:**已分配但稍后由CPython“释放”并且不再包含相关数据的内存部分
- **allocated:**实际包含相关数据的内存的一部分
freeblock
指针指向的内存空闲块的单链表。换句话说,就是放置数据的可用位置的列表。如果需要超过可用的空闲块,则分配器将在内存池中获得一些untouched
块。
当内存管理器使内存块“free”时,那些现在的free
块被添加到freeblock
列表的前面。实际的列表可能不是连续的内存块,就像第一个漂亮的图表一样。它可能类似于下图:
Arenas
Arenas包含Pools。这些内存池可以是used
,full
或empty
。虽然Arenas本身没有明确的状态。
相反,Arenas被组织成一个名为usable_arenas
的双向链表。该列表按可用空闲内存池的数量排序。空闲的内存池越少,Arenas越接近列表的前面。
这意味着分配器将选择有着最多数据的Arenas来放置新数据。但为什么不相反呢?为什么不将数据放在最多空间的地方?
这带给我们真正释放内存的想法。原因是当一个块被认为是“free”时,该内存实际上并没有释放回操作系统。Python进程依旧保持内存块allocated
状态,稍后将用于新数据。真正释放内存会将其返回给操作系统使用。
Arenas是唯一可以真正释放的东西。因此,理所当然地认为那些更接近空的Arenas应该被允许变empty状态。这样做的好处是,可以真正释放那块内存,从而减少Python程序的整体内存占用。