虚拟内存管理

来源:互联网 发布:木星探测器朱诺知乎 编辑:程序博客网 时间:2024/04/29 07:11

来自 《C++应用程序性能优化》 作者:冯宏华等 2007年版。

http://www.cnblogs.com/cswuyg/archive/2010/08/27/1809808.html

什么是虚拟内存?
  虚拟内存是Windows XP为作为内存使用的一部分硬盘空间。即便物理内存很大,虚拟内存也是必不可少的。虚拟内存在硬盘上其实就是为一个硕大无朋的文件,文件名是 PageFile.Sys,通常状态下是看不到的。必须关闭资源管理器对系统文件的保护功能才能看到这个文件。虚拟内存有时候也被称为是“页面文件”就是从这个文件的文件名中来的。

本质上虚拟内存就是让一个程序的代码和数据在没有全部载入内存时即可运行。运行过程中,当执行到尚未载入内存的代码,或者要访问还没有载入到内存的数据时,虚拟内存管理器动态地将这部分代码或数据从硬盘载入到内存中。而且在通常情况下,虚拟内存管理器也会相应地先将内存中某些代码或数据置换到硬盘中,为即将载入的代码或数据腾出空间。

因为内存和硬盘之间的数据传输相对代码执行来说,是非常慢的操作,因此虚拟内存管理器在保证工作正确的前提下,还必须考虑效率因素。比如,它需要优化置换算法,尽量避免就要执行的代码或访问的数据刚被置换出内存,而很久没有访问的代码或数据却一直保留在内存。另外还需要将驻留在内存的各个进程的代码或数据维持在一个合理的数量上,并且根据该进程的性能表现动态调整此数量,等等,使程序运行时将其涉及的磁盘I/O次数降到尽可能低,以提高程序的运行性能。

windows虚拟内存管理:

win32虚拟内存管理器为每一个win32进程提供了进程私有且基于页的4GB(32位)大小的线性虚拟地址空间,这句话可分解为:

(1)进程私有--- 每个进程都只能访问属于自己的地址空间,而无法访问其它进程的地址空间,也不用担心自己的地址空间被其它进程看到(父子进程除外)。需注意的是,进程运行时用到的dll并没有属于自己的虚拟地址空间。dll的全局数据,以及通过dll函数申请的内存都是从调用其进程的虚拟地址空间中开辟。

(2)基于页--- 虚拟地址空间被划分为多个称为‘页’的单元,页的大小由底层处理器决定,x86中页的大小为4kb。页是win32虚拟内存管理器处理的最小单元,相应的物理内存也被划分为多个页。虚拟内存地址空间的申请和释放,以及内存和磁盘的数据传输或置换都是以页为最小单位进行的。

(3)4GB大小--- 意味着进程中的地址取值范围可以从0x00000000到0xffffffff。win32将低区的2GB留给进程使用,高区的2GB则留给系统使用。

win32中用来辅助实现虚拟内存的硬盘文件称为“调页文件”,可以有16个,调页文件用来存放被虚拟内存管理器置换出内存的数据。当这些数据再次被进程访问时,虚拟内存管理器会先将它们从调页文件中置换进内存,这样进程可以正确访问这些数据。用户可以自己配制调页文件。出于空间利用效率和性能的考虑,程序代码(包括exe和dll文件)不会被修改,所以当它们所在的页被置换出内存时,并不会被写进调页文件,而是直接抛弃。当再次需要时,虚拟内存管理器直接从存放它们的exe或dll文件中找到它们并调入内存。另外对exe或dll中包含的只读数据的处理与此类似,也不会在调页文件中开辟空间。

当进程执行某段代码或访问某些数据,而这些代码或数据还没有在内存中,这种情形称为“缺页错误”。缺页错误原因有多种,其中最常见的一种就是,这些代码或数据被虚拟内存管理器置换出了内存。

调页错误涉及磁盘I/O,大量的调页错误会大大降低程序的总体性能。

使用虚拟内存:

win32分配内存分为两个步骤:预留和提交。因此在进程虚拟地址空间中的页有3中状态:自由(free),预留(reserved),提交(committed)。

(1)自由:表示此页尚未被分配,可以用来满足新的内存分配需求。

