文章目录
本文按照 CSAPP 书中的内容组织顺序,按照两部分组织虚拟内存部分的内容:
- 虚拟内存的工作机制;
- 探究和应用虚拟内存;
虚拟内存为计算机提供了三种重要的能力:
- 将主存看成一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域(利用局部性,locality),并根据需要在磁盘和主存之间来回传送数据,高效地使用主存;
- 为每个进程提供了一致的地址空间,简化了内存管理;
- 保护每个进程的地址空间不被其他进程破坏;
下面来一起揭开虚拟内存的神秘面纱!
虚拟内存的工作机制
物理地址(Physical Address, PA) 对应计算机内存当中(DRAM,DRAM相关概念见:计算机组成概略——0.2.2 内存的内容)当中的每个字节,同时对于每个字节,其PA是唯一的。CPU访问某一字节找寻其对应的PA的过程称为物理寻址(Physical Addressing)。早期的PC和低阶的嵌入式设备使用物理寻址方式,而现代处理器早就改用虚拟寻址(Virtual Addressing) 寻址形式。
CPU通过生成一个虚拟地址(Virtual Address, VA) 来访问主存,该VA在送给内存前先通过内存管理单元(Memroy Managment Unit, MMU) 这一专用软件将VA地址翻译(Address Translation) 翻译为物理地址,其利用存放在DRAM当中的查询表执行地址翻译任务。
![](https://i-blog.csdnimg.cn/blog_migrate/25a9683b68842796235f4766964e0bcf.png)
地址空间(Address Space) 抽象为一个非负整数地址的有序集合,对于带虚拟内存的系统中,CPU冲一个有 N = 2 n N=2^n N=2n个地址的地址空间中生成虚拟地址,该地址空间称为虚拟地址空间(Virtual Address Space),其中 n n n是处理器位数(相关概念见:程序的机器级表示前面部分),同时该系统还有个物理地址空间(Physical Address Space),对应于系统物理内存的 M M M个字节。
以上是正式开始时的相关概念简单阐述。
1. 虚拟内存的相关概念和缓存流程
在磁盘(Disk)上,数据/存储被分割为大小固定的块,这些块作为磁盘和内存(DRAM)之间的传输单元。类似的,磁盘分割块的概念,在内存当中对应于页(Page):对于虚拟内存的页(存储在磁盘当中)称为虚拟页(Virtual Page, VP);对于物理内存的页(缓存在DRAM当中)称为物理页(Physical Page, PP) 或页帧(Page Frame)。
对于虚拟内存的页,任何时刻都可分为三种(见左下图):
- 未分配:VM系统尚未进行分配或创建的页。不占用任何磁盘空间,因为未分配就没有任何数据和其相关联;
- 分配未缓存:未缓存在物理内存中的已分配页;
- 分配已缓存:已缓存在物理内存中的已分配页;
有了页的概念,则可以将虚拟内存缓存的 流程 大致描述如下:
同任何缓存一样,虚拟内存系统必须有某种方法来判定一个虚拟页是否缓存在 DRAM 中的某个地方。如果命中(缓存在 DRAM 当中),系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中(未缓存在 DRAM 当中),系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理内存中选择一个牺牲页,并将虚拟页从磁盘复制到DRAM 中,替换这个牺牲页(见1.3 页命中、缺页)。
以下内容是如上所描述过程中(如地址翻译,虚拟页未命中的用时复制)所使用的结构和细节的进一步说明和解释。
1.1 DRAM 缓存的组织结构
(本节只是让读者对DRAM有个认识,同时了解DRAM作为VM缓存载体会有问题但为什么仍选择DRAM作为缓存载体,有什么解决方案)
在上文当中已有提到DRAM的概念,即是通俗理解的PC的内存。此外,在DRAM和CPU取指令之间,还有SRAM的存在,其实际是指CPU的L1、L2(和L3)高速缓存(相关概念见:计算机组成概略——0.2.2 内存的内容)。它们的结构,也即是存储器层次结构图见右下:
![]() |
![]() |
在计算机漫游当中,了解到存储器层次结构的复杂是为了适应CPU的高速和磁盘的慢速之间的差异(见计算机系统漫游——3.2 高速缓存的必要性),提高CPU的利用率。DRAM 比 SRAM 要慢大约 10 倍,而磁盘要比 DRAM 慢大约 100,000 多倍。所以 SRAM 不命中相比 DRAM 不命中要便宜许多,重要原因是更高层次的存储器不命中后寻址是由次级底层存储器服务的,10W多倍的速度差距导致了这一现象。
但是限于SRAM的昂贵和低容量,和程序运行时的局部性特征(见本文1.4节内容),所以仍使用了DRAM进行虚拟内存缓存。同时针对DRAM不命中的情况,OS对于DRAM缓存使用了精密的替换算法,同时对于缓存的更新,均使用时写回。以缓解这一低效,在现代计算机中,运用DRAM缓存的VM系统运行良好,并大规模推广开来。
1.2 页表
以上蓝字的过程描述的实现,是由软硬件联合提供的。包括OS、MMU中的地址翻译硬件和存放在物理内存中的叫做 页表(Page Table) 的数据结构。页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与 DRAM 之间来回传送页。
页表的基本组织结构(可拓展,见本文2.1节)如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/389b9214751b174868cee43c4e5d6343.png)
页表可抽象为 页表条目(Page Table Entry, PTE) 的数组。每个 PTE 有着相同的数据结构和空间大小(偏移量固定),同时对应着虚拟地址空间中的某个唯一的页。
页表条目又由一个有效位(Valid bit)和一个 n n n位地址字段组成。有效位表示该虚拟页是否是分配已缓存:
- 若为
0
0
0:
①地址字段为 n u l l null null:该虚拟页是未分配的;
②地址字段非 n u l l null null:该虚拟页是分配未缓存的,地址字段记录的是该虚拟页在磁盘上的起始地址; - 若为 1 1 1:该虚拟页是分配已缓存的,地址字段记录 DRAM 中相应物理页的起始位置;
对于页面的分配过程,即是分配过程是在磁盘上创建空间并创建、更新一个PTE ,使它指向磁盘上这个新创建的页面。
1.3 页命中、缺页
页命中
当CPU想要读取上图中的物理页(红色标注)的内容,地址翻译硬件发现在页表当中有效位为 1 1 1,即被缓存在DRAM当中,则称页命中了,使用页表当中对应的PTE的物理内存地址,构造出这个内容的物理地址。
缺页
于页命中对照的,若是DRAM缓存不命中则称为缺页(Page Fault)(用时复制,按需调度的体现)。当CPU想要读取上图中分配未缓存页(绿色标注)的内容,通过有效位发现该页尚未缓存,则出发一个缺页异常(异常相关内容见:异常控制流(Exception Control Flow))。
缺页异常的处理程序被启动,该程序会选择一个牺牲页,若是该牺牲页已经标记为更改过,则内核会将其复制回磁盘,若是未更改过,调整牺牲页在页表中所对应的PTE。接着,内核从磁盘(虚拟内存)当中将内容复制到牺牲页(物理内存)上,再次更新其PTE,随后返回。
当缺页异常处理程序返回时,原进程会重新启动导致缺页异常的指令,该指令会将导致缺页的虚拟地址重发送到地址翻译硬件。这时就会进行页命中的相关流程了。
在虚拟内存的习惯说法中,块被称为页。在磁盘和内存之间传送页的活动叫做 交换(swapping) 或者 页面调度(paging)。页从磁盘换入(或者页面调入) DRAM 和从 DRAM 换出(或者页面调出)磁盘。一直等待,直到最后时刻,也就是当有不命中发生时,才换入页面的这种策略称为按需页面调度(demand paging)。 也可以采用其他的方法,例如尝试着预测不命中,在页面实际被引用之前就换人页面。然而,所有现代系统都使用的是按需页面调度的方式。
1.4 虚拟内存缓存高效能的保证——局部性
正如1.1中所述,DRAM 作为虚拟内存的缓存载体,若是缺页则会有很大的处罚,可能破环程序性能。但是,虚拟内存工作的效果却仍是相当好,原因就在于程序运行时的局部性(locality)。正如侯捷老师所说,一个好的程序的运行时进程,运行时间的 80 % 80\% 80%所使用的代码只占总代码量的 20 % 20\% 20%。
尽管在整个运行过程中程序引用的不同页面的总数可能超出物理内存总的大小,但是局部性原则保证了在任意时刻,程序将趋向于在一个较小的 活动页面(active page) 集合上工作,这个集合叫做 工作集(working set) 或者 常驻集合(resident set)。在初始开销,也就是将工作集页面调度到内存中之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。
若是程序在时间局部性上做的不好,运行时进程中会频繁地进行页面的换进换出,同时工作集的大小超出了物理内存的大小,则会产生 抖动(thrashing),此时虚拟内存仍有效,但进程性能很慢很慢。在Linux下可以使用getrusage
函数检查缺页的数量。
2. 虚拟内存进行内存管理相关机制
按需页面调度和独立的虚拟地址空间的结合,使得虚拟内存对系统中内存的使用和管理有如下好处(阐释中包括相关机制):
- 简化链接。独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处;
- 简化加载。虚拟内存还使得容易向内存中加载可执行文件和共享对象文件,例如要把目标文件中 . t e x t .text .text 和 . d a t a .data .data 节(见链接器、链接过程及相关概念解析——2.2 可执行目标文件(无后缀))加载到一个新创建的进程中,Linux 加载器为代码和数据段分配虚拟页,把它们标记为无效的(即未被缓存的), 将页表条目指向目标文件中适当的位置(标记位为0,地址位非 n u l l null null)。同时加载器(loader)(见上个链接中相同部分的 加载可执行目标文件 部分)并不会复制数据到物理内存,仍是遵循按需调度的原则;
- 简化共享。独立地址空间为操作系统提供了一个管理用户进程和操作系统自身之间共享的一致机制。一般而言,每个进程都有自己私有的代码、数据、堆以及栈区域,是不和其他进程共享的。在这种情况中,操作系统创建页表,将相应的虚拟页映射到不连续的物理页面;
- 简化内存分配。虚拟内存为向用户进程提供一个简单的分配额外内存的机制。当一个运行在用户进程中的程序要求额外的堆空间时(如调用malloc 的结果), 操作系统分配一个适当数字(例如个连续的虚拟内存页面,并且将它们映射到物理内存中任意位置的 k k k 个任意的物理页面。由于页表工作的方式,操作系统没有必要分配 k k k 个连续的物理内存页面。页面可以随机地分散在物理内存中。
以下是更细化的相关机制。
2.1 内存保护
一个操作系统应当可以保证一用户进程不可修改其只读代码段( . r o d a t a .rodata .rodata),不允许其修改内核中的代码和数据结构,不允许读写其他进程的私有内存,对于和其他进程的共享虚拟页面若不在显示允许修改的设定下也是不可以修改的。
利用CPU若是想要读取一个虚拟页面的内容时,须经过地址翻译这一过程的事实,可以对所使用的页表当中的PTE进行拓展,添加一些额外的许可位即可。如下图:
![](https://i-blog.csdnimg.cn/blog_migrate/c2ca6cf797e9f3f30ac193f7951b374b.png)
若是指令违反了上图中的许可条件,则CPU触发一般保护故障(fault),Linux一般将其称为段故障(所对应信号量为SIGSEGV
)。
2.2 地址翻译
上文提到,地址翻译是由硬件(MMU)和软件(OS)共同提供的功能。其功能是实现一个 N N N 元素的虚拟地址空间(VAS)中的元素和一个 M M M 元素的物理地址空间(PAS)中元素之间的映射: M A P : V A S → P A S ∪ ∅ MAP:VAS \to PAS \cup \varnothing MAP:VAS→PAS∪∅。
得到物理页面地址
在CPU中 页表基址寄存器(Page Table Base Register, PTBR) 指向当前页表起始位置。具体的页表,和页表当中相关的符号,详见CSAPP p568面,这里不再赘述。MMU通过虚拟页号选择适当的PTE,PTE中的物理页号(PPN)和虚拟地址中的虚拟页面偏移(VPO)串联起来,即可得到相应的物理页面地址(因为物理和虚拟页面都是P字节,所以PPO=VPO)。图解如下:
![](https://i-blog.csdnimg.cn/blog_migrate/d7f24ca484693c101e96a973d7a6e09e.png)
CPU获取物理页面模型和步骤
下图分别展示了最简单的页面命中情形和未命中情形的步骤和流程图解。
![]() |
![]() |
每次CPU产生一个地址,则MMU就需要查阅一个PTE,最坏情况下需要内存多取一次数据,花费几十到几百个机器周期。那么若是可将PTE缓存在SRAM当中(也即是高速缓存和虚拟内存的结合,见左下图),则代价可降低到1或2个周期,所以在MMU可包括一个关于PTE的小缓存,称为 翻译后备缓存器(Translation Lookaside Buffer, TLB),对于TLB缓存的数据结构是从虚拟地址的页号提取出来的,并加入了自身的控制信息。使用TLB缓存之后,用硬件缓存替代软件查找,效率大为提高。TLB命中与不命中情形的步骤和流程图解如下(见右下图):
![]() |
![]() |
需要注意的是,上面图解中的翻译对应的MMU,对于右上a)高速缓存/内存部分对应的是L1高速缓存,b)则是左上图的非虚线框中的所有部分。
2.3 多级页表
由以上的知识,对于内存管理,若是不同位数的操作系统,想要正常运行虚拟内存机制,必定需要在内存当中驻留一定大小的页表。若是使用一个虚拟页表一个PTE的方式,显然会占用较多稀缺的内存资源。所以引入 多级页表 的层次结构形式用来压缩页表。其思想和cpp内存管理当中的小块堆SBH类似(见malloc / free:SBH(Small Block Heap)——以VC6为例——2.2.3 _heap_alloc_base() 阶段),也可以从嵌套指针的角度将多级页表理解为嵌套页表。这样可以省去大量空白/未分配页表的内存开销,做到按需记录(将固定开销限定在一级页表的大小),比如 k k k 级页表,则访问具体的特定某物理页,则访问 k k k 个PTE即可。
探究和应用虚拟内存
3. Linux 虚拟内存系统
Linux为每个进程维护了一个单独的虚拟地址空间,也即是进程的内存快照(memory snapshot)。对于内存快照,可将其分为内核虚拟内存和进程虚拟内存两大部分,如下图所示(未讲解虚拟内存时运行时内存空间的图见下面中间图)。
![]() |
![]() |
![]() |
内核虚拟内存的某些区域被映射到所有进程共享的物理页面,Linux 也将一组连续的虚拟页面(大小等于系统中DRAM 的总量)映射到相应的一组连续的物理页面,内核虚拟内存中的一部分区域也是进程所特有私有的。
进程虚拟内存区域包含每个进程都不相同的数据。比如说,页表、内核在进程的上下文中执行代码时使用的栈,以及记录虚拟地址空间当前组织的各种数据结构。
3.1 Linux 虚拟内存区域
Linux 将虚拟内存组织成一些区域(也叫做 段)的集合。一个区域(area)就是已经存在着的(已分配的)虚拟内存的连续块/页(chunk),这些页是以某种方式相关联的。每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在的,且不能被进程引用。
段是允许虚拟地址空间可以有空隙的条件。正是段的存在,使得内核不用记录那些不存在的虚拟页(未分配的),因为这样的页无需占用内存、磁盘或是其他系统资源,而这也是多级页表进行页表压缩的理论前提。
总结一下,进程的VM中已分配的VP按照部分各自保存在相应的区域/段(segment)当中,这些段在一起组合成为进程的VM。也即是在段的下面包含着相关的的虚拟页,这样的内存管理方式称为 段页式内存管理。
内核为系统中的每个进程维护一个单独的任务结构(对应内核部分源码中的task_struct
)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(例如,PID、指向用户栈的指针、可执行目标文件的名字,以及程序计数器)。对于任务结构中,有一条目指向mm_struct
,其描述了虚拟内存的当前状态。其中的pgd
字段指向第一级页表(页全局目录)的基址(存放在CR3,及基地址寄存器当中),mmp
字段指向一个vm_area_structs
(区域结构)的单链表,每个链表节点表示一个段,节点中的字段为:
vm_start
:指向这个区域的起始处;vm_end
:指向这个区域的结束处;vm_prot
:描述这个区域内包含的所有页的读写许可权限;vm_flags
: 描述这个区域内的页面是与其他进程共享的,还是这个进程私有的(还描述了其他一些信息);vm_next
:指向链表中下一个区域结构;
3.2 Linux 缺页异常处理
当 Linux shell 运行相关子进程/程序时发生缺页异常,异常发生在MMU试图翻译虚拟地址A时,按照以下步骤进行缺页异常处理:
- 判断虚拟地址A是否合法。A是否是属于某一段,若是不属于任何段,则触发缺页异常,对段使用树数据结构进行组织和管理;
- 判断进程对A所对应的物理页的访问是否合法。即判断该进程是否有rwx相关权限,若是没有相关权限,则异常处理程序触发保护异常,从而终止该进程;
- 若是发生缺页异常而不是保护异常,则按照1.3节中的步骤处理缺页异常即可;
3.3 内存映射
Linux通过将一个虚拟内存区域与一个磁盘上的 对象(object) 关联起来,以初始化(将内容从磁盘复制到物理内存)该虚拟内存区域当中的内容,该过程称为内存映射(memory mapping)。虚拟内存区域可以映射到两种类型的对象中的一种:
- Linux 文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件。文件区(section)被分成页大小的片(段页式的体现),每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到CPU第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。如果区域比文件区要大,那么就用零来填充这个区域的余下部分;
- 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建,包含的全是二进制零。CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在内存中的。注意在磁盘和内存之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页(demand-zero page)。
共享对象的实现
对于许多进程有同样的只读代码区域(如Linux shell和bash进程的相同代码部分),且许多程序只需访问只读运行时库代码的相同副本。为了节省内存资源,所以有一个对象可以被映射到虚拟内存的一个区域,并作为共享对象,即共享段中的内容。
由于每个对象都有一个唯一的文件名,内核可以迅速地判定两进程是否有相同的对象可以共享,使得后使用该对象的进程的页表条目指向已有的该对象,过程如下图所示。
![](https://i-blog.csdnimg.cn/blog_migrate/56100e8f507b1fa7dda9de323c674d21.png)
Linux 使用 写时复制(copy-on-write) 技术将私有对象映射到虚拟内存中。若是上述进行共享对象的进程要对该进程的这个私有且共享的对象进行更改,即对页表条目标记为只读,且区域结构被标记为私有的写时复制私有区域的某些页进行写操作,那么该操作会触发一保护故障。
故障处理程序注意到触发保护异常的原因,会在物理内存当中创建共享对象该页面的新副本,更新页表条目指向这个新的副本,然后恢复这个页面的可写权限,接下来处理程序返回,原进程则可以对该页进行写操作。延迟私有对象中的副本直到最后可能的时刻,以保证对内存资源的充分利用。私有的写时复制的过程如下图。
![](https://i-blog.csdnimg.cn/blog_migrate/f62f9ce47770852049d9bc8592240005.png)
fork()函数再探
相关fork()函数内容可见:异常控制流(Exception Control Flow)——3.1 进程操作函数
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct
(3.1节开头)、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
execve()函数再探
函数在当前进程中加载并运行包含在可执行目标文件a.out
中的程序,用a.out
程序有效地替代了当前程序。加载并运行它需要以下几个步骤:
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构;
- 映射私有区域。为新程序的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为
a.out
文件中的 . t e x t .text .text和 . d a t a .data .data区。 b s s bss bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out
中。栈和堆区域也是请求二进制零的,初始长度为零(段映射类型见3.1节头三联图中的最右边图); - 映射共享区域。如果
a.out
程序与共享对象(或目标)链接,比如标准C库libc.so
那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内; - 设置程序计数器(PC)。
execve
做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口点开始执行。之后,Linux 将根据需要换入代码和数据页面。
3.4 mmap函数进行用户级内存映射
更详细内容见:mmap()共享内存详解
//函数所在头文件:
#include <unistd.h>
#include <sys/mman.h>
//-----------------------------------------------------------------------
//-----------------------------------------------------------------------
//mmap函数要求内核创建一个新的虚拟内存区域,最好是从地址 start 开始的一个区域,
//并将文件描述符fd 指定的对象的一个连续的片(chunk)映射到这个新的区域。
//连续的对象片大小为 length 字节,从距文件开始处偏移量为 offset 字节的地方开始。
//start地址仅仅是一个暗示,通常被定义为null。
void *mmap(void *staxt, size_t length, int prot, int flags,
int fd, off_t offset);
//返回:若成功时则为指向映射区域的指针,若出错则为MAP_FAILED(—1).
//---------------------------------------------------------
//prot参数对应如下的宏:
//PROT_EXEC: 这个区域内的页面由可以被CPU 执行的指令组成;
//PROT-READ: 这个区域内的页面可读;
//PROT_WRITE: 这个区域内的页面可写;
//PROT_NONE: 这个区域内的页面不能被访问;
//---------------------------------------------------------
//flags参数由描述被映射对象类型的位组成:
//MAP_PRIVATE表示被映射的对象是一个私有的、写时复制的对象;
//MAP_SHARED 表示是一个共享对象;
//-----------------------------------------------------------------------
//-----------------------------------------------------------------------
//munmap 函数删除虚拟内存的区域
int munmap(void *staxt, size_t length);
//返回:若成功则为0, 若出错则为一1,
4. 动态内存分配
可以使用低级的mmap 和munmap 函数来创建和删除虚拟内存的区域,但当运行时需要额外虚拟内存时,用动态内存分配器(dynamic memory allocator)更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。堆是请求二进制零的区域/段,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(break)它指向堆的顶部。
![](https://i-blog.csdnimg.cn/blog_migrate/3f63927e77668f564f35eb949330bd85.png)
对于分配器有两种风格。主要区别在于程序员还是程序/OS来负责释放已分配的块。
- 显式分配器(explicit allocator):要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C 程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete符与C中的malloc和free相当。相关内容可见C++内存管理原始工具(primitives);
- 隐式分配器(implicit allocator):另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器(garbage collector), 而自动释放未使用的已分配的块的过程叫做垃级收集(garbage collection)例如,诸如Java之类的高级语言就依赖垃圾收集来释放已分配的块;
使用动态内存分配的原因是,很多时候直到程序实际运行时,进程才知道某些数据结构的大小。
4.1 堆分配过程中的碎片
造成堆利用率很低的主要原因是一种称为 碎片(fragmentation) 的现象,当虽然有未使用的内存但不能用来满足分配请求时,就发生这种现象。有两种形式的碎片:
- 内部碎片(internal fragmentation) :为满足对齐约束而填充的padding(一般是0填充)所造成的分配块中的空间浪费。相关内容见:C++类空间大小;
- 外部碎片(external fragmentation):空闲内存总量可以满足分配请求,但是单独的空闲块的大小无一可以满足请求的空间大小所造成的空间浪费;
对于碎片的整理,是个很大的命题,下面链接当中的链接中有相关内容,不再单独说明讲解。
4.2 malloc、free和allocator分配器的实现
在本人博客专栏:内存管理,当中有详细的解释,以侯捷老师的内存管理为基础,结合自身理解所写,可供参考。
- C++内存管理原始工具(primitives):关于C和C++中的内置内存分配工具,如malloc、free和new、delete,及可定制的operator new()和operator delete();
- std::allocator——以GNU2.9为例:对STL标准分配器进行讲解,其中包含了分配器的思想、实现的数据结构,效率优化包括碎片整理等内容;
- malloc / free:SBH(Small Block Heap)——以VC6为例:VC6.0中对小区块分配的解决方案,是malloc函数的实现当中的针对小块内存分配的实现分支,有较好的参考价值;
- loki::allocator:一种暴力简单的分配器的实现版本,虽简单且没有发行,但是思想很受用;
- GNU C++ Allocator分类总结与归纳:C++各种分配器的整理总结,可以对分配器的种类和思想分类有个全面的认识;
4.3 垃圾收集器/隐式分配器
垃圾收集器将内存视为一张有向可达图(reachability graph)。该图的节点被分成一组根节点(root node) 和一组堆节点(heap node),每个堆节点对应于堆中的一个已分配块。有向边 p → q p\to q p→q 意味着块 p p p 中的某个位置指向块 q q q 中的某个位置。根节点对应于一种不在堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存器、栈里的变量,或者是虚拟内存中读写数据区域内的全局变量。
![](https://i-blog.csdnimg.cn/blog_migrate/ce3933b5e34c082a713a19099bdebddf.png)
当存在一条从任意根节点出发并到达 P P P 的有向路径时,我们说节点 P P P 是可达的(reachable)。在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来定期地回收它们。
C++也有垃圾收集器,但是其通常不能维持可达图的精确表示,又称为保守垃圾收集器(conservative garbage collector),即每个可达块都被正确地标记为可达了,而一些不可达节点却可能被错误地标记为可达。
动态内存分配器中,空间配置器和垃圾收集器的搭配方案如下:
![](https://i-blog.csdnimg.cn/blog_migrate/624c73187a9eab8adfecc9bdfda03c2d.png)
4.4 动态内存分配中常出现的错误
C++提高代码效率和避免程序错误见:Effective C++
- 间接引用坏指针:指针引用非可读或不存在的内存空间,又称为坏指针;
- 读未初始化的内存:又称为野指针,即解析未初始化的指针, b s s bss bss区中的内存均初始化为0,但是堆区中的不是,可能导致不可知的错误或程序崩溃;
- 允许栈缓冲区溢出:输入重写的内容长度超出栈缓冲区长度,同时也是计算机安全中的一个课题,见程序的机器级表示——10. 在机器级程序中将控制与数据结合起来;
- 误解指针运算:指针的算术操作是以它们指向的对象的大小为单位来进行的,而这种大小单位并不一定是字节;
- 引起内存泄漏:malloc和free,new和delete没有成对使用,造成堆内存的泄露,且无法查找回收,对于长时间运行的程序是不可接受的;
- …
总结及归纳
- 虚拟内存是对主存的一个抽象。支持虚拟内存的处理器通过使用一种叫做虚拟寻址的间接形式来引用主存。处理器产生一个虚拟地址,在被发送到主存之前,这个地址被翻译成一个物理地址。从虚拟地址空间到物理地址空间的地址翻译要求硬件和软件紧密合作。专门的硬件通过使用页表来翻译虚拟地址,而页表的内容是由操作系统提供的。
- 虚拟内存提供三个重要的功能。第一,它在主存中自动缓存最近使用的存放磁盘上的虚拟地址空间的内容。虚拟内存缓存中的块叫做页。对磁盘上页的引用会触发缺页,缺页将控制转移到操作系统中的一个缺页处理程序。缺页处理程序将页面从磁盘复制到主存缓存,如果必要,将写回被驱逐的页。第二,虚拟内存简化了内存管理,进而又简化了链接、在进程间共享数据、进程的内存分配以及程序加载。最
后,虚拟内存通过在每条页表条目中加人保护位,从而了简化了内存保护。 - 地址翻译的过程必须和系统中所有的硬件缓存的操作集成在一起。大多数页表条目位于 L1 高速缓存中,但是一个称为 TLB 的页表条目的片上高速缓存,通常会消除访问在 L1 上的页表条目的开销。
- 现代系统通过将虚拟内存片和磁盘上的文件片关联起来,来初始化虚拟内存片,这个过程称为内存映射。内存映射为共享数据、创建新的进程以及加载程序提供了一种高效的机制。应用可以使用
mmap
函数 来手工地创建和删除虚拟地址空间的区域。然而,大多数程序依赖于动态内存分配器,例如malloc
,它管理虚拟地址空间区域内一个称为堆的区域。动态内存分配器是一个感觉像系统级程序的应用级程序,它直接操作内存,而无需类型系统的很多帮助。分配器有两种类型。显式分配器要求应用显式地释放它们的内存块。隐式分配器(垃圾收集器)自动释放任何未使用的和不可达的块。 - 对于 C 程序员来说,管理和使用虚拟内存是一件困难和容易出错的任务。常见的错误示例包括:间接引用坏指针,读取未初始化的内存,允许栈缓冲区溢出,假设指针和它们指向的对象大小相同,引用指针而不是它所指向的对象,误解指针运算,引用不存在的变量,以及引起内存泄漏。