(转)C++应用程序性能优化(书)内存管理

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

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

本章前一部分着重介绍Windows的虚拟内存管理机制,后一部分则简要介绍Linux的虚拟内存管理机制。

4.1  Windows内存管理

如果从应用程序的角度来看Windows虚拟内存管理系统,可以扼要地归结为一句话。即Win32虚拟内存管理器为每一个Win32进程提供了进程私有且基于页的4 GB(32位)大小的线性虚拟地址空间,这句话可以分解如下:

(1)“进程私有”意味着每个进程都只能访问属于自己的地址空间,而无法访问其他进程的地址空间,也不用担心自己的地址空间会被其他进程看到(父子进程例外,比如调试器利用父子进程关系来访问被调试进程的地址空间,这里不详述)。需要注意的是,进程运行时用到的dll并没有属于自己的虚拟地址空间。而是其所属进程的虚拟地址空间,dll的全局数据,以及通过dll函数申请的内存都是从调用其进程的虚拟地址空间中开辟。

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

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

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

当进程执行某段代码或者访问某些数据,而这些代码或者数据还没有在内存时,这种情形称为“缺页错误”。缺页错误的原因有很多种,最常见的一种就是已经提到的,即这些代码和数据被虚拟内存管理器置换出了内存,这时虚拟内存管理器在这段代码执行或者这些数据被访问前将它们调入内存。这个操作对开发人员来说是透明的,因此大大简化了开发人员的负担。但是调页错误涉及磁盘I/O,大量的调页错误会大大降低程序的总体性能。因此需要了解缺页错误的主要原因,以及规避它们的方法。

4.1.1  使用虚拟内存

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

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

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

(3)提交,若想得到真正的物理存储,必须对预留的内存进行提交。提交会从调页文件中开辟空间,并修改VAD中的相应项。注意,提交时也并没有立刻从物理内存中分配空间,而只是从磁盘的调页文件中开辟空间。这个空间用做以后置换的备份空间,直到有代码第一次访问这段提交内存中的某些数据时,系统发现并没有真正的物理内存,抛出缺页错误。虚拟内存管理器处理此缺页错误,直到这时才会真正分配物理内存,提交也可以在预留的同时一起进行。需要注意的是,提交操作会从调页文件中开辟磁盘空间,所以比预留操作的时间长。

这也是Win32虚拟内存管理中的demand-paging策略的一个体现,即不到真正访问时,不会为某虚拟地址分配真正的物理内存。这种策略一是出于性能考虑,将工作分段完成,提高总体性能;二是出于空间效率考虑,不到真正访问时,Win32总是假定进程不会访问大多数的数据,因而也不必为它们开辟存储空间或将其置换进物理内存,这样可以提高存储空间(磁盘和物理内存)的使用效率。

设想某些程序对内存有很大的需求,但又不是立即需要所有这些内存,那么一次就从物理存储中开辟空间满足这些还只是“潜在”的需求,从执行性能和存储空间效率来说,都是一种浪费。因为只是“潜在”需求,极有可能这些分配的内存中很大一部分最后都没有真正被用到。如果在申请的时候就一次性为它们分配全部物理存储,无疑会极大地降低空间的利用效率。

另一方面,如果完全不用预留及提交机制,只是随需分配内存来满足每次的请求,那么对一个会在不同时间点频繁请求内存的代码来说,因为在它请求内存的不同时间点的间隙极有可能会有其他代码请求内存。这样这段在不同时间点频繁请求内存的代码请求得到的内存因为虚拟地址不连续,无法很好地利用空间locality特性,对其整体进行访问(比如遍历操作)时就会增加缺页错误的数量,从而降低程序的性能。

预留和提交在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文件)。如果是这两种情况,表明访问的数据在磁盘中有备份,接下来虚拟内存管理器就需要在物理内存中找到合适的页,并将存放在磁盘的备份数据置换进物理内存。

图4-1  访问虚拟内存的处理流程

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

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

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

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

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

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

4.1.3  虚拟地址到物理地址的映射

如上所述,在确保访问的数据已在物理内存中后,还需要先将虚拟地址转换为物理地址,即“地址映射”,才能够真正访问此数据。本节讲述Win32中虚拟内存管理器如何将虚拟地址映射为物理地址。