(2)预留:从虚拟地址空间中划出一块区域(页的整数倍大小),划出之后的这个区域的页不能用来满足新的内存分配需求,而是用来供要求“预留”此段区域的代码以后使用。预留时没有分配物理存储,只是增加了一个描述进程虚拟地址空间使用状况的数据结构(VAD,虚拟地址描述符),用来记录这段区域已被预留。“预留”操作没有分配物理存储,相对较快,也正因如此,对预留页的访问会引起“内存访问违例”(access violation)(会导致整个进程立刻退出,而不仅仅是中止引起违例的线程)。

(3)提交:从调页文件中开辟空间,并修改VAD中的相应项。注意,提交时也并没有立刻从物理内存中分配空间,而只是从磁盘的调页文件中开辟空间。这个空间用作以后置换的备份空间,直到有代码第一次访问这段提交内存中的某些数据时,系统发现并没有真正的物理内存,抛出缺页错误。虚拟内存管理器处理此缺页错误,直到这时才会真正分配物理内存。

     这也是win32虚拟内存管理中的demand-paging策略的一个体现,即不到真正访问时,不会为某虚拟地址分配真正的物理内存。

     预留和提交在win32中都使用VirtualAlloc函数完成,预留传入MEM_RESERVE参数,提交传入MEM_COMMIT参数。释放虚拟内存使用VirtualFree函数,此函数根据不同的传入参数,与VirtualAlloc相对应,可以释放与虚拟地址区域相对应的物理存储,但该虚拟地址区域还可出于预留状态,也可以连同虚拟地址区域一起释放,该段区域恢复为自由状态。

线程栈和进程堆的实现都利用了这种预留和提交两步机制,下面仅以线程栈为例来说明Win32系统是如何使用这种预留和提交两步机制的。

创建线程栈时,只是一个预留的虚拟地址区域,默认是1 MB(此大小可在CreateThread或在链接时通过链接选项修改),初始时只有前两页是提交的。当线程栈因为函数的嵌套调用需要更多的提交页时,虚拟内存管理器会动态地提交该虚拟地址区域中的后续页以满足其需求,直到到达1 MB的上限。当到达此预留区域大小的上限(默认1 MB)时,虚拟内存管理器不会增加预留区域大小,而是在提交最后一页时抛出一个栈溢出异常,抛出栈溢出异常时该栈还有一页空间可用,程序仍可正常运行。而当程序继续使用栈空间,用完最后一页后,还继续需要存储空间,这时就超过了上限,会直接导致进程退出

所以为防止线程栈溢出导致整个程序退出,应该注意尽量控制栈的使用大小。比如减少函数的嵌套层数,减少递归函数的使用,尽量不要在函数中使用太大的局部变量(大的对象可以从堆中开辟空间存放,因为堆会动态扩大,而线程栈的可用内存区域在线程创建时就已固定,之后在整个线程生命期间无法扩展)。

另外为了防止因为一个线程栈的溢出导致整个进程退出,可以对可能会产生线程栈溢出的线程体函数加异常处理,捕获在提交最后一页时抛出的溢出异常,并做出相应处理。

4.1.2 访问虚拟内存时的处理流程

对某虚拟内存区域进行了预留并提交之后,就可以对该区域中的数据进行访问了,下图描述了当程序对某段内存访问时的处理流程:

如图4-1所示,当该数据已在物理内存中时,虚拟内存管理器只需将指向该数据的虚拟地址映射为物理指针,即可访问到物理内存中的真正数据。这一步不会涉及磁盘I/O,速度相对较快。

当第一次访问一段刚刚提交的内存中的数据时,因为并没有真正的物理内存分配给它。或者该数据以前已被访问过,但是被虚拟内存管理器置换出了内存。这两种情形都会引发缺页错误,虚拟内存管理器此时会处理这一缺页错误,它先检测此数据是否在调页文件中已有备份空间(exe和dll的代码页和只读数据页情形与此类似,但是其备份空间不在调页文件,而是包含它们的exe或dll文件)。如果是这两种情况,表明访问的数据在磁盘中有备份,接下来虚拟内存管理器就需要在物理内存中找到合适的页,并将存放在磁盘的备份数据置换进物理内存。

