摘要: 本文主要对Windows内存管理中的堆管理技术进行讨论,并简要介绍了堆的创建、内存块的分配与再分配、堆的撤销以及new和delete操作符的使用等内容。
关键词: 堆;堆管理
1 引言
在大多数Windows应用程序设计中,都几乎不可避免的要对内存进行操作和管理。在进行大尺寸内存的动态分配时尤其显的重要。本文即主要对内存管理中的堆管理技术进行论述。
堆(Heap)实际是位于保留的虚拟地址空间中的一个区域。刚开始时,保留区域中的多数页面并没有被提交物理存储器。随着从堆中越来越多的进行内存分配,堆管理器将逐渐把更多的物理存储器提交给堆。堆的物理存储器从系统页文件中分配,在释放时有专门的堆管理器负责对已占用物理存储器的回收。堆管理也是Windows提供的一种内存管理机制。主要用来分配小的数据块。与Windows的其他两种内存管理机制虚拟内存和内存映射文件相比,堆可以不必考虑诸如系统的分配粒度和页面边界之类比较烦琐而又容易忽视的问题,可将注意力集中于对程序功能代码的设计上。但是使用堆去分配、释放内存的速度要比其他两种机制慢的多,而且不具备直接控制物理存储器提交与回收的能力。
在进程刚启动时,系统便在刚创建的进程虚拟地址空间中创建了一个堆,该堆即为进程的默认堆,缺省大小为1MB,该值允许在链接程序时被更改。进程的默认堆是比较重要的,可供众多Windows函数使用。在使用时,系统必须保证在规定的时间内,每此只有一个线程能够分配和释放默认堆中的内存块。虽然这种限制将会对访问速度产生一定的影响,但却可以保证进程中的多个线程在同时调用各种Windows函数时对默认堆的顺序访问。在进程中允许使用多个堆,进程中包括默认堆在内的每个堆都有一个堆句柄来标识。与自己创建的堆不同,进程默认堆的创建、销毁均由系统来完成,而且其生命期早在进程开始执行之前就已经开始,虽然在程序中可以通过GetProcessHeap()函数得到进程的默认堆句柄,但却不允许调用HeapDestroy()函数显式将其撤消。
2 对动态创建堆的需求
前面曾提到,在进程中除了进程默认堆外,还可以在进程虚拟地址空间中动态创建一些独立的堆。至于在程序设计时究竟需不需要动态创建独立的堆可以从是否有保护组件的需要、是否能更加有效地对内存进行管理、是否有进行本地访问的需要、是否有减少线程同步开销的需要以及是否有迅速释放堆的需要等几个方面去考虑。
对于是否有保护组件的需要这一原则比较容易理解。在图1中,左边的图表示了一个链表(节点结构)组件和一个树(分支结构)组件共同使用一个堆的情况。在这种情况下,由于两组件数据在堆中的混合存放,如果节点3(属于链表组件)的后几个字节由于被错误改写,将有可能影响到位于其后的分支2(属于树组件)。这将致使树组件的相关代码在遍历其树时由于内存被破坏而无法进行。究其原因,树组件的内存是由于链表组建对其自身的错误操作而引起的。如果采用右图所示方式,将树组件和链表组件分别存放于一个独立的堆中,上述情况显然不会发生,错误将被局限于进行了错误操作的链表组件,而树组件由于存放在独立的堆中而受到了保护。
图1 动态创建堆在保护组件中的作用
在上图中,如果链表组件的每个节点占用12个字节,每个树组件的分支占用16个字节如果这些长度不一的对象共用一个堆(左图),在左图中这些已经分配了内存的对象已占满了堆,如果其中有节点2和节点4释放,将会产生24个字节的碎片,如果试图在24个字节的空闲区间内分配一个16字节的分支对象,尽管要分配的字节数小于空闲字节数,但分配仍将失败。只有在堆栈中分配大小相同的对象才可以实行更加有效的内存管理。如果将树组件换成其他长度为12字节的组件,那么在释放一个对象后,另一个对象就可以恰好填充到此刚释放的对象空间中。
进行本地访问的需要也是一条比较重要的原则。系统会经常在内存与系统页文件之间进行页面交换,但如果交换次数过多,系统的运行性能就将受很大的影响。因此在程序设计时应尽量避免系统频繁交换页面,如果将那些会被同时访问到的数据分配在相互靠近的位置上,将会减少系统在内存和页文件之间的页面交换频率。
线程同步开销指的是默认条件下以顺序方式运行的堆为保护数据在多个线程试图同时访问时不受破坏而必须执行额外代码所花费的开销。这种开销保证了堆对线程的安全性,因此是有必要的,但对于大量的堆分配操作,这种额外的开销将成为一个负担,并降低程序的运行性能。为避免这种额外的开销,可以在创建新堆时通知系统只有单个线程对访问。此时堆对线程的安全性将有应用程序来负责。
最后如果有迅速释放堆的需要,可将专用堆用于某些数据结构,并以整个堆去释放,而不再显式地释放在堆中分配的每一个内存块。对于大多数应用程序,这样的处理将能以更快的速度运行。
3 创建堆
在进程中,如果需要可以在原有默认堆的基础上动态创建一个堆,可由HeapCreate()函数完成:
HANDLE HeapCreate( DWORD flOptions, DWORD dwInitialSize, DWORD dwMaximumSize ); |
其第一个参数flOptions指定了对新建堆的操作属性。该标志将会影响一些堆函数如HeapAlloc()、HeapFree()、HeapReAlloc()和HeapSize()等对新建堆的访问。其可能的取值为下列标志及其组合:
属性标志 | 说明 |
HEAP_GENERATE_EXCEPTIONS | 在遇到由于内存越界等而引起的函数失败时,由系统抛出一个异常来指出此失败,而不是简单的返回NULL指针。 |
HEAP_NO_SERIALIZE | 指明互斥现象不会出现 |
参数dwInitialSize和dwMaximumSize分别为堆的初始大小和堆栈的最大尺寸。其中,dwInitialSize的值决定了最初提交给堆的字节数。如果设置的数值不是页面大小的整数倍,则将被圆整到邻近的页边界处。而dwMaximumSize则实际上是系统能为堆保留的地址空间区域的最大字节数。如果该值为0,那么将创建一个可扩展的堆,堆的大小仅受可用内存的限制。如果应用程序需要分配大的内存块,通常要将该参数设置为0。如果dwMaximumSize大于0,则该值限定了堆所能创建的最大值,HeapCreate()同样也要将该值圆整到邻近的页边界,然后再在进程的虚拟地址空间为堆保留该大小的一块区域。在这种堆中分配的内存块大小不能超过0x7FFF8字节,任何试图分配更大内存块的行为将会失败,即使是设置的堆大小足以容纳该内存块。如果HeapCreate()成功执行,将会返回一个标识新堆的句柄,并可供其他堆函数使用。
需要特别说明的是,在设置第一个参数时,对HEAP_NO_SERIALIZE的标志的使用要谨慎,一般应避免使用该标志。这是同后续将要进行的堆函数HeapAlloc()的执行过程有关系的,在HeapAlloc()试图从堆中分配一个内存块时,将执行下述几步操作:
1) 遍历分配的和释放的内存块的链接表
2) 搜寻一个空闲内存块的地址
3) 通过将空闲内存块标记为"已分配"来分配新内存块
4) 将新分配的内存块添加到内存块列表
如果这时有两个线程1、2试图同时从一个堆中分配内存块,那么线程1在执行了上面的1和2步后将得到空间内存块的地址。但是由于CPU对线程运行时间的分片,使得线程1在执行第3步操作前有可能被线程2抢走执行权并有机会去执行同样的1、2步操作,而且由于先执行的线程1并没有执行到第3步,因此线程2会搜寻到同一个空闲内存块的地址,并将其标记为已分配。而线程1在恢复运行后并不能知晓该内存块已被线程2标记过,因此会出现两个线程军认为其分配的是空闲的内存块,并更新各自的联接表。显然,象这种两个线程拥有完全相同内存块地址的错误是非常严重而又是难以发现的。
由于只有在多个线程同时进行操作时才有可能出现上述问题,一种简单的解决的办法就是不使用HEAP_NO_SERIALIZE标志而只允许单个线程独占地对堆及其联接表拥有访问权。如果一定要使用此标志,为了安全起见,必须确保进程为单线程的或是在进程中使用了多线程,但只有单个线程对堆进行访问。再就是使用了多线程,也有多个线程对堆进行了访问,但这些线程通过使用某种线程同步手段。如果可以确保以上几条中的一条成立,也是可以安全使用HEAP_NO_SERIALIZE标志的,而且还将拥有快的访问速度。如果不能肯定上述条件是否满足,建议不使用此标志而以顺序的方式访问堆,虽然线程速度会因此而下降但却可以确保堆及其中数据的不被破坏。
4 从堆中分配内存块
在成功创建一个堆后,可以调用HeapAlloc()函数从堆中分配内存块。在此,除了可以从用HeapCreate()创建的动态堆中分配内存块,也可以直接从进程的默认堆中分配内存块。下面先给出HeapCreate()的函数原型:
LPVOID HeapAlloc( HANDLE hHeap, DWORD dwFlags, DWORD dwBytes ); |
其中,参数hHeap为要分配的内存块来自的堆的句柄,可以是从HeapCreate()创建的动态堆句柄也可以是由GetProcessHeap()得到的默认堆句柄。参数dwFlags指定了影响堆分配的各个标志。该标志将覆盖在调用HeapCreate()时所指定的相应标志,可能的取值为:
标志 | 说明 |
HEAP_GENERATE_EXCEPTIONS | 该标志指定在进行诸如内存越界操作等情况时将抛出一个异常而不是简单的返回NULL指针 |
HEAP_NO_SERIALIZE | 强制对HeapAlloc()的调用将与访问同一个堆的其他线程不按照顺序进行 |
HEAP_ZERO_MEMORY | 如果使用了该标志,新分配内存的内容将被初始化为0 |
最后一个参数dwBytes设定了要从堆中分配的内存块的大小。如果HeapAlloc()执行成功,将会返回从堆中分配的内存块的地址。如果由于内存不足或是其他一些原因而引起HeapAlloc()函数的执行失败,将会引发异常。通过异常标志可以得到引起内存分配失败的原因:如果为STATUS_NO_MEMORY则表明是由于内存不足引起的;如果是STATUS_Access_VIOLATION则表示是由于堆被破坏或函数参数不正确而引起分配内存块的尝试失败。以上异常只有在指定了HEAP_GENERATE_EXCEPTIONS标志时才会发生,如果没有指定此标志,在出现类似错误时HeapAlloc()函数只是简单的返回NULL指针。
在设置dwFlags参数时,如果先前用HeapCreate()创建堆时曾指定过HEAP_GENERATE_EXCEPTIONS标志,就不必再去设置HEAP_GENERATE_EXCEPTIONS标志了,因为HEAP_GENERATE_EXCEPTIONS标志已经通知堆在不能分配内存块时将会引发异常。另外,对HEAP_NO_SERIALIZE标志的设置应慎重,与在HeapCreate()函数中使用HEAP_NO_SERIALIZE标志类似,如果在同一时间有其他线程使用同一个堆,那么该堆将会被破坏。如果是在进程默认堆中进行内存块的分配则要绝对禁用此标志。
在使用堆函数HeapAlloc()时要注意:堆在内存管理中的使用主要是用来分配一些较小的数据块,如果要分配的内存块在1MB左右,那么就不要再使用堆来管理内存了,而应选择虚拟内存的内存管理机制。
5 再分配内存块
在程序设计时经常会由于开始时预见不足而造成在堆中分配的内存块大小的不合适(多数情况是开始时分配的内存较小,而后来实际需要更多的数据复制到内存块中去)这就需要在分配了内存块后再根据需要调整其大小。堆函数HeapReAlloc()将完成这一功能,其函数原型为:
LPVOID HeapReAlloc( HANDLE hHeap, DWORD dwFlags, LPVOID lpMem, DWORD dwBytes ); |
其中,参数hHeap为包含要调整其大小的内存块的堆的句柄。dwFlags参数指定了在更改内存块大小时HeapReAlloc()函数所使用的标志。其可能的取值为HEAP_GENERATE_EXCEPTIONS、HEAP_NO_SERIALIZE、HEAP_REALLOC_IN_PLACE_ONLY和HEAP_ZERO_MEMORY,其中前两个标志的作用与在HeapAlloc()中的作用相同。HEAP_REALLOC_IN_PLACE_ONLY标志在内存块被加大时不移动堆中的内存块,在没有设置此标志的情况下如果对内存进行增大,那么HeapReAlloc()函数将有可能将原内存块移动到一个新的地址。显然,在设置了该标志禁止内存快首地址进行调整时,将有可能出现没有足够的内存供试图增大的内存块使用,对于这种情况,函数对内存块增大调整的操作是失败的,内存块将仍保留原有的大小和位置。HEAP_ZERO_MEMORY标志的用处则略有不同,如果内存快经过调整比以前大,那么新增加的那部分内存将被初始化为0;如果经过调整内存块缩小了,那么该标志将不起任何作用。
函数的最后两个参数lpMem和dwBytes分别为指向再分配内存块的指针和再分配的字节数。如果函数成功执行,将返回新的改变了大小的内存块的地址。如果在调用时使用了HEAP_REALLOC_IN_PLACE_ONLY标志,那么返回的地址将与原内存块地址相同。如果因为内存不足等原因而引起函数的执行失败,函数将返回一个NULL指针。但是HeapReAlloc()的执行失败并不会影响原内存块,它将保持原来的大小和位置继续存在。可以通过HeapSize()函数来检索内存块的实际大小。
6 释放堆内存、撤消堆
在不再需要使用堆中的内存块时,可以通过HeapFree()将其予以释放。该函数结构比较简单,只含有三个参数:
BOOL HeapFree( HANDLE hHeap, DWORD dwFlags, LPVOID lpMem ); |
其中,hHeap为要包含要释放内存块的堆的句柄;参数dwFlags为堆栈的释放选项可以是0,也可以是HEAP_NO_SERIALIZE;最后的参数lpMem为指向内存块的指针。如果函数成功执行,将释放指定的内存块,并返回TRUE。该函数的主要作用是可以用来帮助堆管理器回收某些不使用的物理存储器以腾出更多的空闲空间,但是并不能保证一定会成功。
最后,在程序退出前或是应用程序不再需要其创建的堆了,可以调用HeapDestory()函数将其销毁。该函数只包含一个参数--待销毁的堆的句柄。HeapDestory()的成功执行将可以释放堆中包含的所有内存块,也可将堆占用的物理存储器和保留的地址空间区域全部重新返回给系统并返回TRUE。该函数只对由HeapCreate()显式创建的堆起作用,而不能销毁进程的默认堆,如果强行将由GetProcessHeap()得到的默认堆的句柄作为参数去调用HeapDestory(),系统将会忽略对该函数的调用。
7 对new与delete操作符的重载
new与delete内存空间动态分配操作符是C++中使用堆进行内存管理的一种常用方式,在程序运行过程中可以根据需要随时通过这两个操作符建立或删除堆对象。new操作符将在堆中分配一个足够大小的内存块以存放指定类型的对象,如果每次构造的对象类型不同,则需要按最大对象所占用的空间来进行分配。new操作符在成功执行后将返回一个类型与new所分配对象相匹配的指针,如果不匹配则要对其进行强制类型转换,否则将会编译出错。在不再需要这个对象的时候,必须显式调用delete操作符来释放此空间。这一点是非常重要的,如果在预分配的缓冲里构造另一个对象之前或者在释放缓冲之前没有显式调用delete操作符,那么程序将产生不可预料的后果。在使用delete操作符时,应注意以下几点:
1) 它必须使用于由运算符new返回的指针
2) 该操作符也适用于NULL指针
3) 指针名前只用一对方括号符,并且不管所删除数组的维数,忽略方括号内的任何数字
class CVMShow{ private: static HANDLE m_sHeap; static int m_sAllocedInHeap; public: LPVOID operator new(size_t size); void operator delete(LPVOID pVoid); } …… HANDLE m_sHeap = NULL; int m_sAllocedInHeap = 0; LPVOID CVMShow::operator new(size_t size) { if (m_sHeap == NULL) m_sHeap = HeapCreate(HEAP_GENERATE_EXCEPTIONS, 0, 0); LPVOID pVoid = HeapAlloc(m_sHeap, 0, size); if (pVoid == NULL) return NULL; m_sAllocedInHeap++; return pVoid; } void CVMShow::operator delete(LPVOID pVoid) { if (HeapFree(m_sHeap, 0, pVoid)) m_sAllocedInHeap--; if (m_sAllocedInHeap == 0) { if (HeapDestory(m_sHeap)) m_sHeap = NULL; } } |
在程序中除了直接用上述方法使用new和delete来建立和删除堆对象外,还可以通过为C++类重载new和delete操作符来方便地利用堆栈函数。上面的代码对它们进行了简单的重载,并通过静态变量m_sHeap和m_sAllocedInHeap在类CVMShow的所有实例间共享唯一的堆句柄(因为在这里CVMShow类的所有实例都是在同一个堆中进行内存分配的)和已分配类对象的计数。这两个静态变量在代码开始执行时被分别初始化为NULL指针和0计数。
重载的new操作符在第一次被调用时,由于静态变量m_sHeap为NULL标志着堆尚未创建,就通过HeapCreate()函数创建一个堆并返回堆句柄到m_sHeap。随后根据入口参数size所指定的大小在堆中分配内存,同时已分配内存块计数器m_sAllocedInHeap累加。在该操作符的以后调用过程中,由于堆已经创建,故不再创建堆,而是直接在堆中分配指定大小的内存块并对已分配的内存块个数进行计数。
在CVMShow类对象不再被应用程序所使用时,需要将其撤消,由重载的delete操作符完成此工作。delete操作符只接受一个LPVOID型参数,即被删除对象的地址。该函数在执行时首先调用HeapFree()函数将指定的已分配内存的对象释放并对已分配内存计数递减1。如果该计数不为零则表明当前堆中的内存块没有全部释放,堆暂时不予撤消。如果m_sAllocedInHeap计数减到0,则堆中已释放完所有的CVMShow对象,可以调用HeapDestory()函数将堆销毁,并将堆句柄m_sHeap设置为NULL指针。这里在撤消堆后将堆句柄设置为NULL指针的操作是完全必要的。如果不执行该操作,当程序再次调用new操作符去分配一个CVMShow类对象时将会认为堆是存在的而会试图在已撤消的堆中去分配内存,显然将会导致失败。
象CVMShow这样设计的类通过对new和delete操作符的重载,并且在一个堆中为所有的CVMShow类对象进行分配,可以节省在为每一个类都创建堆的分配开销与内存。这样的处理还可以让每一个类都拥有属于自己的堆,并且允许派生类对其共享,这在程序设计中也是比较好的一种处理方法。
8 小结
在使用堆时有时会造成系统运行速度的减慢,通常是由以下原因造成的:分配操作造成的速度减慢;释放操作造成的速度减慢;堆竞争造成的速度减慢;堆破坏造成的速度减慢;频繁的分配和重分配造成的速度减慢等。其中,竞争是在分配和释放操作中导致速度减慢的问题。基于上述原因,建议不要在程序中过于频繁的使用堆。文中所述代码均在Windows 2000 Professional下由Microsoft Visual C++ 6.0编译通过。