来自Windows核心编程 – 18章
使用堆是一种对内存进行操控的方法,另外两种是虚拟内存 和 内存映射文件。
堆非常适合用来分配小型数据。也是用来管理链表和树的最佳方式。
堆的缺点是 分配 和 释放 内存块的速度比其他方式慢。
无法再对物理存储器的调拨和(撤销调拨)释放进行控制。
在系统内部,堆就是一块预定的地址空间区域。
刚开始,区域内的大部分页面没有调拨物理存储器,随着我们不断从堆中分配内存,堆的管理器会给堆调拨越来越多的物理存储器。这些物理存储器始终是从页交换文件中分配的。释放堆中的内存块时,堆管理器你会撤销(释放)已调拨的物理存储器。
18.1 进程的默认堆
进程初始化的时候,系统会在进程的地址空间中创建一个堆。这个堆被称为进程的默认堆。默认情况下,这个堆的大小是 1MB。但是系统可以增大进程的默认堆。
系统保证不管在什么时候,只运行一个线程从堆中分配或释放块。
我们可以通过调用GetProcessHeap() 函数来得到进程的默认堆的句柄。
18.2 为什么要创建额外的堆
除了使用进程的默认堆之外,我们还可以创建额外的堆。
创建额外的堆有什么好处呢:
- 对组件进行保护
- 更有效的内存管理
- 局部访问
- 避免线程同步的开销
- 快速释放
一个个来看吧:
对组建的保护:
简单的来讲就是内存越界,在文件1中数据结构1有缺陷,内存越界修改了文件2中某个数据结构的数据,导致了错误,而我们认为这个错误来自于文件2…完蛋,这个BUG就很难找了…
更有效的内存管理:
例子:数据结构1-占24字节,数据结构2-占32字节。
然后我们数据结构1 和 2交替填充了整个堆,释放了所有的数据结构1…造成了大量内存碎片,而且,此时除非扩充堆,我们无法为数据结构2分配哪怕仅仅一个空间!
使内存访问局部化:
当系统把一个内存页面换出到页交换文件,或者把页交换文件中的一个页面换入到内存中的时候,会对性能产生非常大的影响。
比较好的做法是将需要同时访问的对象分配在相邻的内存地址。
否则最坏的情况是访问每个元素(比如链表)都会引起页面错误,导致整个过程及其缓慢。
避免线程的同步开销:
默认情况下,对堆的访问是依次进行的,此时堆函数会执行额外的代码来确保堆的线程安全性(thread-safe)。但是如果创建一个新的堆,就可以告诉系统只有一个线程会访问堆,然后由我们程序员手动控制来保证堆的线程安全性。
快速释放:
如果我们仅将一个树或者链表存储到一个堆里面,释放树或者链表的时候,我们不必显式地释放堆中每个内存块,可以直接释放整个堆,是不是很快…
18.3 如何创建额外的堆
调用 HeapCreate() 函数可以创建额外的堆。(属于进程)
1 2 3 4 |
HANDLE HeapCreate( DWORD fdwOptions, SIZE_T dwInitialSize, SIZE_T dwMaximumSize); |
三个参数:
- fdwOptions 用来表示对堆的操作该如何进行。可以指定 0、HEAP_NO_SERIALIZE、HEAP_GENERATE_EXCEPTIONS、HEAP_CREATE_ENABLE_EXECUTE 或这些标志的组合。
- 第二个参数 dwInitialSize 表示一开始要调拨给堆的字节数。如果需要,会把这个值向上取整到CPU页面大小的整数倍。
- 最后一个参数 dwMaximumSize 表示堆所能增长的最大大小(即系统为堆所预定的地址空间的大小)。如果大于0,则表示有一个最大大小,分配超过这个值,则可能失败。如果等于0,那么创建的堆是可增长的,没有上线,直到用尽物理存储器为止。
创建成功后会返回一个句柄,标识创建的堆,其他堆函数会用到这个句柄。
接下来我们详细讨论下第一个参数的值。
HEAP_NO_SERIALIZE : 尽量不要用这个值,除非你能自己管理访问次序。
HEAP_GENERATE_EXCEPTIONS:这个标志告诉系统,每当在堆内分配或重新分配内存失败的时候,抛出一个异常。异常只不过是系统使用的另一种方法,来告诉应用程序有错误发生。
HEAP_CREATE_ENABLE_EXECUTE:如果想在堆中存放可执行代码,就需要设置这个标志。如果不设置这个标志的话,当我们试图在 这个堆的内存块中 执行代码时,系统会抛出 EXCEPTION_ACCESS_VIOLATION 异常。
18.3.1 从堆中分配内存块
从堆中分配一块内存不过是调用下 HeapAlloc() 函数
1 2 3 4 |
PVOID HeapAlloc( HANDLE hHeap, DWORD fdwFlags, SIZE_T dwBytes); |
三个参数:
- hHeap 是一个堆的句柄。
- fdwFlags 用来指定标识,可以指定的有: HEAP_ZERO_MEMORY、HEAP_GENERATE_EXCEPTIONS 和 HEAP_NO_SERIALIZE。
- 第三个参数用来指定分配的大小。单位为字节。
同上我们来讨论下第二个参数的三个标识:
HEAP_ZERO_MEMORY :让 HeapAlloc 在返回之前把内存块的内容都清零。
HEAP_GENERATE_EXCEPTIONS :告诉系统,如果分配失败,就抛出异常。
在创建的时候也可以使用这个标识,如果当时指定了,这次就不需要了。
其实最好是创建堆的时候不指定,这样我们可以控制需要的时候抛出。
如果抛出异常应该是以下之一:
如果分配成功,返回地址。
如果分配失败,没有指定异常标志,返回NULL
HEAP_NO_SERIALIZE:强制系统不要把这次HeapAlloc调用与其他线程对同一个堆的访问一次排列起来。在使用这个标识的时候应该极其小心,因为如果其他线程也在分配堆,可能会破坏堆。
在从进程默认堆分配内存的时候,绝对不要使用这个标志。
PS.在分配1MB以上内存的时候,建议使用VirtualAlloc函数(虚拟内存)。
18.3.2 调整内存块大小
我们有些时候可能会调整内存块大小,比如应用程序一开始会分配一块较大的内存,经过一段时间使用后,可能需要减少内存块的大小。或者一开始分配的比较小,然后需要更大的空间,都可以通过这个函数来完成。
1 2 3 4 5 |
PVOID HeapReAlloc( HANDLE hHeap, DWORD fdwFlags, PVOID pvMem, SIZE_T dwBytes); |
四个参数:
- hHeap 标识堆
- fdwFlag 这次有四个标识:HEAP_ZERO_MEMORY、HEAP_GENERATE_EXCEPTIONS 和 HEAP_NO_SERIALIZE 和 HEAP_REALLOC_IN_PLACE_ONLY。
- pvMem 用来指定想要调整大小的内存块的当前地址。
- dwByte 用来指定新的大小。
接下来我们先讲标识符。
HEAP_NO_SERIALIZE 和 HEAP_GENERATE_EXCEPTIONS 意思和分配的时候一样。
HEAP_ZERO_MEMORY 标识新空间要不要清零
HEAP_REALLOC_IN_PLACE_ONLY :在增大内存块的时候,HeapReAlloc可能会在堆内部移动内存块,而这个标识符则表示不要移动内存块。如果能够在不移动内存块的前提下完成操作,则返回原地址。如果必须移动,则返回一个新地址,它指向一个更大的内存块。要注意的是如果要将内存块减小,那么会返回原地址。
要注意的是,如果一个内存块是链表或者树的一部分,则必须指定 HEAP_REALLOC_IN_PLACE_ONLY。因为在这种情况下,链表或树的其他节点可能指向当前节点的指针,把节点移动到堆中其他的地方会破坏其完整性。
最后:该函数要么返回一个地址,指向调整后的内存块,要么返回NULL(不能调整)
18.3.3 获得内存块的大小
1 2 3 4 |
SIZE_T HeapSize( HANDLE hHeap, DWORD fdwFlags, LPCVOID pvMem); |
标志位fdwFlags :可以是 0 或者 HEAP_NO_SERIALIZE。
pvMem 是内存块地址。
hHeap 是堆句柄
18.3.4 释放内存块
当我们不需要一块内存的时候,可以调用 HeapFree 释放它。
1 2 3 4 |
BOOL HeapFree( HANDLE hHeap, DWORD fdwFlags, PVOID pvMem); |
参数意义同上。
注意,调用这个函数可能会使堆管理器撤销一些已经调拨的物理存储器,但是这并不是一定的。
18.3.5 销毁堆
如果不需要之前创建的堆,则可以调用 HeapDestroy() 函数来销毁它。
1 |
BOOL HeapDestroy(HANDLE hHeap); |
调用该函数会释放堆中所有内存块,同时系统会回收堆占用的物理存储器和地址空间区域。
如果调用成功,返回TRUE。
如果不在进程终止之前调用,那么系统会替我们销毁的。
我们知道线程才是执行体,创建堆是在线程内执行的,但是线程终止的时候,堆是不会被销毁的。
在进程完全终止之前,系统是不会允许销毁进程的默认堆的。所以这时候如果试图销毁,会返回FALSE。
18.3.6 在C++中使用堆
运用堆的最好方法就是将它们集成到已有的C++程序中。在C++中,我们调用new操作符来分配对象(而不是malloc函数),然后在不需要某个对象的时候调用delete操作符释放它(而不是free函数)。
具体的不讲了,其实就是把 创建堆啊,分配内存 之类的堆函数 放到new 和 delelte操作符里面去,重载这两个操作符。当然我们需要 static 来保存堆的句柄,然后用一个static 的计数来表明有多少个对象生成了。每销毁一个则减一,当减到0的时候表示堆空了,可以销毁了。
要注意的情况是:继承! 如果父类和子类的对象大小相差很大,而且子类没有重载操作符,则可能会产生严重的碎片问题。这样我们创建堆的意义也就不存在了。
18.4 其他堆函数
由于进程在自己的地址空间中可以有多个堆,所以GetProcessHeaps 函数可以让我们得到这些堆的句柄:
1 2 3 |
DWORD GetProcessHeaps( DWORD dwNumHeaps, PHANDLE phHeaps); |
用法示例如下:
1 2 3 4 5 6 7 8 9 10 11 |
HANDLE hHeaps[25]; DWORD dwHeaps = GetProcessHeaps(25,hHeaps); if(dwHeaps > 25) { //居然还有更多的堆...这次没获取完 } else { //获取完了 //从 hHeaps[0] ... hHeaps[24] } |
要注意的是,返回的句柄包括进程的默认堆句柄。
下面这个函数可以用来验证堆的完整性:
1 2 3 4 |
BOOL HeapValidate( HANDLE hHeap, DWORD fdwFlags, LPCVOID pvMem); |
传入一个堆句柄,标志位传入0,然后给pvMem传递NULL即可。
如果想判断指定内存块是否被破坏,则给pvMem传递指定内存块地址。
下面的函数作用是将堆中闲置内存块重新结合在一起,并撤销调拨给堆中闲置内存块的物理存储器。
1 2 3 |
UINT HeapCompact( HANDLE hHeap, DWORD fdwFlags); |
一般来说会给 fdwFlags 传入参数0
下面两个函数用于线程同步:
1 2 |
BOOL HeapLock(HANDLE hHeap); BOOL HeapUnLock(HANDLE hHeap); |
这两个函数配对使用,当第一个线程调用 HeapLock 时,它就成为指定堆的所有者。假设这时候有一个线程2调用了线程1指定的堆的时候,就会暂停挂起,直到第一个线程调用了HeapUnLock 函数才会被唤醒。
为了确保对堆的访问是依次进行的,前面提到的 HeapAlloc、HeapSize、HeapFree 之类的函数都会在内部调用这两个函数。所以一般情况下是不需要我们调用的。
最后有一个 HeapWalk 函数,其作用是遍历堆的内容。
1 2 3 |
BOOL HeapWalk( HANDLE hHeap, PPROCESS_HEAP_ENTRY pHeapEntry); |
要求传入一个 PROCESS_HEAP_ENTRY 结构的地址
当返回FALSE时表示遍历完。
该结构类型定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
typedef struct __PROCESS_HEAP_ENTRY { PVOID lpData, DWORD cbData, BYTE cbOverhead, BYTE iRegionIndex, WORD wFlags; union { struct { HADNLE hMem; DWORD dwReserved[3]; } Block; struct { DWORD dwCommittedSize; DWORD dwUnCommittedSize; LPVOID lpFirstBlock; LPVOID lpLastBlock; } Region; }; } PROCESS_HEAP_ENTRY, *LPROCESS_HEAP_ENTRY, *PPROCESS_HEAP_ENTRY; |
当我们开始枚举的时候,要把 lpData 成员设置为NULL。
每次调用 HeapWalk 成功后,可以查看内部成员。
如果要得到堆中的下一块内存,必须再次调用 HeapWalk ,并传入和上一次调用相同的堆句柄和 上述的数据结构地址。
我们可以在 HeapWalk 循环外部调用 HeapLock 和 HeapUnlock,这样可以保证遍历的时候不会有其他线程从中分配与释放内存了。
大致就是这样…