Win32通过一个两层表结构来实现地址映射,因为4 GB虚拟地址空间为每个进程私有,相应地,每个进程都维护一套自己的层次表结构用来实现其地址映射。第一层表称为“页目录”(page directory),实际上就是一个内存页(4 KB = 4 096 byte)。这一页以四个字节为单元分为1 024项,每一项称为一个“页目录项”(Page Directory Entry,PDE);第二层表称为“页表”(page table),共有1 024个页表。页目录中每一个页目录项PDE对应这一层中的某一个页表,每一个页表也占了一个内存页。这一页中的4 KB,即4 096个字节也像页目录那样被分成1 024项,每项4个字节,页表的每一项则称为“页表项”(Page Table Entry,PTE)。每一个页表项PTE都指向物理内存中的某一个页帧,如图4-2所示。

图4-2  页表

已经知道,Win32提供了4 GB(32位)大小的虚拟地址空间。因此每个虚拟地址都是一个32位的整数值,这32位由3个部分组成,如图4-3所示。

图4-3  虚拟地址空间

这三个部分中的第一部分,即前10位为页目录下标,用其可以定位在页目录的1 024项中的某一项。根据定位到的那一项的项值,可以找到第2层页表中的某一个页表。虚拟地址的第二部分,即中间的10位为页表下标,可用来定位刚刚找到的页表的1 024项中的某一项。此项值可以找到物理内存中的某一个页,此页包含此虚拟地址所代表的数据。最后用虚拟地址的第三部分,即最后12位可用来定位此物理页中的特定的字节位置,12位刚好可以定位一个页中的任意位置的字节。

举一个具体的例子,假设在程序中访问一个指针(Win32中的“指针”意味虚拟地址),此指针值为0x2A8E317F,图4-4所示为虚拟地址到物理地址的映射过程。

图4-4  虚拟地址到物理地址的映射过程

0x2A8E317F的二进制写法为0010101010,0011100011,000101111111,为了方便起见,将这32位分成10位、10位和12位。第一个10位00101010用来定位页目录中的页目录项,因为页目录项为四个字节,定位前将此10位左移两位,即0010101000(0x2A8)。再用此值作为下标找到对应的页目录项,此页目录项指向一个页表。同样方法再用第二个10位0011100011定位此页表中的页表项。此页表项指向真正的物理内存,然后用最后12位000101111111定位页内的数据(此时这12位不用再左移,因为物理页内定位时,需要能定位到每一个字节。而不像页目录和页表中,只需要定位每4个字节的第1个字节),即为此指针指向的数据。

上面假设的是此数据已在物理内存中,其实,“判断访问的数据是否在内存中”这一步骤,也是在这个地址映射过程中完成的,Win32总是假使数据已在物理内存中,并进行地址映射。页表项中有一位用来标识包含此数据的页是否在物理内存页中,当取得页表项时,检测此位,如果在,就是本节描述的过程,如果不在,则抛出缺页错误,此时此页表项中包含了此数据是否在调页文件中,如果不在,则为访问违例,如果在,此页表项可查出了此数据页在哪个调页文件中,以及此数据页在该调页文件中的起始位置,然后根据这些信息将此数据页从磁盘中调入物理内存中,再继续进行地址映射过程。

已经说过,为了实现虚拟地址空间各进程私有,每个进程都拥有自己的页目录和页表结构,对不同进程而言,页目录中的页目录项值(PDE),以及页表中的页表项值(PTE)都是不同的,因此相同的指针(虚拟地址)被不同的进程映射到的物理地址也是不同的。这也意味着,在不同进程间传递指针是没有意义的。

4.1.4  虚拟内存空间使用状态记录

当通过VirtualAlloc申请一块虚拟内存时,虚拟内存管理器是如何知道哪些内存块是自由的,可以用来满足此次内存请求呢?即Win32虚拟内存如何维护和记录每一个进程的4 GB虚拟内存地址空间的使用状态,如各个区域的状态、大小及起始地址呢?

上一节中,读者也许会认为可以通过遍历页目录和页表中的项值来收集虚拟内存空间的使用状态,但这样做首先有效率问题,因为每次申请内存都需要做一次搜索。但这个方法不仅仅是因为效率有问题,而且还是行不通的,对预留的页来说,虚拟内存管理器并没有为之分配物理存储。所以也就不会为其填写页表项,这时遍历页表无法分辨某块虚拟内存是自由还是预留的。另外即使对提交页来说,遍历页表也无法得到完整的信息,正如4.1.1节中提到的Win32在虚拟内存管理时用到的主要策略demand-paging,即Win32虚拟内存管理器在程序没有实际访问某块内存前,总是假定这块内存不会被访问到,因此不会为这块内存做过多处理,包括不会为其分配真正的物理内存空间,甚至页表,即进程中用来完成虚拟地址到物理地址映射的页表的存储空间也是随需分配的。

