堆栈基本知识
堆(heap)和栈(stack)是C/C++编程不可避免会碰到的两个基本概念。首先,这两个概念
都可以在讲数据结构的书中找到,他们都是基本的数据结构,虽然栈更为简单一些。
在具体的C/C++编程框架中,这两个概念并不是并行的。对底层机器代码的研究可以揭示,
栈是机器系统提供的数据结构,而堆则是
C/C++
函数库提供的。
具体地说,现代计算机(串行执行机制),都直接在代码底层支持栈的数据结构。这体现在,有专门的寄存器指向栈所在的地址,有专门的机器指令完成数据入栈出栈的操作。
这种机制的特点是效率高,支持的数据有限,一般是整数,指针,浮点数等系统直接支
持的数据类型,并不直接支持其他的数据结构。
因为栈的这种特点,对栈的使用在程序中是非常频繁的。
对子程序的调用就是直接利用栈完成的。机器的call指令里隐含了把返回地址推入栈,然后跳转至子程序地址的操作,而子程序中的ret指令则隐含从堆栈中弹出返回地址并跳转之的操作。C/C++中的自动变量是直接利用栈的例子,这也就是为什么当函数返回时,该函数的自动变量自动失效的原因。
和栈不同,堆的数据结构并不是由系统(无论是机器系统还是操作系统)支持的,而是由函数库提供的。基本的
malloc/realloc/free函数维护了一套内部的堆数据结构。当程序使用这些函数去获得新的内存空间时,这套函数首先试图从内部堆中寻找可用的内存空间,如果没有可以使用的内存空间,则试图利用系统调用来动态增加程序数据段的内存大小,新分配得到的空间首先被组织进内部堆中去,然后再以适当的形式返回给调用者。当程序释放分配的内存空间时,这片内存空间被返回内部堆结构中,可能会被适当的处理(比如和其他空闲空间合并成更大的空闲空间),以更适合下一次内存分配申请。这套复杂的
分配机制实际上相当于一个内存分配的缓冲池
(Cache),使用这套机制有如下若干原因:
1. 系统调用可能不支持任意大小的内存分配。有些系统的系统调用只支持固定大小及其倍数的内存请求(按页分配);这样的话对于大量的小内存分类来说会造成浪费。
2. 系统调用申请内存可能是代价昂贵的。系统调用可能涉及用户态和核心态的转换。
3. 没有管理的内存分配在大量复杂内存的分配释放操作下很容易造成内存碎片。
堆和栈的对比
从以上知识可知,
栈是系统提供的功能,特点是快速高效,缺点是有限制,数据不灵活;而堆是函数库提供的功能,特点是灵活方便,数据适应面广泛,但是效率有一定降低。栈是系统数据结构,对于进程
/
线程是唯一的;堆是函数库内部数据结构,不一定唯一。不同堆分配的内存无法互相操作。
栈空间分静态分配和动态分配两种。静态分配是编译器完成的,比如自动变量(auto)的分配。动态分配由malloc函数完成。栈的动态分配无需释放(是自动的),也就没有释放函数。
为可移植的程序起见,栈的动态分配操作是不被鼓励的!堆空间的分配总是动态的,虽然程序结束时所有的数据空间都会被释放回系统,但是精确的申请内存/释放内存匹配是良好程序的基本要素。
使用堆栈
1
堆实现的注意事项
传统上,操作系统和运行时库是与堆的实现共存的。在一个进程的开始,操作系统创建一个默认堆,叫做“进程堆”。如果没有其他堆可使用,则块的分配使用“进程堆”。
语言运行时也能在进程内创建单独的堆。(例如,c 运行时创建它自己的堆。)除这些专用的堆外,应用程序或许多已载入的动态链接库 (dll) 之一可以创建和使用单独的堆。win32提供一整套 api 来创建和使用私有堆。有关堆函数(英文)的详尽指导,请参见 msdn。
当应用程序或 dll 创建私有堆时,这些堆存在于进程空间,并且在进程内是可访问的。从给定堆分配的数据将在同一个堆上释放。(不能从一个堆分配而在另一个堆释放。)
在所有虚拟内存系统中,堆驻留在操作系统的“虚拟内存管理器”的顶部。语言运行时堆也驻留在虚拟内存顶部。某些情况下,这些堆是操作系统堆中的层,而语言运行时堆则通过大块的分配来执行自己的内存管理。不使用操作系统堆,而使用虚拟内存函数更利于堆的分配和块的使用。
典型的堆实现由前、后端分配程序组成。前端分配程序维持固定大小块的空闲列表。对于一次分配调用,堆尝试从前端列表找到一个自由块。如果失败,堆被迫从后端(保留和提交虚拟内存)分配一个大块来满足请求。通用的实现有每块分配的开销,这将耗费执行周期,也减少了可使用的存储空间。
knowledge base 文章 q10758,“用 calloc() 和 malloc() 管理内存” (搜索文章编号), 包含了有关这些主题的更多背景知识。另外,有关堆实现和设计的详细讨论也可在下列著作中找到:“dynamic storage allocation: asurvey and critical review”,作者 paul r. wilson、mark s. johnstone、michaelneely 和 david boles;“international workshop on memory management”, 作者 kinross, scotland, uk, 1995 年 9 月(http://www.cs.utexas.edu/users/oops/papers.html)(英文)。
windows nt 的实现(windows nt 版本 4.0 和更新版本) 使用了 127 个大小从 8 到 1,024 字节的 8 字节对齐块空闲列表和一个“大块”列表。“大块”列表(空闲列表[0]) 保存大于 1,024 字节的块。空闲列表容纳了用双向链表链接在一起的对象。
默认情况下,“进程堆”执行收集操作。(收集是将相邻空闲块合并成一个大块的操作。)收集耗费了额外的周期,但减少了堆块的内部碎片。
单一全局锁保护堆,防止多线程式的使用。(请参见“server performance and scalability killers”中的第一个注意事项, george reilly 所著,在 “msdn online web workshop”上(站点:http://msdn.microsoft.com/workshop/server/iis/tencom.asp(英文)。)单一全局锁本质上是用来保护堆数据结构,防止跨多线程的随机存取。若堆操作太频繁,单一全局锁会对性能有不利的影响。
2
什么是常见的堆性能问题?
以下是您使用堆时会遇到的最常见问题:
分配操作造成的速度减慢。
光分配就耗费很长时间。最可能导致运行速度减慢原因是空闲列表没有块,所以运行时分配程序代码会耗费周期寻找较大的空闲块,或从后端分配程序分配新块。
释放操作造成的速度减慢。
释放操作耗费较多周期,主要是启用了收集操作。收集期间,
每个释放操作“查找”它的相邻块,取出它们并构造成较大块,然后再把此较大块插入空闲列表。在查找期间,内存可能会随机碰到,从而导致高速缓存不能命中,性能降低。
堆竞争造成的速度减慢。当两个或多个线程同时访问数据,而且一个线程继续进行之前必须等待另一个线程完成时就发生竞争。竞争总是导致麻烦;这也是目前多处理器系统遇到的最大问题。当大量使用内存块的应用程序或 dll 以多线程方式运行(或运行于多处理器系统上)时将导致速度减慢。单一锁定的使用—常用的解决方案—意味着使用堆的所有操作是序列化的。当等待锁定时序列化会引起线程切换上下文。可以想象交叉路口闪烁的红灯处走走停停导致的速度减慢。
竞争通常会导致线程和进程的上下文切换。上下文切换的开销是很大的,但开销更大的是数据从处理器高速缓存中丢失,以及后来线程复活时的数据重建。
堆破坏造成的速度减慢。造成堆破坏的原因是应用程序对堆块的不正确使用。通常情形包括释放已释放的堆块或使用已释放的堆块,以及块的越界重写等明显问题。(破坏不在本文讨论范围之内。有关
内存重写和泄漏等其他细节,请参见 microsoft visual c++(r) 调试文档 。)
频繁的分配和重分配造成的速度减慢。这是使用脚本语言时非常普遍的现象。如字符串被反复分配,随重分配增长和释放。不要这样做,如果可能,尽量分配大字符串和使用缓冲区。
另一种方法就是尽量少用连接操作。
竞争是在分配和释放操作中导致速度减慢的问题。理想情况下,希望使用没有竞争和快速分配/释放的堆。可惜,现在还没有这样的通用堆,也许将来会有。
在所有的服务器系统中(如 IIS、msproxy、databasestacks、网络服务器、 exchange ?
其他), 堆锁定实在是个大瓶颈。处理器数越多,竞争就越会恶化。 尽量减少堆的使用
现在您明白使用堆时存在的问题了,难道您不想拥有能解决这些问题的超级魔棒吗?我可希望有。但没有魔法能使堆运行加快—因此不要期望在产品出货之前的最后一星期能够大为改观。如果提前规划堆策略,情况将会大大好转。
调整使用堆的方法,减少对堆的操作是提高性能的良方。
如何减少使用堆操作?通过利用数据结构内的位置可减少堆操作的次数。请考虑下列实例:
struct objecta {
// objecta 的数据
}
struct objectb {
// objectb 的数据
}
// 同时使用 objecta 和 objectb
//
// 使用指针
//
struct objectb {
struct objecta * pobja;
// objectb 的数据
}
//
// 使用嵌入
//
struct objectb {
struct objecta pobja;
// objectb 的数据
}
//
// 集合 – 在另一对象内使用 objecta 和 objectb
//
struct objectx {
struct objecta obja;
struct objectb objb;
}
避免使用指针关联两个数据结构。如果使用指针关联两个数据结构,前面实例中的对象 a 和 b 将被分别分配和释放。这会增加额外开销—我们要避免这种做法。
把带指针的子对象嵌入父对象。当对象中有指针时,则意味着对象中有动态元素(百分之八十)和没有引用的新位置。嵌入增加了位置从而减少了进一步分配/释放的需求。这将提高应用程序的性能。
合并小对象形成大对象(聚合)。聚合减少分配和释放的块的数量。如果有几个开发者,
各自开发设计的不同部分,则最终会有许多小对象需要合并。集成的挑战就是要找到正确的聚合边界。
内联缓冲区能够满足百分之八十的需要(aka 80-20 规则)。个别情况下,需要内存缓冲来保存字符串/二进制数据,但事先不知道总字节数。估计并内联一个大小能满足百分之八十需要的缓冲区。对剩余的百分之二十,可以分配一个新的缓冲区和指向这个缓冲区的指针。这样,就减少分配和释放调用并增加数据的位置空间,从根本上提高代码的性能。在块中
分配对象(块化)。块化是以组的方式一次分配多个对象的方法。如果对列表的项连续跟踪,例如对一个 {名称,值} 对的列表,有两种选择:选择一是为每一个“名称-值”对分配一个节点;选择二是分配一个能容纳(如五个)“名称-值”对的结构。例如,一般情况下,如果存储四对,就可减少节点的数量,如果需要额外的空间数量,则使用附加的链表指针。
块化是友好的处理器高速缓存,特别是对于 l1-高速缓存,因为它提供了增加的位置 —不用说对于块分配,很多数据块会在同一个虚拟页中。
正确使用 _amblksiz。c
运行时
(crt) 有它的自定义前端分配程序,该分配程序从后端(
win32 堆)分配大小为 _amblksiz 的块。将 _amblksiz 设置为较高的值能潜在地减少对后端的调用次数。这只对广泛使用 crt 的程序适用。
使用上述技术将获得的好处会因对象类型、大小及工作量而有所不同。但总能在性能和可升缩性方面有所收获。另一方面,代码会有点特殊,但如果经过深思熟虑,代码还是很容易管理的。
3 其他提高性能的技术
下面是一些提高速度的技术:
使用 windows nt5 堆
由于几个同事的努力和辛勤工作,1998 年初 microsoft windows(r) 2000 中有了几个重大改进:
改进了堆代码内的锁定。堆代码对每堆一个锁。全局锁保护堆数据结构,防止多线程式的使用。但不幸的是,在高通信量的情况下,堆仍受困于全局锁,导致
高竞争和低性能。windows 2000 中,锁内代码的临界区将竞争的可能性减到最小,从而提高了可伸缩性。
使用
“
lookaside
”列表。堆数据结构对块的所有空闲项使用了大小在 8 到 1,024 字节(以 8-字节递增)的快速高速缓存。快速高速缓存最初保护在全局锁内。现在,使用 lookaside 列表来访问这些快速高速缓存空闲列表。这些列表不要求锁定,而是使用 64 位的互锁操作,因此提高了性能。
内部数据结构算法也得到改进。
这些改进避免了对分配高速缓存的需求,但不排除其他的优化。使用 windows nt5 堆评估您的代码;它对小于 1,024 字节 (1 kb) 的块(来自前端分配程序的块)是最佳的。globalalloc() 和 localalloc() 建立在同一堆上,是存取每个进程堆的通用机制。如果希望获得高的局部性能,则使用 heap(r) api 来存取每个进程堆,或为分配操作创建自己的堆。如果需要对大块操作,也可以直接使用 virtualalloc() / virtualfree() 操作。
上述改进已在 windows 2000 beta 2 和 windows nt 4.0 sp4 中使用。改进后,堆锁的竞争率显著降低。这使所有 win32 堆的直接用户受益。crt 堆建立于 win32 堆的顶部,但它使用自己的小块堆,因而不能从 windows nt 改进中受益。(visual c++ 版本 6.0 也有改进的堆分配程序。)
使用分配高速缓存
分配高速缓存允许高速缓存分配的块,以便将来重用。这能够减少对进程堆(或全局堆)
的分配/释放调用的次数,也允许最大限度的重用曾经分配的块。另外,分配高速缓存允许收集统计信息,以便较好地理解对象在较高层次上的使用。
典型地,自定义堆分配程序在进程堆的顶部实现。自定义堆分配程序与系统堆的行为很相似。主要的差别是它在进程堆的顶部为分配的对象提供高速缓存。高速缓存设计成一套固定大小(如 32 字节、64 字节、128 字节等)。这一个很好的策略,但这种自定义堆分配程序丢失与分配和释放的对象相关的“语义信息”。
与自定义堆分配程序相反,“分配高速缓存”作为每类分配高速缓存来实现。除能够提供
自定义堆分配程序的所有好处之外,它们还能够保留大量语义信息。每个分配高速缓存处理程序与一个目标二进制对象关联。它能够使用一套参数进行初始化,这些参数表示并发级别、对象大小和保持在空闲列表中的元素的数量等。分配高速缓存处理程序对象维持自己的私有空闲实体池(不超过指定的阀值)并使用私有保护锁。合在一起,分配高速缓存和私有锁减少了与主系统堆的通信量,因而提供了增加的并发、最大限度的重用和较高的可伸缩性。 需要使用清理程序来定期检查所有分配高速缓存处理程序的活动情况并回收未用的资源。如果发现没有活动,将释放分配对象的池,从而提高性能。可以审核每个分配/释放活动。第一级信息包括对象、分配和释放调用的总数。通过查看它们的统计信息可以得出各个对象之间的语义关系。利用以上介绍的许多技术之一,这种关系可以用来减少内存分配。
分配高速缓存也起到了调试助手的作用,帮助您跟踪没有完全清除的对象数量。通过查看态堆栈返回踪迹和除没有清除的对象之外的签名,甚至能够找到确切的失败的调用者。
mp
堆
mp
堆是对多处理器友好的分布式分配的程序包,在 win32 sdk(windows nt 4.0 和更新
版本)中可以得到。最初由 jvert 实现,此处堆抽象建立在 win32 堆程序包的顶部。mp 堆创建多个 win32 堆,并试图将分配调用分布到不同堆,以减少在所有单一锁上的竞争。
本程序包是好的步骤 —一种改进的 mp-友好的自定义堆分配程序。但是,它不提供语义信息和缺乏统计功能。通常将 mp 堆作为 sdk 库来使用。如果使用这个 sdk 创建可重用组件,您将大大受益。但是,如果在每个 dll 中建立这个 sdk 库,将增加工作设置。
重新思考算法和数据结构
要在多处理器机器上伸缩,则算法、实现、数据结构和硬件必须动态伸缩。请看最经常分配和释放的数据结构。试问,“我能用不同的数据结构完成此工作吗?”例如,如果在应用程序初始化时加载了只读项的列表,这个列表不必是线性链接的列表。如果是动态分配的数组就非常好。
动态分配的数组将减少内存中的堆块和碎片,从而增强性能。
减少需要的小对象的数量减少堆分配程序的负载。例如,我们在服务器的关键处理路径上使用五个不同的对象,每个对象单独分配和释放。一起高速缓存这些对象,把堆调用从五
个减少到一个,显著减少了堆的负载,特别当每秒钟处理 1,000 个以上的请求时。
如果大量使用“automation”结构,请考虑从主线代码中删除“automation bstr”,或至少避免重复的 bstr 操作。(bstr 连接导致过多的重分配和分配/释放操作。)
摘要
对所有平台往往都存在堆实现,因此有巨大的开销。每个单独代码都有特定的要求,但设计能采用本文讨论的基本理论来减少堆之间的相互作用。
1.评价您的代码中堆的使用。
2.改进您的代码,以使用较少的堆调用:
分析关键路径和固定数据结构。
3.在实现自定义的包装程序之前使用量化堆调用成本的方法。
4.如果对性能不满意,请要求 os 组改进堆。更多这类请求意味着对改进堆的更多关注。
5.要求 c 运行时组针对 os 所提供的堆制作小巧的分配包装程序。随着 os 堆的改进,c运行时堆调用的成本将减小。
操作系统(windows nt 家族)正在不断改进堆。请随时关注和利用这些改进.