内存堆栈图

这是一张内存堆栈图,根据这张图,来了解一些原理。

程序在内存中的模型

要讲清楚内存模型,我们就要深入底层涉及到汇编,很多高级语言都会经历翻译到汇编这一中间过程,汇编可以直观地使用机器指令,是最接近的底层的语言。

在一个汇编程序中,常常把一个用户空间程序按习惯分为三个段:.data段,.bss段,.text段。

.data段

.data段包含了已经初始化了的数据项,这些数据在程序开始运行前就拥有自己的值,这些值是可执行文件的一部分,当可执行文件被加载到内存中用于执行时,这些数据也被加载到内存中。

定义的初始化数据越多,可执行文件就越大,运行它的时候也就需要更长的时间才能将它们从磁盘加载到内存。

一些全局或者静态的,且经过定义初始化过的变量,就属于该段。例如下面代码中的a,指针p以及b三个变量:

1
2
3
4
5
6
7
int a = 2;
int *p = &a;
int main ()
{
static int b = 1;
return 0;
}

.bss段

并不是所有数据项在程序开始之前都拥有值,例如你可以定义一个缓冲区来存在某些数据,这个缓冲区是.bss段中定义的。

分别定义.data段与.bss段中的数据,它们一个重要的区别就是:.data段中的数据会添加到可执行文件的大小上,而.bss段中的则不会。即便你给.bss段定义一个1M字节的缓冲区,其最终可执行文件大小也几乎不变(除了大约50个字节用于描述外)。

程序在加载时知道哪些数据项没有初值,它会为这些数据项分配空间,而具有初值的数据线会与其初值一同读入。

一些全局或者静态的,且未经过初始化的变量,属于.bss段。例如上文中.data段段的三个变量,如果不进行初始化,就会存储在本段中。

.text段

以上两个段都是源程序所需要的数据,而真正组成程序的机器指令则存放在.text段中。一般情况下,在.text段中不进行数据项的定义。.text段包含名为标号(label)的符号,这些符号用于标识跳转和调用程序代码位置。

程序内存中的堆栈

文字常量区

如下代码:
char *p3 = "123456"; //123456\0在常量区,p3在栈上
这个文字常量区是什么?显然它与字符串存放有关。所谓字符串是指位于连续内存区域中的一个字符序列。字符串通过在起始处关联一个标号来进行定义。在汇编中,常见的字符串定义如下:
MSG: db "something"
它是位于.data段中的。和.data段中的所有变量一样,它也是一种已经初始化的数据:带有一个值,而不仅仅是一个在将来某时刻用于存放数据的内存空间。MSG标号和DB指令在内存中指定一个字节作为字符串的起点,而字符串中的字符数则告诉汇编编译器为该字符预留多少个字节的存储空间。

但高级语言中的字符串可能要比这里复杂一点,以C语言为例,针对printf函数中包含的字串。我认为其存储于.data段和.text段之间的一个名叫.rodata段的地方。即那张常见的“堆栈内存图”中底部绿色的“只读区”

堆和栈的细节

堆和栈是两种变量存储的位置。在汇编中常说的堆栈,其实是栈,并不包含堆。

栈由系统管理。

首先,栈是一个后进先出(LIFO)结构。当把数据放入栈时,我们把数据push进入;当从栈取出数据时,我们把数据pop出来。栈随着数据被压入或者弹出而增长或者减小。最新压入栈的项被认为是在“栈的顶部”。当从栈中弹出一个项时,我们得到的是位于栈最顶部的那一个。就像给弹夹上子弹,只能在顶部进行操作。

在x86体系中,栈顶由堆栈指针寄存器ESP来标记,它是一个32位寄存器,里面存放着最后一个压入栈顶的项的内存地址。正因为有它,我们才能够随时操作到需要的项。需要注意的是,栈顶是朝着地内存方向增长的

再看上面的程序内存中的堆栈那张图,位于.bss段之间有一段空余内存,C程序常用这种剩余内存空间来为那些位于堆内存中的,“已经在运行中”的变量分配空间。我们常说的堆就在这里。

一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
main.cpp 
int a = 0; 全局初始化区
char *p1; 全局未初始化区
main()
{
int b; 栈
char s[] = "abc"; 栈
char *p2; 栈
char *p3 = "123456"; //123456\0在常量区,p3在栈上。
static int c =0; 全局(静态)初始化区
p1 = (char *)malloc(10); 堆
p2 = (char *)malloc(20); 堆
}

堆和栈的区别

申请方式和回收方式不同

  1. 栈(satck):由系统自动分配。例如,声明在函数中一个局部变量int b;系统自动在栈中为b开辟空间。
  2. 堆(heap):需程序员自己申请(调用malloc,realloc,calloc),并指明大小,并由程序员进行释放。容易产生memory leak。

例如:

1
2
char  *p;
p = (char *)malloc(sizeof(char));

但是,p本身是在栈中。

由于栈上的空间是自动分配自动回收的,所以栈上的数据的生存周期只是在函数的运行过程中,运行后就释放掉,不可以再访问。而堆上的数据只要程序员不释放空间,就一直可以访问到,不过缺点是一旦忘记释放会造成内存泄露。

申请后系统的响应

  1. 栈:只要栈的空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
  2. 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,但系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的free语句才能正确的释放本内存空间。另外,找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

说明:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题
堆会在申请后还要做一些后续的工作这就会引出申请效率的问题。

申请效率

  1. 栈由系统自动分配,速度快。但程序员是无法控制。
  2. 堆是由malloc分配的内存,一般速度比较慢,而且容易产生碎片,不过用起来最方便。

申请大小的限制

  1. 栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在 Windows下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
  2. 堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

堆和栈中的存储内容

  1. 栈: 在函数调用时,第一个进栈的是主函数中函数调用后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。
    当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
  2. 堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容有程序员安排。

存取效率

  1. 堆:char *s1=”hellow tigerjibo”;是在编译是就确定的;
  2. 栈:char s1[]=”hellow tigerjibo”;是在运行时赋值的;用数组比用指针速度更快一些,指针在底层汇编中需要用edx寄存器中转一下,而数组在栈上读取。

补充:
栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

分配方式

  1. 堆都是动态分配的,没有静态分配的堆。
  2. 栈有两种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的。它的动态分配是由编译器进行释放,无需手工实现。

最后,关于栈和堆一个形象的比喻:

使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,好处是快捷,但是自由度小。
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。