Win32虚拟内存管理器使用另外一个数据结构来记录和维护每个进程的4 GB虚拟地址空间的使用及状态信息,这就是虚拟地址描述符树(Virtual Address Descriptor,VAD)。每一个进程都有一个自己的VAD集合,这个集合中的VAD被组织成一个自平衡二叉树,以提高查找的效率。另外只有预留或者提交的内存块才会有VAD,自由的内存块没有VAD(因此不在VAD树结构中的虚拟地址块就是自由的)。VAD的组织如图4-5所示。

图4-5  VAD的组织结构

(1)当程序申请一块新内存时,虚拟内存管理器只需访问VAD树。找到两个相邻VAD,只要小的VAD的上限与大的VAD的下限之间的差值满足所申请的内存块的大小需求,即可使用二者之间的虚拟内存。

(2)当第一次访问提交的内存时,虚拟内存管理器根据上一节描述的流程。即总是假定该数据页已在物理内存中,并进行虚拟地址到物理地址的转换。当找到相应的页目录项后发现该页目录项并没有指向一个合法的页表,它就会查找该进程的VAD树。找到包含该地址的VAD,并根据VAD中的信息,比如该内存块的大小、范围,以及在调页文件中的起始位置等,随需生成相应的页表项,然后从刚才发生缺页错误的地方继续进行地址映射。由此可以看出,一个虚拟内存页被提交时,除了在调页文件中开辟一个备份页之外,不会生成包含指向它的页表项的页表,也不会填充指向它的页表项,更不会为之开辟真正的物理内存页,而是直到第一次访问这个提交页时,才会“随需地”从VAD中取得包含该页的整个区域的信息,生成相应页表,并填充相应页的表项。

(3)当访问预留的内存时,虚拟内存管理器也是根据上一节描述的流程进行虚拟地址到物理地址的映射,找到相应的页目录项后发现该页目录项并没有指向一个合法的页表,它就会查找该进程的VAD树,找到包含该地址的VAD。这时它会发现此段内存块只是预留的,而没有提交,即并没有对应的真正的物理存储,这时直接抛出访问违例,进程退出。

(4)当访问自由的内存时,虚拟内存管理器还是根据上一节描述的流程进行虚拟地址到物理地址的映射。找到相应的页目录项后发现该页目录项并没有指向一个合法的页表,它就会查找该进程的VAD树,发现并没有VAD包含此虚拟地址,此时可以知道该地址所在的虚拟地址页是自由状态,直接抛出访问违例,进程退出。

4.1.5  进程工作集

因为频繁的调页操作引起的磁盘I/O会大大降低程序的运行效率,因此对每一个进程,虚拟内存管理器都会将其一定量的内存页驻留在物理内存中。并跟踪其执行的性能指标,动态调整这个数量。Win32中驻留在物理内存中的内存页称为进程的“工作集”(working set),进程的工作集可以通过“任务管理器”查看,其中“内存使用”列即为工作集大小。图4-6中绿色方框的数字是笔者写作本书时所用Word编辑器的工作集大小,即38740 KB。

工作集是会动态变化的,进程初始时只有很少的代码页和数据页被调入内存。当执行到未被调入内存的代码或者访问到尚未调入内存的数据时,这些代码页或者数据页会被调入物理内存,工作集也随之增长。但工作集不能无限增长,系统为每个进程都定义了一个默认的最小工作集(根据系统物理内存大小,此值可能为20~50 MB)和最大工作集(根据系统物理内存大小,此值可能为45~345 MB)。当工作集到达最大工作集,即进程需要再次调入新页到物理内存中时,虚拟内存管理器会将其原来的工作集中的某些页先置换出内存,然后将需要调入的新页调入内存。

图4-6  工作集

因为工作集的页驻留在物理内存中,因此对这些页的访问不涉及磁盘I/O,相对而言非常快;反之,如果执行的代码或者访问的数据不在工作集中,则会引发额外的磁盘I/O,从而降低程序的运行效率。一个极端的情况就是所谓的颠簸或抖动(thrashing),即程序的大部分的执行时间都花在了调页操作上,而不是代码执行上。

如前所述,虚拟内存管理器在调页时,不仅仅只是调入需要的页,同时还将其附近的页也一起调入内存中。综合这些知识,对开发人员来说,如果想提高程序的运行效率,应该考虑以下两个因素。

