1 引言html
在大多数Windows应用程序设计中,都几乎不可避免的要对内存进行操做和管理。在进行大尺寸内存的动态分配时尤为显的重要。本文即主要对内存管理中的堆管理技术进行论述。windows
堆(Heap)实际是位于保留的虚拟地址空间中的一个区域。刚开始时,保留区域中的多数页面并无被提交物理存储器。随着从堆中愈来愈多的进行内存分配,堆管理器将逐渐把更多的物理存储器提交给堆。堆的物理存储器从系统页文件中分配,在释放时有专门的堆管理器负责对已占用物理存储器的回收。堆管理也是Windows提供的一种内存管理机制。主要用来分配小的数据块。与Windows的其余两种内存管理机制(1)虚拟内存和(2)内存映射文件相比,堆能够没必要考虑诸如系统的分配粒度和页面边界之类比较烦琐而又容易忽视的问题,可将注意力集中于对程序功能代码的设计上。可是使用堆去分配、释放内存的速度要比其余两种机制慢的多,并且不具有直接控制物理存储器提交与回收的能力。数组
在进程刚启动时,系统便在刚建立的进程虚拟地址空间中建立了一个堆,该堆即为进程的默认堆,缺省大小为1MB,该值容许在连接程序时被更改(即,在VS编译程序的时候,在项目设置-链接器设置-中能够设置)。进程的默认堆是比较重要的,可供众多Windows函数使用。在使用时,系统必须保证在规定的时间内,每此只有一个线程可以分配和释放默认堆中的内存块。虽然这种限制将会对访问速度产生必定的影响,但却能够保证进程中的多个线程在同时调用各类Windows函数时对默认堆的顺序访问。安全
在进程中容许使用多个堆,进程中包括默认堆在内的每一个堆都有一个堆句柄来标识。与本身建立的堆不一样,进程默认堆的建立、销毁均由系统来完成,并且其生命期早在进程开始执行以前就已经开始,虽然在程序中能够经过GetProcessHeap()函数获得进程的默认堆句柄,但却不容许调用HeapDestroy()函数显式将其撤消。数据结构
2. 对动态建立堆的需求多线程
前面曾提到,在进程中除了进程默认堆外,还能够在进程虚拟地址空间中动态建立一些独立的堆。至于在程序设计时究竟需不须要动态建立独立的堆能够从是否有保护组件的须要、是否能更加有效地对内存进行管理、是否有进行本地访问的须要、是否有减小线程同步开销的须要以及是否有迅速释放堆的须要等几个方面去考虑。
(1)对因而否有保护组件的须要这一原则比较容易理解。在图1中,左边的图表示了一个链表(节点结构)组件和一个树(分支结构)组件共同使用一个堆的状况。在这种状况下,因为两组件数据在堆中的混合存放,若是节点3(属于链表组件)的后几个字节因为被错误改写,将有可能影响到位于其后的分支2(属于树组件)。这将导致树组件的相关代码在遍历其树时因为内存被破坏而没法进行。究其缘由,树组件的内存是因为链表组建对其自身的错误操做而引发的。若是采用右图所示方式,将树组件和链表组件分别存放于一个独立的堆中,上述状况显然不会发生,错误将被局限于进行了错误操做的链表组件,而树组件因为存放在独立的堆中而受到了保护。函数
【图1】性能
在上图中,若是链表组件的每一个节点占用12个字节,每一个树组件的分支占用16个字节若是这些长度不一的对象共用一个堆(左图),在左图中这些已经分配了内存的对象已占满了堆,若是其中有节点2和节点4释放,将会产生24个字节的碎片,若是试图在24个字节的空闲区间内分配一个16字节的分支对象,尽管要分配的字节数小于空闲字节数,但分配仍将失败。只有在堆栈中分配大小相同的对象才能够实行更加有效的内存管理。若是将树组件换成其余长度为12字节的组件,那么在释放一个对象后,另外一个对象就能够刚好填充到此刚释放的对象空间中。
(2)进行本地访问的须要也是一条比较重要的原则。系统会常常在内存与系统页文件之间进行页面交换,但若是交换次数过多,系统的运行性能就将受很大的影响。所以在程序设计时应尽可能避免系统频繁交换页面,若是将那些会被同时访问到的数据分配在相互靠近的位置上,将会减小系统在内存和页文件之间的页面交换频率。
(3)线程同步开销指的是默认条件下以顺序方式运行的堆为保护数据在多个线程试图同时访问时不受破坏而必须执行额外代码所花费的开销。这种开销保证了堆对线程的安全性,所以是有必要的,但对于大量的堆分配操做,这种额外的开销将成为一个负担,并下降程序的运行性能。为避免这种额外的开销,能够在建立新堆时通知系统只有单个线程对访问。此时堆对线程的安全性将有应用程序来负责。
(4)最后若是有迅速释放堆的须要,可将专用堆用于某些数据结构,并以整个堆去释放,而再也不显式地释放在堆中分配的每个内存块。对于大多数应用程序,这样的处理将能以更快的速度运行。spa
3. 建立堆线程
在进程中,若是须要能够在原有默认堆的基础上动态建立一个堆,可由HeapCreate()函数完成:
HANDLE HeapCreate(
DWORD flOptions,
DWORD dwInitialSize,
DWORD dwMaximumSize
);
其第一个参数flOptions指定了对新建堆的操做属性。该标志将会影响一些堆函数如HeapAlloc()、HeapFree()、HeapReAlloc()和HeapSize()等对新建堆的访问。其可能的取值为下列标志及其组合:
参数dwInitialSize和dwMaximumSize分别为堆的初始大小和堆栈的最大尺寸。其中,dwInitialSize的值决定了最初提交给堆的字节数。若是设置的数值不是页面大小的整数倍,则将被圆整(Round Up)到邻近的页边界处。而dwMaximumSize则其实是系统能为堆保留的地址空间区域的最大字节数。如果该值为0,那么将建立一个可扩展的堆,堆的大小仅受可用内存的限制。若是应用程序须要分配大的内存块,一般要将该参数设置为0。若是dwMaximumSize大于0,则该值限定了堆所能建立的最大值,HeapCreate()一样也要将该值圆整到邻近的页边界,而后再在进程的虚拟地址空间为堆保留该大小的一块区域。在这种堆中分配的内存块大小不能超过0x7FFF8字节,任何试图分配更大内存块的行为将会失败,即便是设置的堆大小足以容纳该内存块。
若是HeapCreate()成功执行,将会返回一个标识新堆的句柄,并可供其余堆函数使用。
须要特别说明的是,在设置第一个参数时,对HEAP_NO_SERIALIZE的标志的使用要谨慎,通常应避免使用该标志。这是同后续将要进行的堆函数HeapAlloc()的执行过程有关系的,在HeapAlloc()试图从堆中分配一个内存块时,将执行下述几步操做:
1) 遍历分配的和释放的内存块的连接表
2) 搜寻一个空闲内存块的地址
3) 经过将空闲内存块标记为"已分配"来分配新内存块
4) 将新分配的内存块添加到内存块列表
如果这时有两个线程一、2试图同时从一个堆中分配内存块,那么线程1在执行了上面的1和2步后将获得空间内存块的地址。但是因为CPU对线程运行时间的分片,使得线程1在执行第3步操做前有可能被线程2抢走执行权并有机会去执行一样的一、2步操做,并且因为先执行的线程1并无执行到第3步,所以线程2会搜寻到同一个空闲内存块的地址,并将其标记为已分配。而线程1在恢复运行后并不能知晓该内存块已被线程2标记过,所以会出现两个线程军认为其分配的是空闲的内存块,并更新各自的联接表。显然,象这种两个线程拥有彻底相同内存块地址的错误是很是严重而又是难以发现的。
因为只有在多个线程同时进行操做时才有可能出现上述问题,一种简单的解决的办法就是不使用HEAP_NO_SERIALIZE标志而只容许单个线程独占地对堆及其联接表拥有访问权。若是必定要使用此标志,为了安全起见,必须确保进程为单线程的或是在进程中使用了多线程,但只有单个线程对堆进行访问。再就是使用了多线程,也有多个线程对堆进行了访问,但这些线程经过使用某种线程同步手段。若是能够确保以上几条中的一条成立,也是能够安全使用HEAP_NO_SERIALIZE标志的,并且还将拥有快的访问速度。若是不能确定上述条件是否知足,建议不使用此标志而以顺序的方式访问堆,虽然线程速度会所以而降低但却能够确保堆及其中数据的不被破坏。
4. 从堆中分配内存块
在成功建立一个堆后,能够调用HeapAlloc()函数从堆中分配内存块。
在此,该函数能够从两个种堆中分配内存块。(1)从用HeapCreate()建立的动态堆中分配内存块,(2)也能够直接从进程的默认堆中分配内存块。
下面先给出HeapCreate()的函数原型
LPVOID HeapAlloc(
HANDLE hHeap,
DWORD dwFlags,
DWORD dwBytes
);
其中,参数hHeap为要分配的内存块来自的堆的句柄(从分配的哪一个堆中进行份内存块),能够是从HeapCreate()建立的动态堆句柄也能够是由GetProcessHeap()获得的默认堆句柄。
参数dwFlags指定了影响堆分配的各个标志。该标志将覆盖在调用HeapCreate()时所指定的相应标志,可能的取值为:
最后一个参数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编译经过。
参考: