计算机体系结构——虚拟内存
虚拟内存地址和物理内存地址
在早期的计算机系统中,计算机使用物理内存地址直接进行内存寻址,即把内存看成一个巨大的数组,使用每个内存单元的实际地址进行寻址。
现代计算机系统使用虚拟内存地址,即通过在 CPU 中内置一个地址转换器, CPU 将虚拟内存地址交给地址转换器,地址转换器将虚拟内存地址转换为物理内存地址交给内存进行寻址。
地址空间
一个地址空间是 { 0 , 1 , 2 , … } \{0,1,2,\ldots\} {0,1,2,…} 的整数集合,当这个集合是连续的成为线性地址空间。虚拟和物理内存地址空间都是线性地址空间。
虚拟地址空间是 { 0 , 1 , 2 , … N − 1 } \{0,1,2,\ldots\,N-1\} {0,1,2,…N−1} 的整数集合,其中 N = 2 n N = 2^n N=2n 一定是 2 的幂次,成为 n 位虚拟内存地址空间。现代操作系统通常使用 32 或 64 位虚拟内存地址空间。
物理地址空间是 { 0 , 1 , 2 , … M − 1 } \{0,1,2,\ldots\,M-1\} {0,1,2,…M−1} 其中 M M M 指的是内存大小,不一定是 2 的幂次。
虚拟内存作为缓存工具
从概念上将,虚拟内存指的是储存在磁盘上 { 0 , 1 , 2 , … N − 1 } \{0,1,2,\ldots\,N-1\} {0,1,2,…N−1} 连续的空间。这些空间被分成虚拟内存页,一个虚拟内存页。同样的,物理内存也被分成物理内存页。一个内存页的大小是 P = 2 p P = 2^p P=2p 。一个虚拟内存页能够被加载进一个物理内存页。我们说这段虚拟内存被映射成物理内存。
因此,虚拟内存页分成三个状态:
- 未分配。表示这个虚拟内存页没有被实际分配。因此不储存任何数据,也不占用磁盘空间。
- 已缓存。表示这个虚拟内存页被加载进物理内存。
- 未缓存。表示这个已分配的虚拟内存页还没有被加载进物理内存。
为了对时间和空间的舍弃,每个虚拟页的大小通常在 4-8 K 。
页表
CPU 中的内存管理单元,是利用内存中的页表进行维护虚拟内存页的信息,进而进行地址转换。页表由操作系统所维护。
在一个页表中,包含很多的实体。每个实体有两个字段,其一是有效位其二是地址位,如果有效位被设置,那么地址位指向的是该虚拟页在内存中的位置。如果有效位没有被设置,并且地址为为空,说明该虚拟页位置没有被分配,否则地址位应该指向在磁盘中的虚拟页。
每个虚拟页对应一个实体,有多少个虚拟页就有多少个实体。
如果程序需要某段虚拟页中的数据,并且该虚拟页在内存中,称为页命中,如果不在称为页失败,此时系统中的异常处理器会负责加载页,重新执行指令。
从磁盘到内存和从内存到磁盘的页的来回加载称为页交换,页交换算法可能需要选择一个被替换页在内存中当内存满时。页只在页失败时才加载叫做延迟加载,几乎所有的现代操作系统都使用延迟加载。
当一个页被分配时,例如调用 malloc
函数,那么对应页表中的实体的地址位将指向磁盘中的对应位置。
在 Linux 中可以使用 getrusage
函数查看页失败的次数。
虚拟内存作为内存管理工具
实际上,操作系统为每个进程都创建了自己的页表,不同的页表中的实体可以指向相同的内存,此时称为共享内存,此想法为简化管理内存做出了贡献。
因为每个进程都有独立的页表,因此每个进程都有独立的虚拟内存空间,这也就意味着,链接器在生成可执行目标文件的时候,可以不用知道实际的物理内存地址,只计算虚拟内存地址即可,例如,所有的可执行目标文件中的 .text
段都是从 0x08048000
开始。
也为共享内存提供了方便,例如两个程序使用的同一个共享库可以只在内存中加载一次而页表指向相同的内存位置达到贡献内存。
也为内存分配提供了方便,例如调用 malloc
的时候,系统不必要真的在物理内存中找到连续的一段内存空间,在虚拟内存中找到一段连续的空间更加方便。
也为系统加载器加载可执行目标文件提供了方便,系统加载器实际上并没有加载任何数据到内存,而是创建页表,将控制权转移给目标地址,此时会发生页失败,是由异常处理器加载页到内存中,进而执行指令。
虚拟内存作为内存保护工具
在页表中的每个实体加入权限标记位,可以实现内存保护,即读取内存时要先经过内存地址转换,此时就可以拦截越权指令,此时 CPU 会引发一般保护失败,即段错误。
地址转换
内存管理将实际地址的高 p p p 位作为页表中的索引,剩下的作为在页中的偏移。
在内存管理器中,还存在一个较小的页表缓存用于缓存页表加快地址转换速度。
页的大小太小可能造成页表太大,此时使用多级页表的解决方案,即先查询第一级页表,在根据第一级页表的查询结果查询第二级页表,以此类推。
内存映射
在 Linux 中,通过关联的目标文件初始化虚拟内存称为内存映射,通常分为两种:
- 普通文件映射,将磁盘中的文件划分成虚拟页映射进内存。
- 匿名文件映射,此时虚拟页将以全零的方式填充。
Linux 中的虚拟内存区域有两种,一种是共用区域,即两个进程使用相同的共享库但是在不同的虚拟内存区域映射到相同的物理内存,此时两个进程共享一份物理内存中的共享库,此时共用区域反应到被映射的目标文件上。
第二种是私有区域, Linux 使用 copy-on-write 策略,即如果两个进程使用相同的目标文件,先按照共用区域的方式加载虚拟内存,然后直到一方请求写入数据,再复制一份物理内存作为拷贝,进而重新映射页表,此时不反应到被映射的目标文件上。
当使用 fork
函数的时候,此函数只拷贝了内存映射和进程的相关信息,数据和指令均没有拷贝即使用和父进程相同的区域,当子进程写入数据的时候,再使用 copy-on-write 策略创建子进程的私有内存区域。
当使用 execve
的时候,将重新加载和映射进程的所有页表信息。
Linux 系统提供了 mmap
和 munmap
函数将目标文件映射进虚拟内存,提供了一种将 IO 映射到虚拟内存的方式,进而可以通过读写内存的方式来读写 IO 。
动态内存分配
在内存中,有一段称为堆的内存区域,由程序动态管理,称为动态内存分配,在堆中分布着许多内存块,每个内存块是由连续的内存区域组成,每个块有两个状态,分别是分配和未分配,动态内存分配有两种方式:
- 显示内存分配。例如调用 C 语言中的
malloc
和free
函数。 - 隐式内存分配。高级语言中的自动管理动态内存,例如 Java 中的自动对象内存管理。也称为垃圾回收机制。
malloc
和 free
函数
C 标准库提供一个 malloc
函数用于动态内存分配,注意,传递的参数是至少所需的内存大小,由于内存对齐机制的存在,分配的内存可能比申请的预期内存大小要大。
如果动态内存分配发送错误,例如没有有限的空间了,那么该函数返回 NULL
并设置 errno
。另外,也提供了 calloc
申请初始化为 0 的内存区域,因为 malloc
不会初始化内存。 realloc
用于调整已经申请的堆内存的大小。
动态内存的底层是使用 mmap
和 munmap
实现的。
在系统中,进程保存一个 brk
指针指向堆的堆顶,可以使用 sbrk
调整堆的堆顶。
片段化内存
一个好的动态内存管理算法应该兼顾时间(单位时间内存请求吞吐量)和空间(空间利用率)两者的平衡,对于空间上一个巨大的障碍就是片段化内存。
片段化内存分为两种:
- 内部片段化,指分配的内存区域大于所需的内存区域,导致一些内存没有被预期使用,这通常发生在内存对齐的时候。
- 外部片段化,指连续几次的
malloc
和free
之后,一些小的未分配的内存块夹在分配的内存块之间,导致未使用的内存总和大于申请的内存但是无法申请连续的所需内存。
隐式内存列表
动态内存管理器通过在每一个内存块嵌入一个记录块信息的数据结构来维护整个堆内存。
例如,一个内存块可以使用一个字作为头部,记录这个块的大小和是否被分配,并记录一些填充信息。
适当的填充是有益的,这利于内存对齐的实现和减少外部片段化内存。
这样就形成一个隐式的链表结构,称为隐式内存列表。
这样做的缺点也很明显,就是时间复杂度太高。
匹配策略
如果寻找一个合适的未分配的内存块称为匹配策略:
- 第一次匹配,从头开始搜索,直到第一个空闲内存块能够装下申请的大小为止,优点,在后面有大块的空间为大量空间分配准备,缺点,时间和空间效率低下,容易产生碎片。
- 下一次匹配,从上一次匹配结束的位置开始搜索,到下一个个空闲内存块能够装下申请的大小为止,优点,空间和时间效率提高。
- 最佳匹配,搜索所有的空闲块,找到那个最佳匹配,空间效率最好,时间效率低下。
分裂策略
当内存管理器找到一个远大于预期大小的内存块,内存管理器有两种选择,第一种是选择整个内存,但是会造成大量的内部片段化内存,第二就是将这个内存块分裂成两个内存块,第一个用于分配,第二个仍为未分配的内存块,其中第二种叫做分裂策略。
合并策略
当两个紧挨着的两个空闲块出现,但是又不能满足所需申请大小的时候,称为伪片段化内存,此时可以将两个相连的空闲块连接起来来打破这个局面。
合并策略有两种:
- 立即合并。当一个块转为空闲块的时候,检查他紧挨着的两个是否是空闲块,如果是则合并。
- 延迟合并。只有在内存不足的情况下才扫描所有的块进行合并。
第一种时间是常数级别,但是会产生例如合并之后又立即分裂的情况。第二种避免了这种情况,但是最坏时间复杂度不优。
对于第一种而言,由于内存列表形成的是一种单链表结构,无法在常数时间内找到上一个内存块的头部,因此我们在每个内存块的尾部加上一个尾部信息,记录块的大小,即可形成一个双链表结构。
显式内存列表
对于空闲块,我们可以额外记录两个数据,即上一个空闲块的内存位置和下一个空闲块的内存位置,这样所有的空闲块形成一个显式的双向链表,这样可以加快搜索时间。
分离式内存列表
分离式内存列表将申请的大小分成多个区间分成多个类别,为每一个区间创建并维护一个内存块列表。
例如,每个区间储存最大的块大小,假设存在区间 [ 5 , 8 ] [5,8] [5,8] 那么这个区间的每个块的大小都是 8 即可。
每次申请内存的时候,总是查找相应的内存列表即可。
这么做的优点是时间效率提升,不需要任何的合并和分离操作。缺点是容易造成片段化内存。
另外一种方案,称为分离式匹配,每个区间的内存块大小不同,使用分离和合并策略,例如两个小区间的内存块可以合并为一个内存块放到大的区间,反之也可以,这么做是时间和空间效率最高的方法,仅次于最佳匹配,广泛用于生产级库,例如 GNU C 标椎库的 malloc
使用的就是分离式内存列表和分离式匹配。
特别的,每个区间的大小按照 2 的幂次递增的分离式内存列表,称为伙伴系统,一个伙伴指的是将一个内存块(大小一定是 2 的幂次)分成两个相同的子块时,剩下的那个称为伙伴。这种伙伴系统利用方便计算内存地址和实现内存对齐,缺点是容易形成内部片段化内存,因此伙伴系统只在特定的程序下工作。
垃圾回收
垃圾回收机制表明程序只显式分配内存而不用显式回收内存。回收内存的工作由垃圾回收器负责。垃圾回收机制可追溯到 Lisp 语言,这里我们只介绍标记扫描算法。
垃圾回收器将内存看成为一个可达图,一个可达图是一个有向图,包含若干个根节点,根节点的入度为 0 ,和一些堆节点。根节点代表了静态区的内存,例如函数栈帧中的内存,堆节点代表了动态内存中的空间。如果存在 p → q p \to q p→q 的一条边,表示内存 p p p 引用了内存 q q q 。如果某个节点能够从根节点达到这个节点,我们说这个节点是可达的。一个垃圾回收器要做的就是回收所有不可达节点。
例如 ML 和 Java 这些对指针使用要求严格的语言,可以轻松实现可达图的维护,对于 C 和 C++ 这种非严格指针语言,垃圾回收器对可达图的维护具有一定的难度,这种垃圾回收器称为保守型垃圾回收器。
垃圾回收器运行可以和程序并行运行也称异步执行,也可在程序显式调用 malloc
发现内存不够用时再调用垃圾回收器,称为同步执行。
标记扫描算法
标记扫描算法分为两个执行阶段:
- 标记阶段,标记哪些节点是可回收的,哪些是不可回收的。
- 扫描节点,回收哪些标记位可回收的节点。
在标记阶段中,垃圾回收器通过深度优先搜索从每一个根节点进行遍历,标记哪些可达的节点。
在扫描阶段,遍历每一个节点,如果该节点被标记,那么说明该节点可达,并清空其标记,否则节点不可达,取消其标记。
对于像 C 这种指针不严格语言,实现垃圾回收机制将面临两个严重的问题:
- 无法知道一个数据类型是不是一个指针,例如一个 int 类型可以保存一个地址信息。
- 即使知道一个数据类型是指针,也不好定位该指针指向了哪个内存块。
面对第二个问题,我们将所有内存块视为一个平衡二叉搜索树,在每个内存块的头部标记好左子树和右子树的内存块指针。每次垃圾回收机制将进行二分搜索。