(1)对代码来说,尽量编写紧凑代码,这样最理想的情形就是工作集从不会到达最大阀值。在每次调入新页时,也就不需要置换已经载入内存的页。因为根据locality特性,以前执行的代码和访问的数据在后面有很大可能会被再次执行或访问。这样程序执行时,发生的缺页错误数就会大大降低,即减少了磁盘I/O,在图4-6中也可以看到一个程序执行时截至当时共发生的缺页错误次数。即使不能达到这种理想情形,紧凑的代码也往往意味着接下来执行的代码更大可能就在相同的页或相邻页。根据时间locality特性,程序80%的时间花在了20%的代码上。如果能将这20%的代码尽量紧凑且排在一起,无疑会大大提高程序的整体运行性能。

(2)对数据来说,尽量将那些会一起访问的数据(比如链表)放在一起。这样当访问这些数据时,因为它们在同一页或相邻页,只需要一次调页操作即可完成;反之,如果这些数据分散在多个页(更糟的情况是这些页还不相邻),那么每次对这些数据的整体访问都会引发大量的缺页错误,从而降低性能。利用Win32提供的预留和提交两步机制,可以为这些会一同访问的数据预留出一大块空间。此时并没有分配实际存储空间,然后在后续执行过程中生成这些数据时随需为它们提交内存。这样既不浪费真正的物理存储(包括调页文件的磁盘空间和物理内存空间),又能利用locality特性。另外内存池机制也是基于类似的考虑。

4.1.6  Win32内存相关API

在Win32平台下,开发人员可以通过如下5组函数来使用内存(完成申请和释放等操作)。

(1)传统的CRT函数(malloc/free系列):因为这组函数的平台无关性,如果程序会被移植到其他非Windows平台,则这组函数是首选。也正因为这组函数非Win32专有,而且介绍这组函数的资料俯拾皆是,这里不作详细介绍。

(2)global heap/local heap函数(GlobalAlloc/LocalAlloc系列):这组函数是为了向后兼容而保留的。在Windows 3.1平台下,global heap为系统中所有进程共有的堆,这些进程包括系统进程和用户进程。它们对此global heap内存的申请会交错在一起,从而使得一个用户进程的不小心的内存使用错误会导致整个操作系统的崩溃。local heap又被称为“private heap”,与global heap相对应,local heap为每个进程私有。进程通过LocalAlloc从自己的local heap里申请内存,而不会相互干扰。除此之外,进程不能通过另外的用户自定义堆或者其他方式动态地申请内存。到了Win32平台,由于考虑到安全因素,global heap已经废弃,local heap也改名为“process heap”。为了使得以前针对Windows 3.1平台写的应用程序能够运行在新的Win32平台上,GlobalAlloc/ LocalAlloc系列函数仍然得到沿用,但是这一系列函数最后都是从process heap中分配内存。不仅如此,Win32平台还允许进程除process heap之外生成和使用新的用户自定义堆,因此在Win32平台下建议不使用GlobalAlloc/LocalAlloc系列函数进行内存操作,因此这里不详细介绍这组函数。

(3)虚拟内存函数(VirtualAlloc/VirtualFree系列):这组函数直接通过保留(reserve)和提交(commit)虚拟内存地址空间来操作内存,因此它们为开发人员提供最大的自由度,但相应地也为开发人员内存管理工作增加了更多的负担。这组函数适合于为大型连续的数据结构数组开辟空间。

(4)内存映射文件函数(CreateFileMapping/MapViewOfFile系列):系统使用内存映射文件函数系列来加载.exe或者.dll文件。而对开发人员而言,一方面通过这组函数可以方便地操作硬盘文件,而不用考虑那些繁琐的文件I/O操作;另一方面,运行在同一台机器上的多个进程可以通过内存映射文件函数来共享数据(这也是同一台机器上进程间进行数据共享和通信的最有效率和最方便的方法)。

(5)堆内存函数(HeapCreate/HeapAlloc系列):Win32平台中的每个堆都是各进程私有的,每个进程除了默认的进程堆,还可以另外创建用户自定义堆。当程序需要动态创建多个小数据结构时,堆函数系列最为适合。一般来说CRT函数(malloc/free)就是基于堆内存函数实现的。

1.虚拟内存

虚拟内存相关函数共有4对,即VirtualAlloc/VirtualFree、VirtualLock/VirtualUnlock、VirtualQuery/VirtualQueryEx及VirtualProtect/VirtualProtectEx。其中最重要的是第一对,本节主要介绍这一对。

LPVOID VirtualAlloc(

    LPVOID lpAddress,

    DWORD dwSize,

    DWORD flAllocationType,

    DWORD flProtect

);

