12.2 - 栈和堆
Key Takeaway
一个程序的内存可以被分为几个不同的区域,称为内存段:
- 代码段(code段) (也称为 text 段),编译后的程序就位于该段。代码段通常是只读的;
- bss段 (也称为未初始化数据段),这里存放0初始化的全局变量和静态变量;
- 数据段(也称为初始化数据段),这里存放初始化的全局变量和静态变量;
- 堆:动态变量的内存是从堆中分配的;
- 栈:形参、局部变量和其他函数相关的信息都存放在这里。
本节课,我们将主要关注堆和栈,它们是这几个内存段中最”有意思“的。
堆内存段
堆段(也称为“自由存储区”)跟踪用于动态内存分配的内存。我们已经在 11.11 - 使用 new 和 delete 进行动态内存分配 中介绍了堆,所以这里只是复习一下:
在 C++ 中,当你使用 new 分配内存时,该内存就会位于堆段。
1 2 |
|
该内存的地址通过操作符new返回,然后可以存储在指针中。你不必担心定位空闲内存并将其分配给用户的底层机制。然而,你需要知道的是,连续内存请求可能不会分配连续的内存地址!
1 2 3 |
|
当动态分配的变量被删除时,内存被“返回”到堆中,然后可以在接收到未来的分配请求时重新分配。请记住,删除指针并不删除变量,它只是将相关地址的内存返回给操作系统。
堆有优点和缺点:
- 在堆上分配内存相对很慢;
- 已分配的内存一直保持分配状态,直到它被特别地释放(注意内存泄漏)或应用程序结束(这时操作系统应该清理它);
- 动态分配的内存必须通过指针访问。解引用指针比直接访问变量比较慢;
- 因为堆是一个很大的内存池,所以可以在这里分配大型数组、结构或类。
调用栈
调用栈所扮演的角色更加有意思。调用栈会追踪所有活动函数(已经被调用但尚未结束)从开始到当前时间点的信息,并且负责为函数形参和局部变量分配内存。
调用栈使用数据结构——栈实现。所以在讨论调用栈的工作原理之前,我们必须要指定栈是一种什么样的数据结构。
数据结构——栈
数据结构是一种组织数据以便有效地使用数据的编程机制,你已经了解了几种类型的数据结构,例如数组和结构体。这两种数据结构都提供了有效地存储数据和访问数据的机制。还有许多在编程中经常使用的附加数据结构,其中相当一部分是在标准库中实现的,栈就是其中之一。
想想自助餐厅里的一叠盘子。因为每个盘子都很重,而且它们是堆叠的,所以你实际上只能做三件事中的一件:
- 看到最顶层盘子的表面;
- 拿走最上面一个盘子(漏出下面一个盘子);
- 将一个新的盘子放在最上面(遮蔽下面一个盘子)。
在计算机编程中,栈是一种容纳多个变量的容器数据结构(很像数组)。然而,数组允许你按任何顺序访问和修改元素(称为随机访问),而栈则有更多的限制。可以在堆栈上执行的操作对应于上面提到的三件事:
- 查看栈顶元素(通常通过
top()
函数或peek()
); - 获取并移除栈顶元素(通过
pop()
完成); - 将一个新元素放置在栈顶(通过
push()
完成)。
栈是一个后入先出(LIFO)的数据结构。最后被压入栈的元素会第一个被弹出。如果你将一个新的盘子放在盘子堆顶上的话,那你第一个拿走的盘子也只能是它。当新的元素被压入栈时,栈就会增长。当元素被移除时,则栈会收缩。
例如,下面是一个简短的序列,展示了栈上的push和pop是如何工作的:
1 2 3 4 5 6 7 8 9 10 11 |
|
对于调用栈是如何工作的,盘子类比是一个非常好的类比,但我们可以做一个更好的类比。考虑一堆邮箱,它们都堆叠在一起。每个邮箱只能装一件物品,而且所有邮箱都是空的。此外,每个邮箱都固定在它下面的邮箱上,因此邮箱的数量不能更改。如果不能更改邮箱的数量,如何获得类似于堆栈的行为?
首先,我们使用一个标记(比如便利贴)来跟踪最下面的空邮箱的位置。在开始时,这将是最低的邮箱(在堆栈的底部)。当我们将一个项目推入邮箱堆栈时,我们将它放入有标记的邮箱中(即第一个空邮箱),并将标记向上移动一个邮箱。当我们从堆栈中弹出一个项目时,我们将标记向下移动一个邮箱(因此它指向顶部的非空邮箱),并从该邮箱中删除该项目。任何低于标记的东西都被认为是“在栈上”。标记处或标记上方的任何东西都不在栈上。
调用栈内存段
调用栈内存段保存用于调用栈的内存。当应用程序启动时,main()
函数被操作系统压到调用栈上。然后程序开始执行。
函数调用时将函数压入调用栈。当当前函数结束时,该函数从调用栈中弹出。因此,通过查看压入调用栈上的函数,我们可以看到为到达当前执行点而调用的所有函数。
邮箱的比喻很好地描述了调用栈的工作原理。栈本身是一段固定长度的内存。邮箱可以被看做是内存地址,而压入或弹出的内容称为栈帧。栈帧追踪和某个函数调用相关的全部数据。比喻中的“标记”是一个被称为堆栈指针(SP)的寄存器(CPU中的一小块内存)。堆栈指针用于追踪当前的栈顶位置。
我们还可以进一步优化:当从调用栈中弹出内容的时候,我们并不需要实际清理或清零被弹出的栈帧——只需要将栈顶指针向下移动即可。相应的内容已经不被认为是栈的一部分了(栈指针会在该地址或该地址的下方),所以它也不会被寻址。如果稍后我们将新的栈帧压入了该内存地址,则其内容正好就被覆盖掉了。
调用栈实例
让我们更详细地研究调用栈是如何工作的。下面是调用函数时的步骤:
- 程序中发生函数调用;
- 栈帧被构建并压入调用栈。栈帧包括:
- 函数调用后指令的地址(返回地址)。CPU通过它知道函数退出时应该返回到的地址;
- 所有函数的实参;
- 所有局部变量的内存;
- 保存被函数修改且需要在函数返回时恢复的寄存器
- CPU 跳转到函数起点;
- 函数中的指令开始被执行。
当函数结束时,会执行下面的步骤:
- 寄存器从调用栈恢复;
- 栈帧弹出调用栈。此时会释放局部变量和实参的内存;
- 处理返回值;
- CPU继续从返回地址执行。
根据计算机的体系结构的不同,可以有许多不同的方法处理返回值。有些体系结构将返回值作为堆栈框架的一部分,其他则使用CPU寄存器。
通常,了解调用堆栈如何工作的所有细节并不重要。但是,了解函数在调用时被有效地推入堆栈,在返回时弹出,可以帮助您了解递归所需的基础知识,以及在调试时有用的其他一些概念。
提示:在某些体系结构上,调用堆栈从内存地址0增长。在其他情况下,它会向内存地址0增长。因此,新推的堆栈帧可能比以前的栈帧有更高或更低的内存地址。
一个简单的调用栈例子
考虑下面这个简单的应用程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
在对应调用栈标记点处看起来是这样的:
a:
1 |
|
b:
1 2 |
|
c:
1 |
|
堆栈溢出
栈的大小是有限的,因此它只能保存有限的信息。对于Windows上的Visual Studio 来说,栈的默认大小为 1MB。而对于Linux上的 g++/Clang,栈的默认大小为8MB。如果程序视图在栈上保存过多的信息,就会导致堆栈溢出。当栈上的所有内存都被分配出去时,就会发生堆栈溢出——这种情况下,再次分配的内存就会溢出到其他内存段。
堆栈溢出的主要原因是在栈上分配了过多的变量,或者执行了过多的函数调用(函数A调用函数B调用函数C调用函数D等等)。对于现代操作系统来说,堆栈溢出会导致操作系统报告非法访问并终止程序。
下面这个程序就有可能导致堆栈溢出。你可以在你的系统上试试看程序会不会崩溃:
1 2 3 4 5 6 7 8 9 |
|
这个程序试图在堆栈上分配一个巨大的(可能是40MB)数组。因为堆栈不够大,无法处理这个数组,数组分配溢出到程序不允许使用的内存部分。
在 Windows (Visual Studio) 上,程序运行结果为:
1 |
|
-1073741571
等于十六进制的 c0000005
,它在 Windows 中表示非法访问。注意“hi”并没有被打印出来,因为还没到这一步程序就崩溃了。
下面是另一个程序,它会因为不同的原因导致堆栈溢出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
在上面的程序中,每次调用函数foo()
时,栈帧都会被推入堆栈。因为foo()
无限地调用自身,最终栈将耗尽内存并导致溢出。
栈的优缺点:
- 在栈上分配内存相对较快;
- 在栈上分配的内存只要在作用域就会一直在栈上,在弹出栈时会被释放;
- 所有在栈上分配的内存都是在编译时就已知的,因此这些内存可以通过变量直接访问;
- 因为栈相对来说很小,所以在栈上分配大量的内存是不明智的。这其中就包括创建很大的局部变量数组或大型结构体。
作者注
此评论有一些附加的(简化的)信息,关于堆栈上的变量是如何布局的,以及在运行时如何接收实际的内存地址。