对内存进行操作的第三个机制是使用堆栈。堆栈可以用来分配许多较小的数据块。例如,若要对链接表和链接树进行管理,最好的方法是使用堆栈,而不是虚拟内存操作方法或内存映射文件操作方法。堆栈的优点是,可以不考虑分配粒度和页面边界之类的问题,集中精力处理手头的任务。堆栈的缺点是,分配和释放内存块的速度比其他机制要慢,并且无法直接控制物理存储器的提交和回收。
从内部来讲,堆栈是保留的地址空间的一个区域。开始时,保留区域中的大多数页面没有被提交物理存储器。当从堆栈中进行越来越多的内存分配时,堆栈管理器将把更多的物理存储器提交给堆栈。物理存储器总是从系统的页文件中分配的,当释放堆栈中的内存块时,堆栈管理器将收回这些物理存储器。
1.进程的默认堆栈
当进程初始化时,系统在进程的地址空间中创建一个堆栈。该堆栈称为进程的默认堆栈。按照默认设置,该堆栈的地址空间区域的大小是1 MB。但是,系统可以扩大进程的默认堆栈,使它大于其默认值。当创建应用程序时,可以使用/ H E A P链接开关,改变堆栈的1 M B默认区域大小。由于D L L没有与其相关的堆栈,所以当链接D L L时,不应该使用/ H E A P链接开关。/ H E A P链接开关的句法如下:
/HEAP:reserve[,commit]
由于进程的默认堆栈可供许多Wi n d o w s函数使用,你的应用程序有许多线程同时调用各种Wi n d o w s函数,因此对默认堆栈的访问是顺序进行的。换句话说,系统必须保证在规定的时间内,每次只有一个线程能够分配和释放默认堆栈中的内存块。如果两个线程试图同时分配默认堆栈中的内存块,那么只有一个线程能够分配内存块,另一个线程必须等待第一个线程的内存块分配之后,才能分配它的内存块。一旦第一个线程的内存块分配完,堆栈函数将允许第二个线程分配内存块。这种顺序访问方法对速度有一定的影响。如果你的应用程序只有一个线程,并且你想要以最快的速度访问堆栈,那么应该创建你自己的独立的堆栈,不要使用进程的默认堆栈。不幸的是,你无法告诉Wi n d o w s函数不要使用默认堆栈,因此,它们对堆栈的访问总是顺序进行的。
单个进程可以同时拥有若干个堆栈。这些堆栈可以在进程的寿命期中创建和撤消。但是,默认堆栈是在进程开始执行之前创建的,并且在进程终止运行时自动被撤消。不能撤消进程的默认堆栈。每个堆栈均用它自己的堆栈句柄来标识,用于分配和释放堆栈中的内存块的所有堆栈函数都需要这个堆栈句柄作为其参数。
可以通过调用G e t P r o c e s s H e a p函数获取你的进程默认堆栈的句柄:
HANDLE GetProcessHeap();
2.什么要创建辅助堆栈
除了进程的默认堆栈外,可以在进程的地址空间中创建一些辅助堆栈。由于下列原因,你可能想要在自己的应用程序中创建一些辅助堆栈:
• 保护组件。
• 更加有效地进行内存管理。
• 进行本地访问。
• 减少线程同步的开销。
• 迅速释放。
下面让我们来详细说明每个原因。
2.1保护组件
假如你的应用程序需要保护两个组件,一个是节点结构的链接表,一个是B R A N C H结构的二进制树。你有两个源代码文件,一个是L n k L s t . c p p,它包含负责处理N O D E链接表的各个函数,另一个文件是B i n Tr e e . c p p,它包含负责处理分支的二进制树的各个函数。
如果节点和分支一道存储在单个堆栈中,那么这个组合堆栈将类似图1 8 - 1所示的样子。
图18-1 将节点和分支存放在一起的单个堆栈
现在假设链接表代码中有一个错误,它使节点1后面的8个字节不小心被改写了,从而导致分支3中的数据被破坏。当B i n Tr e e . c p p文件中的代码后来试图遍历二进制树时,它将无法进行这项操作,因为它的内存已经被破坏。当然,这使你认为二进制树代码中存在一个错误,而实际上错误是在链接表代码中。由于不同类型的对象混合放在单个堆栈中,因此跟踪和确定错误将变得非常困难。
通过创建两个独立的堆栈,一个堆栈用于存放节点,另一个堆栈用于存放分支,就能够确定你的问题。你的链接表代码中的一个小错误不会破坏你的二进制树的完整性。反过来,二进制树中的小错误也不会影响链接表代码中的数据完整性。但是,你的代码中的错误仍然可能导致对堆栈进行杂乱的内存写操作,不过出现这种情况的可能性很小。
2.2更有效的内存管理
通过在堆栈中分配同样大小的对象,就可以更加有效地管理堆栈。例如,假设每个节点结构需要2 4字节,每个分支结构需要3 2字节。所有这些对象均从单个堆栈中分配。图1 8 - 2显示了单个堆栈中已经分配的若干个节点和分支对象占满了这个堆栈。如果节点2和节点4被释放,堆栈中的内存将变成许多碎片。这时,如果试图分配分支结构,那么尽管分支只需要3 2个字节,而实际上可以使用的有4 8个字节,但是分配仍将失败。
图18-2 变成碎片的单个堆栈包含若干个节点和分支对象
如果每个堆栈只包含大小相同的对象,那么释放一个对象后,另一个对象就可以恰好放入被释放的对象空间中。
2.3进行本地访问
每当系统必须在R A M与系统的页文件之间进行R A M页面的交换时,系统的运行性能就会受到很大的影响。如果经常访问局限于一个小范围地址的内存,那么系统就不太可能需要在R A M与磁盘之间进行页面的交换。
所以,在设计应用程序的时候,如果有些数据将被同时访问,那么最好把它们分配在互相靠近的位置上。让我们回到链接表和二进制树的例子上来,遍历链接表与遍历二进制树之间并无什么关系。如果将所有的节点放在一起(放在一个堆栈中),就可以使这些节点位于相邻的页面上。实际上,若干个节点很可能恰好放入单个物理内存页面上。遍历链接表将不需要C P U为了访问每个节点而引用若干不同的内存页面。
如果将节点和分支分配在单个页面上,那么节点就不一定会互相靠在一起。在最坏的情况下,每个内存页面上可能只有一个节点,而其余的每个页面则由分支占用。在这种情况下,遍历链接表将可能导致每个节点的页面出错,从而使进程运行得极慢。
2.4 减少线程同步的开销
正如下面就要介绍的那样,按照默认设置,堆栈是顺序运行的,这样,如果多个线程试图同时访问堆栈,就不会使数据受到破坏。但是,堆栈函数必须执行额外的代码,以保证堆栈对线程的安全性。如果要进行大量的堆栈分配操作,那么执行这些额外的代码会增加很大的负担,从而降低你的应用程序的运行性能。当你创建一个新堆栈时,可以告诉系统,只有一个线程将访问该堆栈,因此额外的代码将不执行。但是要注意,现在你要负责保证堆栈对线程的安全性。系统将不对此负责。
2.5迅速释放堆栈
最后要说明的是,将专用堆栈用于某些数据结构后,就可以释放整个堆栈,而不必显式释放堆栈中的每个内存块。例如,当Windows Explorer遍历硬盘驱动器的目录层次结构时,它必须在内存中建立一个树状结构。如果你告诉Windows Explorer刷新它的显示器,它只需要撤消包含这个树状结构的堆栈并且重新运行即可(当然,假定它将专用堆栈用于存放目录树信息)。对于许多应用程序来说,这是非常方便的,并且它们也能更快地运行。
3.如何创建辅助堆栈
你可以在进程中创建辅助堆栈,方法是让线程调用H e a p C r e a t e函数:
HANDLE HeapCreate(
DWORD fdwOptions,
SIZE_T dwInitialSize,
SIZE_T dwMaximumSize);
第一个参数f d w O p t i o n s用于修改如何在堆栈上执行各种操作。你可以设定0、H E A P _ N O _S E R I A L I Z E、H E A P _ G E N E R AT E _ E X C E P T I O N S或者是这两个标志的组合。
按照默认设置,堆栈将顺序访问它自己,这样,多个线程就能够分配和释放堆栈中的内存块而不至于破坏堆栈。当试图从堆栈分配一个内存块时, H e a p A l l o c函数(下面将要介绍)必须执行下列操作:
1) 遍历分配的和释放的内存块的链接表。
2) 寻找一个空闲内存块的地址。
3) 通过将空闲内存块标记为“已分配”分配新内存块。
4) 将新内存块添加给内存块链接表。
下面这个例子说明为什么应该避免使用H E A P _ N O _ S E R I A L I Z E标志。假定有两个线程试图同时从同一个堆栈中分配内存块。线程1执行上面的第一步和第二步,获得了空闲内存块的地址。但是,在该线程可以执行第三步之前,它的运行被线程2抢占,线程2得到一个机会来执行上面的第一步和第二步。由于线程1尚未执行第三步,因此线程2发现了同一个空闲内存块的地址。
由于这两个线程都发现了堆栈中它们认为是空闲的内存块,因此线程1更新了链接表,给新内存块做上了“已分配”的标记。然后线程2也更新了链接表,给同一个内存块做上了“已分配”标记。到现在为止,两个线程都没有发现问题,但是两个线程得到的是完全相同的内存块的地址。
这种类型的错误是很难跟踪的,因为它不会立即表现出来。相反,这个错误会在后台等待着,直到很不适合的时候才显示出来。可能出现的问题是:
• 内存块的链接表已经被破坏。在试图分配或释放内存块之前,这个问题不会被发现。
• 两个线程共享同一个内存块。线程1和线程2会将信息写入同一个内存块。当线程1查看该内存块的内容时,它将无法识别线程2提供的数据。
• 一个线程可能继续使用该内存块并且将它释放,导致另一个线程改写未分配的内存。这将破坏该堆栈。
解决这个问题的办法是让单个线程独占对堆栈和它的链接表的访问权,直到该线程执行了对堆栈的全部必要的操作。如果不使用H E A P _ N O _ S E R I A L I Z E标志,就能够达到这个目的。只有当你的进程具备下面的一个或多个条件时,才能安全地使用H E A P _ N O _ S E R I A L I Z E标志:
• 你的进程只使用一个线程。
• 你的进程使用多个线程,但是只有单个线程访问该堆栈。
• 你的进程使用多个线程,但是它设法使用其他形式的互斥机制,如关键代码段、互斥对象和信标(第8、9章中介绍),以便设法自己访问堆栈。
如果对是否可以使用H E A P _ N O _ S E R I A L I Z E标志没有把握,那么请不要使用它。如果不使用该标志,每当调用堆栈函数时,线程的运行速度会受到一定的影响,但是不会破坏你的堆栈及其数据。
另一个标志H E A P _ G E N E R AT E _ E X C E P T I O N S,会在分配或重新分配堆栈中的内存块的尝试失败时,导致系统引发一个异常条件。所谓异常条件,只不过是系统使用的另一种方法,以便将已经出现错误的情况通知你的应用程序。有时在设计应用程序时让它查看异常条件比查看返回值要更加容易些。异常条件将在第2 3、2 4和2 5章中介绍。
H e a p C r e a t e的第二个参数d w I n i t i a l S i z e用于指明最初提交给堆栈的字节数。如果必要的话,H e a p C r e a t e函数会将这个值圆整为C P U页面大小的倍数。最后一个参数d w M a x i m u m S i z e用于指明堆栈能够扩展到的最大值(即系统能够为堆栈保留的地址空间的最大数量)。如果d w M a x i m u m S i z e大于0,那么你创建的堆栈将具有最大值。如果尝试分配的内存块会导致堆栈超过其最大值,那么这种尝试就会失败。
如果d w M a x i m u m S i z e的值是0,那么可以创建一个能够扩展的堆栈,它没有内在的限制。从堆栈中分配内存块只需要使堆栈不断扩展,直到物理存储器用完为止。如果堆栈创建成功,H e a p C r e a t e函数返回一个句柄以标识新堆栈。该句柄可以被其他堆栈函数使用。
3.1从堆栈中分配内存块
若要从堆栈中分配内存块,只需要调用H e a p A l l o c函数:
PVOID HeapAlloc(
HANDLE hHeap,
DWORD fdwFlags,
SIZE_T dwBytes);
第一个参数h H e a p用于标识分配的内存块来自的堆栈的句柄。d w B y t e s参数用于设定从堆栈中分配的内存块的字节数。参数f d w F l a g s用于设定影响分配的各个标志。目前支持的标志只有3个,即H E A P _ Z E R O _ M E M O RY、H E A P _ G E N E R AT E _ E X C E P T I O N S和H E A P _ N O _S E R I A L I Z E。
H E A P _ Z E R O _ M E M O RY标志的作用应该是非常清楚的。该标志使得H e a p A l l o c在返回前用0来填写内存块的内容。第二个标志H E A P _ G E N E R AT E _ E X C E P T I O N S用于在堆栈中没有足够的内存来满足需求时使H e a p A l l o c函数引发一个软件异常条件。当用H e a p C r e a t e函数创建堆栈时,可以设定H E A P _ G E N E R AT E _ E X C E P T I O N S标志,它告诉堆栈,当不能分配内存块时,就应该引发一个异常条件。如果在调用H e a p C r e a t e函数时设定了这个标志,那么当调用H e a p A l l o c函数时,就不需要设定该标志。另外,你可能想要不使用该标志来创建堆栈。在这种情况下,为H e a p A l l o c函数设定该标志只会影响对H e a p A l l o c函数的一次调用,并不是每次调用都会受到影响。
如果H e a p A l l o c运行失败,引发一个异常条件,那么这个异常条件将是表1 8 - 1中的两个异常条件之一。
表18-1 异常条件
标志 | 含义 |
S TAT U S _ N O _ M E M O RY | 由于内存不够,分配内存块的尝试失败 |
S TAT U S _ A C C E S S _ V I O L AT I O N | 由于堆栈被破坏,或者函数的参数不正确,分配内存块的尝试失败 |
如果内存块已经成功地分配, H e a p A l l o c返回内存块的地址。如果内存不能分配并且没有设定H E A P _ G E N E R AT E _ E X C E P T I O N S标志,那么H e a p A l l o c函数返回N U L L。
最后一个标志H E A P _ N O _ S E R I A L I Z E可以用来强制对H e a p A l l o c函数的调用与访问同一个堆栈的其他线程不按照顺序进行。在使用这个标志时应该格外小心,因为如果其他线程在同一时间使用该堆栈,那么堆栈就会被破坏。当从你的进程的默认堆栈中分配内存块时,决不要使用这个标志,因为数据可能被破坏,你的进程中的其他线程可能在同一时间访问默认堆栈。
Windows 98 如果调用H e a p A l l o c函数并且要求分配大于256 MB的内存块,Wi n d o w s 98 就将它看成是一个错误,函数的调用将失败。注意,在这种情况下,该函数总是返回N U L L,并且不会引发异常条件,即使你在创建堆栈或者试图分配内存块时使用H E A P _ G E N E R ATE_ EXCEPTIONS标志,也不会引发异常条件。
注意当你分配较大的内存块(大约1 MB或者更大)时,最好使用Vi r t u a l A l l o c函数,应该避免使用堆栈函数。
3.2改变内存块的大小
常常需要改变内存块的大小。有些应用程序开始时分配的内存块比较大,然后,当所有数据放入内存块后,再缩小内存块的大小。有些应用程序开始时分配的内存块比较小,后来需要将更多的数据拷贝到内存块中去时,再设法扩大它的大小。如果要改变内存块的大小,可以调用H e a p R e A l l o c函数:
PVOID HeapReAlloc(
HANDLE hHeap,
DWORD fdwFlags,
PVOID pvMem,
SIZE_T dwBytes);
与其他情况一样, h H e a p参数用于指明包含你要改变其大小的内存块的堆栈。f d w F l a g s参数用于设定改变内存块大小时H e a p R e A l l o c函数应该使用的标志。可以使用的标志只有下面4个,即H E A P _ G E N E R AT E _ E X C E P T I O N S、H E A P _ N O _ S E R I A L I Z E、H E A P _ Z E R O _ M E M O RY和H E A P _ R E A L L O C _ I N _ P L A C E _ O N LY。
前面两个标志在用于H e a p A l l o c时,其作用相同。H E A P _ Z E R O _ M E M O RY标志只有在你扩大内存块时才使用。在这种情况下,内存块中增加的字节将被置0。如果内存块已经被缩小,那么该标志不起作用。
H E A P _ R E A L L O C _ I N _ P L A C E _ O N LY标志告诉H e a p R e A l l o c函数,它不能移动堆栈中的内存块。如果内存块在增大, H e a p R e A l l o c函数可能试图移动内存块。如果H e a p R e A l l o c能够扩大内存块而不移动它,那么它将会这样做并且返回内存块的原始地址。另外,如果H e a p R e A l l o c必须移动内存块的内容,则返回新的较大内存块的地址。如果内存块被缩小, H e a p R e A l l o c将返回内存块的原始地址。如果内存块是链接表或二进制树的组成部分,那么可以设定H E A P _ R E A L L O C _ I N _ P L A C E _ O N LY标志。在这种情况下,链接表或二进制树中的其他节点可能拥有该节点的指针,改变堆栈中的节点位置会破坏链接表的完整性。
其余的两个参数p v M e m和d w B y t e s用于设定你要改变其大小的内存块的地址和内存块的新的大小(以字节为计量单位)。H e a p R e A l l o c既可以返回新的改变了大小的内存块的地址,也可以在内存块不能改变大小时返回N U L L。
3.3了解内存块的大小
当内存块分配后,可以调用H e a p S i z e函数来检索内存块的实际大小:
SIZE_T HeapSize(
HANDLE hHeap,
DWORD fdwFlags,
LPCVOID pvMem);
3.4释放内存块
当不再需要内存块时,可以调用H e a p F r e e函数将它释放:
BOOL HeapFree(
HANDLE hHeap,
DWORD fdwFlags,
PVOID pvMem);
3.5撤消堆栈
如果应用程序不再需要它创建的堆栈,可以通过调用H e a p D e s t r o y函数将它撤消:
BOOL HeapDestroy(HANDLE hHeap);
调用H e a p D e s t r o y函数可以释放堆栈中包含的所有内存块,也可以将堆栈占用的物理存储器和保留的地址空间区域重新返回给系统。如果该函数运行成功, H e a p D e s t r o y返回T R U E。如果在进程终止运行之前没有显式撤消堆栈,那么系统将为你将它撤消。但是,只有当进程终止运行时,堆栈才能被撤消。如果线程创建了一个堆栈,当线程终止运行时,该堆栈将不会被撤消。
在进程完全终止运行之前,系统不允许进程的默认堆栈被撤消。如果将进程的默认堆栈的句柄传递给H e a p D e s t r o y函数,系统将忽略对该函数的调用。
4.其他堆栈函数
除了上面介绍的堆栈函数外,Wi n d o w s还提供了若干个别的函数。下面对它们作一个简单的介绍。
To o l H e l p的各个函数(第4章后面部分讲过)可以用来枚举进程的各个堆栈和这些堆栈中分配的内存块。关于这些函数的详细说明,请参见Platform SDK文档中的下列函数:H e a p 3 2 F i r s t、H e a p 3 2 N e x t、H e a p 3 2 L i s t F i r s t和H e a p 3 2 L i s t N e x t。To o l H e l p函数的优点在于,在Windows 98和Windows 2000中都能够使用它们。