VirtualAlloc根据flAllocationType的不同,可以保留一段虚拟内存区域(MEM_ RESERVE)或者提交一段虚拟内存区域(MEM_COMMIT)。当保留时,除了修改进程的VAD之外(准确地说是增加了一项),并没有分配其他资源,如调页文件空间或者实际物理内存,甚至没有创建页表项。因此非常快捷,而且执行速度与保留空间的大小没有关系。因为保留仅仅只是让内存管理器预留一段虚拟地址空间,并没有实在的存储(硬盘上的调页文件空间或者物理内存),因此访问保留地址会引起访问违例,这是一种严重错误,会直接导致进程退出;相反,提交虚拟内存时,内存管理器必须从系统调页文件中开辟实际的存储空间,因此速度会比保留操作慢。但是需要注意的是,此时在物理内存中并没有立刻分配空间用来与这段虚拟内存空间相对应,甚至也没有相应的页表项被创建,但是提交操作会相应修改VAD项。只有首次访问这段虚拟地址空间中的某个地址时,由于缺页中断,虚拟内存管理器查找VAD,接着根据VAD的内容,动态创建PTE,然后根据PTE信息,分配物理内存页,并实际访问该内存。由此可见,真正花费时间的操作不是提交内存,而是对提交内存的第一次访问!这种lazy-evaluation机制对程序运行性能是十分有益的,因为如果某个程序提交了大段内存,但只是零星地对其中的某些页进行访问,如果没有这种lazy-evaluation机制,提交大段内存会极大地降低系统的性能。

与之相对,VirtualFree释放内存,它提供两种选择:可以将提交的内存释放给系统,但是不释放保留的虚拟内存地址空间;也可以在释放内存的同时将虚拟内存地址空间一并释放,这样这块虚拟内存地址空间的状态变回初始的自由状态。如果内存是提交状态,VirtualFree因为会释放真正的存储空间而比较慢;如果只是释放保留的虚拟内存地址空间,那么因为只需要修改VAD,该操作会很快。

除此之外,VirtualLock保证某块内存在lock期间一直在物理内存中,因此对该内存的访问不会引起缺页中断。lock的内存用VirtualUnlock解锁。因为VirtualLock会把内存锁定在物理内存中,如果这些内存实际中访问的并不频繁,那么会使得其他经常使用到的内存反而增大了被调页出去的概率,从而降低了系统的整体性能,因此在实际使用中,并不推荐使用VirtualLock/VirtualUnlock函数。VirtualQuery可以获得传入指针所在的虚拟内存块的状态,如包含该指针所在页的虚拟内存区域的基址,以及该区域的状态等。VirtualProtect可用来修改某段区域的提交内存页的存取保护标志。

2.内存映射文件

内存映射文件主要有三个用途,Windows利用它来有效使用exe和dll文件,开发人员利用它来方便地访问硬盘文件,或者实现不同进程间的内存共享。第一种这里不详细介绍,只介绍后两种用途。首先讨论它提供的方便访问硬盘文件的机制,一旦通过这种机制将一个硬盘文件(部分或者全部)映射到进程的一段虚拟地址空间中,读写该文件的内容就像通过指针访问变量一样。假设pViewMem为文件映射到内存的首址,那么:

*pViewMem = 100;                     //写文件的第1个字节

char ch = *(pViewMem + 50);     //读文件的第50个字节内容

下面介绍这种机制的使用步骤。

(1)新建或者打开一个硬盘文件。

此步骤用来获得一个文件对象的句柄,用CreateFile函数来新建或者打开一个文件:

HANDLE CreateFile(

PCSTR pszFileName,

DWORD dwDesiredAccess,

DWORD dwShareMode,

PSECURITY_ATTRIBUTES psa,

DWORD dwCreationDisposition,

DWORD dwFlagsAndAttributes,

HANDLE hTemplateFile);

其中pszFileName参数指示该文件的路径名,dwDesiredAccess参数表示该文件内容将会被如何访问,此参数包括0、GENERIC_READ、GENERIC_WRITE,以及GENERIC_ READ | GENERIC_WRITE共4种可能,分别表示“不能读也不能写”(在只为了读取该文件属性时使用)、“只读”、“只写”,以及“既可读也可写”;dwShareMode参数用来限定对该文件的任何其他访问的权限,也包括上述4种类型。剩余的几个参数因为与要讨论的问题关系不大,所以不赘述。

此函数成功时,会返回一个文件对象句柄;否则会返回INVALID_HANDLE_ VALUE。

(2)创建或者打开一个文件映射内核对象。

