堆(heap)和栈(stack)是C/C++编程不可避免会碰到的两个基本概念。首先,这两个概念都可以在讲数据结构的书中找到,他们都是基本的数据结构,虽然栈更为简单一些。
在具体的C/C++编程框架中,这两个概念并不是并行的。对底层机器代码的研究可以揭示,栈是机器系统提供的数据结构,而堆则是C/C++函数库提供的。
具体地说,现代计算机(串行执行机制),都直接在代码底层支持栈的数据结构。这体现在,有专门的寄存器指向栈所在的地址,有专门的机器指令完成数据入栈出栈的操作。这种机制的特点是效率高,支持的数据有限,一般是整数,指针,浮点数等系统直接支持的数据类型,并不直接支持其他的数据结构。因为栈的这种特点,对栈的使用在程序中是非常频繁的。对子程序的调用就是直接利用栈完成的。机器的call指令里隐含了把返回地址推入栈,然后跳转至子程序地址的操作,而子程序中的ret指令则隐含从堆栈中弹出返回地址并跳转之的操作。C/C++中的自动变量是直接利用栈的例子,这也就是为什么当函数返回时,该函数的自动变量自动失效的原因(因为堆栈恢复了调用前的状态)。
和栈不同,堆的数据结构并不是由系统(无论是机器系统还是操作系统)支持的,而是由函数库提供的。基本的malloc/realloc/free函数维护了一套内部的堆数据结构。当程序使用这些函数去获得新的内存空间时,这套函数首先试图从内部堆中寻找可用的内存空间,如果没有可以使用的内存空间,则试图利用系统调用来动态增加程序数据段的内存大小,新分配得到的空间首先被组织进内部堆中去,然后再以适当的形式返回给调用者。当程序释放分配的内存空间时,这片内存空间被返回内部堆结构中,可能会被适当的处理(比如和其他空闲空间合并成更大的空闲空间),以更适合下一次内存分配申请。这套复杂的分配机制实际上相当于一个内存分配的缓冲池(Cache),使用这套机制有如下若干原因:
1. 系统调用可能不支持任意大小的内存分配。有些系统的系统调用只支持固定大小及其倍数的内存请求(按页分配);这样的话对于大量的小内存分类来说会造成浪费。
2. 系统调用申请内存可能是代价昂贵的。系统调用可能涉及用户态和核心态的转换。
3. 没有管理的内存分配在大量复杂内存的分配释放操作下很容易造成内存碎片。
堆和栈的对比
从以上知识可知,栈是系统提供的功能,特点是快速高效,缺点是有限制,数据不灵活;而栈是函数库提供的功能,特点是灵活方便,数据适应面广泛,但是效率有一定降低。栈是系统数据结构,对于进程/线程是唯一的;堆是函数库内部数据结构,不一定唯一。不同堆分配的内存无法互相操作。栈空间分静态分配和动态分配两种。静态分配是编译器完成的,比如自动变量(auto)的分配。动态分配由alloca函数完成。栈的动态分配无需释放(是自动的),也就没有释放函数。为可移植的程序起见,栈的动态分配操作是不被鼓励的!堆空间的分配总是动态的,虽然程序结束时所有的数据空间都会被释放回系统,但是精确的申请内存/释放内存匹配是良好程序的基本要素
另一篇网摘:
堆:欢乐和痛苦
前言
您是否是动态分配的 c/c++ 对象忠实且幸运的用户?您是否在模块间的往返通信中频繁地使用了“自动化”?您的程序是否因堆分配而运行起来很慢?不仅仅您遇到这样的问题。几乎所有项目迟早都会遇到堆问题。大家都想说,“我的代码真正好,只是堆太慢”。那只是部分正确。更深入理解堆及其用法、以及会发生什么问题,是很有用的。
什么是堆?
在程序中,使用堆来动态分配和释放对象。在下列情况下,调用堆操作:
1. 事先不知道程序所需对象的数量和大小。
2. 对象太大而不适合堆栈分配程序。
堆使用了在运行时分配给代码和堆栈的内存之外的部分内存。下图给出了堆分配程序的不同层。
我: 一些堆分配的函数操作:
globalalloc/globalfree:microsoft win32 堆调用,这些调用直接与每个进程的默认堆进行对话。
localalloc/localfree:win32 堆调用(为了与 microsoft windows nt 兼容),这些调用直接与每个进程的默认堆进行对话。
com 的 imalloc 分配程序(或 cotaskmemalloc / cotaskmemfree):函数使用每个进程的默认堆。自动化程序使用“组件对象模型 (com)”的分配程序,而申请的程序使用每个进程堆。
c/c++ 运行时 (crt) 分配程序:提供了 malloc() 和 free() 以及 new 和 delete 操作符。如 microsoft visual basic 和 java 等语言也提供了新的操作符并使用垃圾收集来代替堆。crt 创建自己的私有堆,驻留在 win32 堆的顶部。
windows nt 中,win32 堆是 windows nt 运行时分配程序周围的薄层。所有 api 转发它们的请求给 ntdll。
windows nt 运行时分配程序提供 windows nt 内的核心堆分配程序。它由具有 128 个大小从 8 到 1,024 字节的空闲列表的前端分配程序组成。后端分配程序使用虚拟内存来保留和提交页。
在图表的底部是“虚拟内存分配程序”,操作系统使用它来保留和提交页。所有分配程序使用虚拟内存进行数据的存取。
分配和释放块不就那么简单吗?为何花费这么长时间?
堆实现的注意事项
传统上,操作系统和运行时库是与堆的实现共存的。在一个进程的开始,操作系统创建一个默认堆,叫做“进程堆”。如果没有其他堆可使用,
则块的分配使用“进程堆”。语言运行时也能在进程内创建单独的堆。(例如,c 运行时创建它自己的堆。)除这些专用的堆外,应用程序或许多
已载入的动态链接库 (dll) 之一可以创建和使用单独的堆(dll单独的堆)。win32 提供一整套 api 来创建和使用私有堆。有关堆函数(英文)
的详尽指导,请参见 msdn。
当应用程序或 dll 创建私有堆时,这些堆存在于进程空间,并且在进程内是可访问的。从给定堆分配的数据将在同一个堆上释放。
(不能从一个堆分配而在另一个堆释放。)
在所有虚拟内存系统中,堆驻留在操作系统的“虚拟内存管理器”的顶部。语言运行时堆也驻留在虚拟内存顶部。某些情况下,这些堆是操作系统堆
中的层,而语言运行时堆则通过大块的分配来执行自己的内存管理,不使用操作系统堆,而使用虚拟内存函数更利于堆的分配和块的使用。(我,使用
函数来分配堆。)
典型的堆实现由前、后端分配程序组成。前端分配程序维持固定大小块的空闲列表。对于一次分配调用,堆尝试从前端列表找到一个自由块。如果失败,
堆被迫从后端(保留和提交虚拟内存)分配一个大块来满足请求。通用的实现有每块分配的开销,这将耗费执行周期,也减少了可使用的存储空间。
knowledge base 文章 q10758,“用 calloc() 和 malloc() 管理内存” (搜索文章编号), 包含了有关这些主题的更多背景知识。另外,
有关堆实现和设计的详细讨论也可在下列著作中找到:“dynamic storage allocation: a survey and critical review”,作者
paul r. wilson、mark s. johnstone、michael neely 和 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
(英文)。)单一全局锁本质上是用来保护堆数据结构,防止跨多线程的随机存取。若堆操作太频繁,单一全局锁会对性能有不利的影响。
什么是常见的堆性能问题?
以下是您使用堆时会遇到的最常见问题:
1. 分配操作造成的速度减慢。光分配就耗费很长时间。
最可能导致运行速度减慢原因是空闲列表没有块,所以运行时分配程序代码会耗费周期寻找较大的空闲块,或从后端分配程序分配新块。
2. 释放操作造成的速度减慢。释放操作耗费较多周期,主要是启用了收集操作。收集期间,每个释放操作“查找”它的相邻块,取出它们并构造成较大块,
然后再把此较大块插入空闲列表。在查找期间,内存可能会随机碰到,从而导致高速缓存不能命中,性能降低。
3. 堆竞争造成的速度减慢。当两个或多个线程同时访问数据,而且一个线程继续进行之前必须等待另一个线程完成时就发生竞争。
竞争总是导致麻烦;这也是目前多处理器系统遇到的最大问题。当大量使用内存块的应用程序或 dll 以多线程方式运行(或运行于多处理器系统上)时
将导致速度减慢。单一锁定的使用(常用的解决方案)意味着使用堆的所有操作是序列化的。当等待锁定时序列化会引起线程切换上下文。可以想象交叉
路口闪烁的红灯处走走停停导致的速度减慢。竞争通常会导致线程和进程的上下文切换。上下文切换的开销是很大的,但开销更大的是数据从处理器高速
缓存中丢失,以及后来线程复活时的数据重建。
4.堆破坏造成的速度减慢。造成堆破坏的原因是应用程序对堆块的不正确使用。通常情形包括释放已释放的堆块或使用已释放的堆块,以及块的越界重写
等明显问题。(破坏不在本文讨论范围之内。有关内存重写和泄漏等其他细节,请参见 microsoft visual c++(r) 调试文档 。)
5. 频繁的分配和重分配造成的速度减慢。这是使用脚本语言时非常普遍的现象。如字符串被反复分配,随重分配增长和释放。不要这样做,如果可能,
尽量分配大字符串和使用缓冲区。另一种方法就是尽量少用连接操作。
竞争是在分配和释放操作中导致速度减慢的问题。理想情况下,希望使用没有竞争和快速分配/释放的堆。
可惜,现在还没有这样的通用堆,也许将来会有。
在所有的服务器系统中(如 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 规则)。个别情况下,需要内存缓冲区来保存字符串/二进制数据,但事先不知道总字节数。
估计并内联一个大小能满足百分之八十需要的缓冲区。对剩余的百分之二十,可以分配一个新的缓冲区和指向这个缓冲区的指针。
这样,就减少分配和释放调用并增加数据的位置空间,从根本上提高代码的性能。
在块中分配对象(块化)。块化是以组的方式一次分配多个对象的方法。如果对列表的项连续跟踪,例如对一个 {名称,值} 对的列表,
有两种选择:选择一是为每一个“名称-值”对分配一个节点;选择二是分配一个能容纳(如五个)“名称-值”对的结构。
例如,一般情况下,如果存储四对,就可减少节点的数量,如果需要额外的空间数量,则使用附加的链表指针。
块化是友好的处理器高速缓存,特别是对于高速缓存,因为它提供了增加的位置 —不用说对于块分配,很多数据块会在同一个虚拟页中。
正确使用 _amblksiz。c 运行时 (crt) 有它的自定义前端分配程序,该分配程序从后端(win32 堆)分配大小为 _amblksiz 的块。
将 _amblksiz 设置为较高的值能潜在地减少对后端的调用次数。这只对广泛使用 crt 的程序适用。
堆与栈的学习(1)
最新推荐文章于 2024-03-22 20:59:22 发布