虚拟内存管理器首先查询当前物理内存中是否有空闲页,虚拟内存管理器维护一个称为“页帧数据库”(page-frame database)的数据结构,此数据结构是操作系统全局的,当Windows启动时被初始化,用来跟踪和记录物理内存中每一个页的状态,它会用一个链表将所有空闲页连接起来,当需要空闲页时,直接查找此空闲页链表,如果有,直接使用某个空闲页;否则根据调页算法首先选出某个页。需要指出的是,虚拟内存管理器调页时并不是只调入一个页,为了利用局部特性,它在调入包含所需数据的页的同时,会将其附近的几个页一起调入内存。这里为了简单和清楚起见,假定只调入目标页。但应该意识到Win32调页时的这个特性,因为可以利用它来提高程序效率。这个页将会用来存放即将从磁盘置换进来的页的内容。选出某个内存页后,接着检查此页状态,如果此页自上次调进内存以来尚未被修改过,则直接使用此页(代码页和只读页也可以直接使用);反之,如果此页已被修改过(“脏”),则需要先将此页的内容“写”到调页文件中与此页相对应的备份页中,并随即将此页标为空闲页。

现在,有了一个空闲页用来存放即将要访问的数据。此时,虚拟内存管理器会再次检测,此数据是否是刚被申请的内存且是第一次被访问。如果是,则直接将此空闲页清0使用即可(不必从磁盘中将其备份页的内容读进,因为该备份页中的内容无意义);如果不是,则需要将调页文件中该页的备份页读到此空闲页中,并随即将此页的状态从空闲页改为活动页。

此时,此数据已在物理内存页中,通过虚拟地址映射到物理地址,即就可访问此数据了。

上述为访问成功时的情形,但情形并非总是如此。比如当用户定义了一个数组,而此数组刚好在其所在页的下边界,且此页的下一页刚好是自由或者预留的(不是提交的,即没有真正的物理存储)。当程序不小心向下越界访问此数组,则首先引发缺页错误。随即虚拟内存管理器在处理缺页错误时检测到它也不在调页文件中,这就是所谓的“访问违例”(access violation)。访问违例意味着要访问的地址所在的虚拟内存页还没有被提交,即没有实际的物理存储与之对应,访问违例会直接导致整个进程退出(即crash)。

可以看到,指针越界访问的后果根据运行时实际情况而有所不同。如上所述,当数组并非处于其所在页的边界,越界后还在同一页中,这时只会“误访问”(误读或误写,其中误读只会影响到正在执行的代码;误写则会影响到其他处代码的执行)该页中其他数据,而不会导致整个进程的crash。即使在该数组真的处于其所在页的边界,且越界后指针值落在了其相邻页。但如果此相邻页碰巧也为一个提交页,此时仍然只是“误访问”,也不会导致进程的crash。这也意味着,同一个应用程序的代码中存在着指针越界访问错误,运行时有时crash,但有时则不会

Microsoft提供了一个监测指针越界访问的工具pageheap,它的原理就是强制使每次分配的内存都位于页的边界,同时强制该页的相邻页为自由页(即不分配其相邻页给程序使用)。这样每次越界访问都会立即引起access violation,导致程序crash。从而使得指针越界访问错误在开发期间一定会被暴露出来,而不会发生某个指针越界访问错误一直隐藏到Release版本,直到最终用户使用时才被发现的情形。



VC访问违例(access violation)错误产生原因:

当用户定义一个数组,而此数组刚好在其所在页的下边界,且此页的下一页刚好是自由或预留的(不是提交的,即没有真正的物理存储)。当程序不小心向下越界访问此数组,首先引发缺页错误,随即虚拟内存管理器在处理缺页错误时检测到它不再调页文件中,即发生访问违例,会导致整个进程退出(crash)。

当数组越界后还在同一个页中,或越界后落在相邻页,但相邻页也为提交页,此时只会“误访问”(误读或误写,误读只会影响到正在执行的代码,误写会影响到其它代码的执行),但不会引起进程的crash.


Microsoft提供了一个监测指针越界访问的工具pageheap,它的原理就是强制使每次分配的内存都位于页的边界,同时强制该页的相邻页为自由页(即不分配其相邻页给程序使用)。这样每次越界约会都会立即引起access violation,导致crash。从而使指针越界访问在开发期间一定会暴露出来,而不用一直隐藏到release版本。