还需要有一个文件映射内核对象,正是它真正将文件内容映射到内存中。如果已经存在此内核对象,只需通过OpenFileMapping函数将其打开即可,这个函数返回该命名对象的句柄。大多数情况下,需要新建一个文件映射内核对象,此时调用CreateFileMapping函数:

HANDLE CreateFileMapping(

HANDLE hFile,

PSECURITY_ATTRIBUTES psa,

DWORD fdwProtect,

DWORD dwMaximumSizeHigh,

DWORD dwMaximumSizeLow,

PCTSTR pszName);

hFile参数是第一个步骤中返回的文件内核对象句柄;psa参数是指明内核对象安全特性的,不详述;fdwProtect参数指明了对映射到内存页中的文件内容的存取权限,这个权限必须与第一个步骤中的文件访问权限对应;dwMaximumSizeHigh和dwMaximumSizeLow参数指明映射的最大的空间大小,因为Windows支持大小达到64位的文件,因此需要两个32位的参数;pszName为内核对象名称。

此步只是创建了一个文件映射内核对象,并没有预留或者提交虚拟地址空间,更没有物理内存页被分配出来存放文件内容。

(3)映射文件的内容到进程虚拟地址空间。

访问文件内容之前,必须将要访问的文件内容映射到内存中,通过MapViewOfFile函数完成:

PVOID MapViewOfFile(

HANDLE hFileMappingObject,

DWORD dwDesiredAccess,

DWORD dwFileOffsetHigh,

DWORD dwFileOffsetLow,

SIZE_T dwNumberOfBytesToMap);

其中参数分别为:用来映射内存映射内核对象的句柄,映射的文件内容到内存内存页的存取权限,需要映射的文件内容的起始部分在文件中的偏移及大小。映射时并不需要一次将整个文件的内容全部映射到内存中。

这个函数的操作包括从进程虚拟地址空间中预留出所需映射大小的一段区域,然后提交。提交时并不是从系统的调页文件中开辟空间用来作为该段区域的备份存储,而是内存映射内核对象所对应的文件的指明区域。与虚拟内存使用的惟一不同就是该段虚拟地址空间区域的备份存储不同,其他都是一样的。同样,此时并没有真正的物理内存开辟出来,直到通过返回的指针访问已经映射到内存中的文件内容时,因为发生缺页错误,系统才会分配物理内存页,并将对应的文件存储中的内容调页到该物理内存页。

(4)访问文件内容。

现在可以通过MapViewOfFile函数返回的指针来访问该段映射到内存中文件内容,就像本小节演示的那样,通过指针访问硬盘文件内容。

这里需要提醒的是,通过该指针修改文件内容时,修改的结果常常不会立刻反映到文件中,因为实际上是在对调入物理内存页中的数据进行修改。考虑到性能因素,该页并不会每做一次修改就立刻将该修改同步到硬盘文件中。如果需要在某个时候强制将之前所做的修改一次性同步到与之对应的硬盘文件中时,可以通过FlushViewOfFile函数达到这个目的:

BOOL FlushViewOfFile(PVOID pvAddress, SIZE_T dwNumberOfBytesToFlush);

这个函数传入需要将修改同步到硬盘文件中的内存块的起始地址和大小。

(5)取消文件内容到进程虚拟地址空间的映射。

当该段映射到内存中的文件内容访问完毕,不再需要访问时,为了有效地利用系统的资源,应该及时回收该段内存,这时调用UnmapViewOfFile函数:

BOOL UnmapViewOfFile(PVOID pvBaseAddress);

此函数传入MapViewOfFile函数返回的指针,系统回收对应的MapViewOfFile调用时预留并提交的虚拟内存地址空间区域,这样该段区域可被其他申请使用。另外因为对应的备份存储不是系统的调页文件,所以不存在备份存储回收的问题。

(6)关闭文件映射内核对象和文件内核对象。

最后,在完成任务不再使用该文件时,通过CloseHandle(hFile)和CloseHandle (hMapping)来关闭文件并释放内存映射文件的内核对象句柄。

下面接着讨论如何利用内存映射文件内核对象来进行进程间的内存共享。

进程间通过内存映射文件进行内存共享时,该内存映射文件内核对象常常不是基于某一个硬盘文件,而是从系统的调页文件中开辟空间作为临时用做共享的存储空间。因此与单纯地利用内存映射文件来访问硬盘文件内容稍有不同,下面是通过内存映射文件来进行进程间内存共享的步骤。假设有进程A和进程B,进程A通过CreateFileMapping创建一个基于系统调页文件的名为“SharedMem”的内存映射文件内核对象:

HANDLE m_hFileMapA = CreateFileMapping

