C++中的堆与栈
C++ 中的堆与栈
1 基本概念
也不知道是什么原因,很多人总是把堆和栈混合一起,在写程序时,总是经常脱口而出地说堆栈。网上的一些资料说堆栈的叫法是有历史原因的,至于具体是什么历史原因,这不是本文所要讨论的问题。
堆: 在数据结构中,堆是一种满足“堆性质”(至于什么是堆性质可以查阅任何一本数据结构的书)的数据结构。然而,通常我们所指的堆都是指二叉堆,即一种使用数 组来模拟完全二叉树的结构。当然,也存在其它形式的堆,包括斐波拉契堆、二项堆、杨氏表等,想获得有关这些特殊堆的性质可以查阅算法导论。然而,在编译器 中,堆是一个存储区,通常用于动态分配存储空间,一般堆具有不连续性(在下文中将讲到堆的不连续性)。
栈:在数据结构中,栈是一种按照数据项先进后出的顺序排列的数据结构,我们只能在栈顶来对栈中的数据项进行操作。然而在编译器中,栈通常是用来为函数中的临时变量分配存储空间,通常栈空间的分配具有连续性。
2 相关知识
通常一个由 C++ 编译的程序占用的内存分为以下五个部分(这些知识对理解下文至关重要,这些是对一个基本的 C++ 程序的存储方式的认识):
1 )栈区( stack )
是由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
2 )堆区( heap )
一般由程序员分配释放,若程序员不释放,程序结束时可能由操作系统回收(如果回收的不及时有可能会造成内存泄露)。堆空间的分配方式类似于数据结构中的链表。
3 )全局区(静态区)( static )
全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。在程序结束后由系统释放。
4 )文字常量区
用于存放常量数据,程序结束后由系统释放。
5 )程序代码区
存放函数体的二进制代码。
3 堆和栈的区别
在 IT 面试中,通常有人会问哪个变量是堆变量,哪个变量又是栈变量,操作系统中的栈是向上(从低地址向高地址的方向)申请空间还是向下申请空间等等问题。我想只要掌握了堆和栈的区别,以及它们的工作原理,这些问题都会迎刃而解。本节将分以下几个方面来讲述它们之间的差别。
3.1 存储对象的不同
这个问题其实在第 2 节已经初步提到,在本小节中再次详细说明一下,因为这对下文的理解至关重要。
3.1.1 堆区的存储对象
主要存储动态申请的空间。在 C++ 中,存储“ new 出来”的对象,如下程序段
int *a;
a = new int;
*a = 1;
那么,变量 a 存储的值为 1 , 1 的存储地址在堆区,即指针 a 所指向的那个对象的存储地址是在堆区,但是要注意的是指针 a 本身所存储的区域是在栈区(嘿嘿,晕乎了把,可以看以下例子)。
Exp01:
#include <iostream.h>
int main ()
{
int *a;
a = new int;
*a = 1;
cout << " 指针 a 所指向对象的地址为: " << a << endl;
cout << " 存储指针 a 本身的地址为: " << &a << endl;
return 0;
}
Exp01 的输出结果如下:
指针 a 所指向对象的地址 ( 堆区地址 ) 为: 0x00371100
存储指针 a 本身的地址 ( 栈区地址 ) 为: 0x0012FF7C
3.1.2 栈区的存储对象
主要存储程序中的临时变量,这些临时变量包括函数的参数变量、函数内的临时变量、指针变量(指的是指针本身)、数组变量等。注意:全局变量和静态变量不在栈区,它们是放在全局区。
3.2 存储空间的分配方式
3.2.1 堆区的空间分配方式
堆 区的空间分配是由程序管理,而不是由系统管理。堆空间通常是由程序动态申请的。通常操作系统中有一个记录空闲内存地址的链表,当系统收到程序的申请时,会 遍历该链表,根据某种内存管理算法,寻找一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。对于这种申请 方式,需要在程序中使用 delete 语句释放空间,否则容易导致内存泄露。
堆空间的分配一般都是向高地址扩展,并且具有不连续性。这是由于系统是用链表来管理空闲内存地址的,当然也就不连续了,而系统中链表的遍历方向通常是由低地址向高地址遍历。我们可以通过以下例子可以看到,
Exp02:
#include <iostream.h>
int main ()
{
int *a,*b,*c;
a = new int;
b = new int;
c = new int;
cout << a << " " << b << " " << c << endl;
return 0;
}
Exp02 的输出结果:
0x00371100 0x00371138 0x00371170
由前文我们可以知道 *a,*b,*c 均为堆变量(注意指针本身为栈变量),再由输出结果我们可以看到, a 的地址小于 b , b 小于 c ,并且 a,b,c 之间的差不是 4 ,而是差值较大,由此可以说明堆分配的特点是向高地址扩展的、不连续的。
3.2.2 栈区的空间分配方式
栈通常是由系统自动分配空间的。只要系统剩余的空间大于程序所申请的空间,那么空间申请操作一般都会成功,否则就会出现缓冲栈溢出的错误。 Windows 系统中 C++ 编译器的栈区空间的分配有以下一些性质:
1) 在 Windows 系统中,栈空间的分配是从高地址向低地址扩展的,并且栈空间的分配一般
具有连续性,栈顶的地址和栈的最大容量是由系统预先规定。可以通过以下例子来查看这一性质。
Exp03:
#include <iostream.h>
int main ()
{
//a,b,c 均为临时变量,即为栈变量,由系统自动分配空间
int a;
int b;
int c;
cout << &a << " " << &b << " " << &c << endl;
return 0;
}
Exp03 的输出结果为:
0x0012FF7C 0x0012FF78 0x0012FF74
显然, a 的地址大于 b 和 c 的地址,并且 a,b,c 的地址间隔均为 4 个字节,这可以说明 2 个问题: 1 栈空间的分配是由高地址向低地址扩展的; 2 栈空间的分配一般具有连续性(即相邻变量之间的地址是不间断的,我做了多次实验,均证实了这点,不过仍然不能代表正确,所以只能说一般具有连续性)。
2 ) C++ 中函数参数的空间分配
函数参数的地址分配是根据参数列表中,从左到右的方向来分配的。我们根据下面这个例子来分析:
Exp04:
#include <iostream.h>
int p(int a, int b, int *h)
{
int c,d;
a = 1;
cout << &a << " " << &b << " " << &h << " " << &c << " " << &d << endl;
return a;
}
int main ()
{
int a;
cout << " 变量 a 的地址: " << &a << endl;
int *h;
cout << " 指针变量 h 的地址: " << &h << endl;
a = p(2,3,h);
h = new int;
a = p(2,3,h);
a = q(a);
int *q;
q = new int;
cout << &p << " " << &q << " " << endl;
cout << " 堆变量地址: " << h << " " << q << endl;
return 0;
}
Exp04 的输出结果为:
变量 a 的地址: 0x0012FF7C
指针变量 h 的地址: 0x0012FF78
0x0012FF14 0x0012FF18 0x0012FF1C 0x0012FF08 0x0012FF04
0x0012FF14 0x0012FF18 0x0012FF1C 0x0012FF08 0x0012FF04
0x00401028 0x0012FF74
堆变量地址: 0x00371280 0x003712B8
由上面的输出结果我们可以得到以下结论:
1 )进一步证实栈区的分配地址方式是由高地址向低地址扩展(根据主函数中变量 a 的地址大于指针变量 h 的地址);
2 )函数参数变量的地址分配是由右向左的方式进行的(根据函数 p 中参数变量 a 的地址小于参数变量 b 的地址,参数变量 b 的地址小于指针参数变量 h 的地址,此处还发现了一个现象就是:临时变量 c 的地址比参数变量 a 的地址小了 12 个字节,那么编译器需要这 12 个字节是做什么用的呢?莫非是用于保存断点等信息,这些东西我们不得而知);
3 )函数指针存储在另外一个区域(由函数指针 p 的地址为 0x00401028 ,我们可以知道,它并不是存储在一般的栈区,因为根据输出结果,栈区的地址一般都是 0x0012FFxx 左右,也不是存储在一般的堆区,因为根据输出结果,堆区的地址一般为 0x003712xx 左右,那到底编译器是如何给函数指针分配空间的呢?是另外开一块区域吗?这些问题我们也不得而知,不过我个人认为函数指针仍然是存储在一个“特殊的栈区”,这一点下文会有一个说明);
4 )函数变量申请空间的顺序为:按照先参数变量,后函数内的临时变量的顺序来申请空间(由函数 p 中临时变量 c 的地址小于参数 a 的地址);
5 )一个函数在调用结束后,所有的临时变量都会由系统释放,并且再次调用该函数时,仍然是从第一次调用的地址开始分配空间,这也说明了栈空间是由系统管理的,而不必程序员手工释放(这一点可以由 2 次调用函数 p 的输出结果一样来说明)。
3.3 存储空间的回收方式
1 )堆空间的回收方式
堆空间通常需要使用 free, delete 等函数来释放,系统本身不会对堆空间进行回收。
2 )栈空间的回收方式
栈空间通常是在程序结束时由系统回收,或者在函数调用完毕后,由系统自动回收。
3.4 存储空间的分配效率
栈由系统自动分配,速度较快,但程序员是无法控制的。堆是由 new 分配的内存,一般速度比较慢,而且容易产生内存碎片。这也解释了在写 ACM 程序时,使用静态数组要比动态数组的速度要快。
4 关于函数指针变量的存储问题
在第 3 节中,我们不能确定函数指针的存储位置,下面我们通过下面一个实例来说明我的观点:
Exp05:
#include <iostream.h>
int p()
{ return 0; }
int q()
{ return 0; }
int o()
{ return 0; }
void f()
{}
void g()
{}
void h()
{}
int main ()
{
p();
q();
o();
f();
g();
h();
cout << &p << endl;
p();
cout << &p << endl;
cout << &q << endl;
cout << &o << endl;
cout << &f << endl;
cout << &g << endl;
cout << &h << endl;
return 0;
}
Exp05 的输出结果为:
0x00401019
0x00401019
0x00401014
0x00401037
0x00401023
0x0040101E
0x00401032
由输出结果,我们可以知道,函数指针的存储不具有连续性,但也不像堆区域那样有很大的间隔(各个函数指针的地址都相差不大)。我的观点是:编译器会专门开一块连续的内存区域来函数指针,然后通过某种 hash 算 法来找到相应的函数,其空间的释放和申请也是由系统来管理。函数指针不会像堆那样,动态申请内存空间,因为我们在写程序时,是不需要为函数指针申请空间, 也不需要为函数指针释放空间,因此这一点跟栈类似,但也它也不像栈,满足向低地址扩展、地址连续等特性。虽然它不是栈,但跟栈有很多共同点,所以可以认 为,函数指针是存储在一个“特殊的栈区”。
5 附录
1 )内存泄露
在计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
2) 缓冲栈溢出
指程序中栈所申请的空间大于系统剩余的空间时就会发生缓冲栈溢出的错误。