(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0,

10 * 1024, TEXT("SharedMem"));

需要注意的是,因为现在不再基于普通的硬盘文件,所以不需要调用CreateFile来新建或者打开文件这个步骤,注意此时传入的文件句柄参数为INVALID_HANDLE_VALUE,此参数代表从调页文件中开辟空间作为共享内存。

进程B通过OpenFileMapping打开刚才进程A创建的名为“SharedMem”的内存映射文件内核对象:

HANDLE m_hFileMapB = OpenFileMapping(..., TEXT("SharedMem"));

进程A和进程B都可以用此内存映射文件内核对象将从系统调页文件中开辟的那块存储空间的全部或者部分映射到内存中,然后即可使用。

进程A:

...

PVOID pViewA = MapViewOfFile(m_hFileMapA, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);

...

进程B:

...

PVOID pViewB = MapViewOfFile(m_hFileMapB, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0);

...

它们各自对该共享内存的修改都能够及时地被对方看到。另外需要注意的是,它们映射到的虚拟内存空间区域并不一定有相同的起始地址,这是因为它们拥有自己的虚拟地址空间。

还有一个需要引起注意,但很难发现的问题是因为创建基于系统调页文件的内存映射文件内核对象是通过传入hFile为INVALID_HANDLE_VALUE的参数来标记的,而创建或者打开普通硬盘文件失败时的返回值也是INVALID_HANDLE_VALUE,因此诸如下面这段代码存在的bug是很难发现的:

...

HANDLE hFile = CreateFile(...);

HANDLE hMap = CreateFileMapping(hFile, ...);

if (hMap == NULL)

return(GetLastError());

...

这段代码的本意是首先创建或者打开一个普通的硬盘文件,然后创建一个基于此文件的内存映射文件内核对象,而并不是想创建一个基于系统调页文件的该对象。但是可以看到,当第1句CreateFile执行失败时,返回INVALID_HANDLE_VALUE。这个返回值立刻被传入到CreateFileMapping函数,结果创建了一个基于系统调页文件的内存映射文件内核对象。这并不是这段代码的原意,而且也会造成问题。因为基于普通硬盘文件的内存映射文件内核对象的操作往往希望将最后的结果保存在该文件中,而基于系统调页文件的内存映射文件内核对象的操作往往只是关注该数据在执行期的结果,操作完毕后并不保存该结果。当CreateFile失败且程序运行后,程序运行无误。但是当检查结果文件时,会发现该文件要么没有被创建,要么数据没有改动,因为随后的操作都是基于系统调页文件的!

因此当使用基于普通硬盘文件的内存映射文件内核对象时,一定要在CreateFile调用完后检查返回值。

3.堆

分配多个小块内存一般都会选择使用堆函数,比如链表节点和树节点等,堆函数的最大优点就是开发人员不用考虑页边界之类的琐碎事情;劣势就是堆函数的操作相对虚拟内存和内存映射文件来说速度要慢些,而且无法像虚拟内存或者内存映射文件那样直接提交或者回收物理存储。

进程都有一个默认的堆,其初始区域大小默认是1 MB,链接时可以通过/HEAP参数修改此默认值。很多操作的临时存储都使用进程的默认堆,比如绝大多数的Win32函数,进程默认堆的句柄可以通过GetProcessHeap函数获得。

因为程序大部分的内存需求都是从进程默认堆中分配的,而且在多线程情况下还需要考虑线程安全问题。因此对特定的应用,这种情况会造成程序的性能下降。针对这种需求,Win32提供了自定义堆机制。

自定义堆的步骤如下。

(1)创建自定义堆。

与进程默认堆(进程创建时系统自动创建)不同,自定义堆需要开发人员首先通过HeapCreate函数创建:

HANDLE HeapCreate(

DWORD fdwOptions,

SIZE_T dwInitialSize,

SIZE_T dwMaximumSize);

fdwOptions参数可以指明是否需要串行化访问支持(HEAP_NO_SERIALIZE),以及分配和回收内存出错时是否抛出异常(HEAP_GENERATE_EXCEPTIONS)。当该自定义堆会被多个线程同时访问时,需要加上串行化访问支持,但相应的性能会有所下降。

dwInitialSize参数指明该自定义堆创建时提交的存储大小(页大小的倍数),dwMaximumSize参数则指明该自定义堆从进程虚拟地址空间中预留出的区域大小。随着对此自定义堆内存的分配,提交的存储大小随之变大,但此参数限制了增大的极限。另一种情况时是dwMaximumSize为0,此时该自定义堆可以一直增长,直到进程虚拟地址空间用完。

(2)从自定义堆中分配内存。

从自定义堆中分配内存调用函数HeapAlloc(从进程默认堆中分配内存也调用此函数):

PVOID HeapAlloc(

HANDLE hHeap,

DWORD fdwFlags,

SIZE_T dwBytes);

hHeap参数即上一步骤中返回的堆内核对象句柄,fdwFlags可以取HEAP_ ZERO_MEMORY、HEAP_GENERATE_EXCEPTIONS和HEAP_NO_SERIALIZE共3个值,HEAP_ZERO_MEMORY指明返回的内存必须全部清0。HEAP_GENERATE_ EXCEPTIONS指明此次分配内存如果失败,需要抛出异常。如果该自定义堆创建时指明过此参数,则其上的内存分配不必再指明此参数;如果堆创建时没有指明,则可以在每次申请时指明。HEAP_NO_SERIALIZE参数指明此次分配不必串行化访问支持。最后的dwBytes参数指明此次分配的内存大小,返回值为分配内存的起始位置。

(3)释放内存。

从堆中释放内存调用HeapFree函数:

BOOL HeapFree(

HANDLE hHeap,

DWORD fdwFlags,

PVOID pvMem);

这个函数的参数意义很明显,无须赘述。需要指出的是,这样释放内存并不能保证所有物理存储被回收,一是因为物理存储以页大小为单位判断是否可以回收;二是Windows设计堆机制时对效率的考虑。

(4)销毁自定义堆。

当程序不再需要使用某个自定义堆时,调用HeapDestroy函数:

BOOL HeapDestroy(HANDLE hHeap);

对堆的销毁有几点需要说明,一是当堆销毁时,所有从该堆分配的内存全部被回收,而不必对那些内存一一进行释放,同时该堆占用物理存储以及虚拟地址空间区域也会被系统回收;二是如果没有显式销毁自定义堆,这些堆会在程序退出时被系统销毁。需要注意的是,线程创建的自定义堆并不会在线程退出时被销毁,而是当整个进程退出时才会被销毁,从资源利用效率角度出发,应该在自定义堆不再被使用时立即销毁;三是进程默认堆不能通过此函数销毁,更严格地说,进程默认堆在进程退出前是不能被销毁的。

自定义堆的其他函数如下。

(1)获得进程所有堆:

DWORD GetProcessHeaps(

DWORD dwNumHeaps,

PHANDLE pHeaps);

此函数返回进程目前所有的堆(包括进程默认堆),传入存放所有堆内核对象句柄的数组,以及数组的大小,返回值为堆数目。

(2)修改分配内存的大小:

PVOID HeapReAlloc(

HANDLE hHeap,

DWORD fdwFlags,

PVOID pvMem,

SIZE_T dwBytes);

这个函数可以修改原来分配的内存块(pvMem)的大小,新的大小由参数dwBytes指明。

(3)查询某块分配内存的大小:

SIZE_T HeapSize(

HANDLE hHeap,

DWORD fdwFlags,

LPCVOID pvMem);

这个函数可以查询到原来分配的一个内存块的大小。当该内存块指针是外部模块传入时,如果需要知道该块确切大小时,这个函数就可以发挥作用。

(4)堆压缩:

UINT HeapCompact(

HANDLE hHeap,

DWORD fdwFlags);

此函数将相邻的回收回来的自由块合并在一起,需要注意的是,这个函数并不能移动已经分配的内存块,即它并不能消除内存碎片。

自定义堆有如下优点。

(1)减少碎片,节省内存:由于大多数自定义堆是为某些特定的数据结构创建的,所以这些数据结构大小相同,从而使得上次释放的空间有更大机会刚好可以满足下一次的内存申请,从而减少了碎片的产生。

(2)由于局部性获得的性能提高:由上所述,自定义堆上的内存块大多是某些特定的数据结构,比如链表节点或者树节点。这些数据结构有着很强的时间局部性,即程序往往会在相邻的时间内访问所有这些数据。如果这些数据都放在某一个自定义堆中,这种空间局部性就会极大地减少对这些数据整体访问引起的缺页错误,从而提高了程序的运行性能。

(3)由避免线程同步获得的性能提高:如前所述,因为进程默认堆可能会被多个线程同时访问,因此添加了保证线程安全的串行化访问支持,但串行化访问支持的代价就是性能下降。如果某个自定义堆只允许某单个线程访问,那么此自定义堆不必添加串行化访问支持,从而提高程序的性能。 


转自:http://blog.csdn.net/gaoteng1984/article/details/1681862

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值