目录
堆(Heap)和栈(Stack)是计算机内存管理中两个重要的概念,它们在数据存储、生命周期和访问方式上有着显著的区别,在理解这两个概念时,需要放到具体的场景下,因为不同场景下,堆与栈代表不同的含义。一般情况下,有两层含义:
(1)程序内存布局场景下,堆与栈表示两种内存管理方式。
(2)数据结构场景下,堆与栈表示两种常用的数据结构。
二者又有不同的使用场景:
栈(Stack):在函数调用时,参数和局部变量的存储;递归算法的实现;临时数据的快速处理。
堆(Heap):存储大规模数据结构,如动态数组、链表、树等;在运行时需要创建对象或数据结构但其大小无法预先确定的情况。
栈适合用于小规模、短生命周期的数据,自动管理,速度快,但空间有限。
堆适合用于大规模、长生命周期的数据,灵活性高,但需要手动管理。
1. 栈的工作原理
1.1 内存分配
在函数调用时,操作系统会为该函数分配栈空间,用于存储局部变量、参数以及返回地址等。
每当一个新的函数被调用时,一个新的栈帧(stack frame)会被创建,这个栈帧包含了该函数的所有局部变量和参数。
在编程中,每当一个函数被调用时,操作系统会为该函数分配一段栈空间,通常称为“栈帧”。
代码示例:
#include <stdio.h>
void exampleFunction(int a, int b) {
int sum = a + b; // 局部变量
printf("Sum: %d\n", sum);
printf("Address of a: %p\n", (void*)&a);
printf("Address of b: %p\n", (void*)&b);
printf("Address of sum: %p\n", (void*)&sum);
}
int main() {
int x = 5; // 主函数的局部变量
int y = 10; // 主函数的局部变量
exampleFunction(x, y); // 调用函数
return 0;
}
对以上代码,当 exampleFunction 被调用时,操作系统为其分配一个栈帧。参数 a 和 b 会被压入栈中。在上面的例子中,x 和 y 的值(5 和 10)将被传递到函数的栈帧中。局部变量 sum 会在栈帧中分配一定的内存空间。在调用 exampleFunction 时,当前执行位置的地址会被保存到栈中,以便函数执行结束后能够返回到正确的位置。
通常情况下,生长方向是向下(地址减小),这意味着新分配的栈空间地址会比之前的地址小。栈帧的简化示意图如下:
|----------------- |
| sum (局部) | <- exampleFunction 的栈帧顶部
|------------------|
| b (参数) |
|------------------|
| a (参数) |
|------------------|
| 返回地址 (main) | <- 上一个函数(main)的栈帧
|------------------|
这里我们可以看到sum的内存地址比a和b的小,但是我们又会发现一个问题,不是说先分配的内存大吗?为什么b的要比a的大,这是因为编译器在生成代码时可能会根据调用约定、优化策略等因素,调整数据在栈中的布局。比如,有时候局部变量可能会按特定顺序排列,以提高访问效率,这就导致了b的内存地址比a的大。
1.2 地址生长方向
通常,栈是向低地址生长的,也就是说,后定义的变量会占用较低的内存地址。
例如,在你的代码示例中,char s[] = "abc"; 会比 int b; 先被分配,因此 s 的地址会低于 b 的地址。
代码示例:
#include <stdio.h>
int main() {
int b; // 栈变量
char s[] = "abc"; // 栈变量,存放字符串
char* p2; // 栈变量,指针声明,未初始化
printf("Address of b: %p\n", (void*)&b);
printf("Address of s: %p\n", (void*)&s);
printf("Address of p2: %p\n", (void*)&p2);
return 0;
}
这个并没有按照向低地址生长,具体原因同上,编译器为了优化或满足对齐要求,可能改变了变量在栈中的排列顺序。
1.3 生命周期
栈中存储的变量在函数执行期间存在,一旦函数执行结束,栈帧被销毁,所有局部变量的内存会被释放,生命周期结束。
这意味着栈中的数据不需要开发者手动管理,简单而且高效。
代码示例:
#include <stdio.h>
void exampleFunction() {
int localVar = 10; // 局部变量在栈中创建
printf("Local variable value: %d\n", localVar);
}
int main() {
exampleFunction(); // 调用函数
// 在此处 localVar 不再可用
// printf("%d\n", localVar); // 这行会导致编译错误
return 0;
}
当 exampleFunction 执行完毕后,其栈帧被销毁,localVar 的内存被释放。
如果尝试在 main 中访问 localVar(如注释中的 printf),将会导致编译错误,因为 localVar 超出了作用域。
2. 堆的工作原理
2.1 动态内存分配
动态内存分配是指在程序运行时根据需要申请和释放内存的能力,这对于处理不确定大小的数据结构非常重要。在 C 语言中,常用的动态内存管理函数包括 malloc、calloc、realloc 和 free。下面是这些函数的简要说明及其用法示例。
2.1.1 malloc函数
用途:分配指定大小的内存块。
返回值:返回指向分配内存块的指针,如果失败则返回 NULL。
int *arr = (int *)malloc(n * sizeof(int));
2.1.2 calloc函数
用途:分配内存块并初始化为零。
参数:第一个参数是要分配的元素个数,第二个参数是每个元素的大小。
返回值:返回指向分配内存块的指针,如果失败则返回 NULL。
int *arr = (int *)calloc(n, sizeof(int));
2.1.3 realloc函数
用途:重新调整已分配内存块的大小。
参数:第一个参数是之前分配的指针,第二个参数是新的大小。
返回值:返回指向新内存块的指针,如果失败则返回 NULL,原内存块保持不变。
arr = (int *)realloc(arr, newSize * sizeof(int));
2.1.4 free函数
用途:释放之前分配的内存块。
参数:要释放的指针。
free(arr);
代码示例:
#include <stdio.h>
#include <stdlib.h>
int main() {
int n;
printf("请输入元素的数量:");
scanf("%d", &n);
// 使用 malloc 分配内存
int *arr = (int *)malloc(n * sizeof(int));//若内存分配失败,返回值为NULL
if (arr == NULL) //判断内存是否分配成功
{
printf("内存分配失败!\n");
return 1; // 错误处理
}
// 初始化数组
for (int i = 0; i < n; i++)
{
arr[i] = i + 1;
}
// 打印数组
printf("数组元素: ");
for (int i = 0; i < n; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
// 重新分配内存
printf("输入新大小: ");
int newSize;
scanf("%d", &newSize);
arr = (int *)realloc(arr, newSize * sizeof(int));
if (arr == NULL)
{
printf("重新分配失败!\n");
return 1; // 错误处理
}
// 如果增加了大小,初始化新元素
for (int i = n; i < newSize; i++)
{
arr[i] = 0; // 或其他值
}
// 打印新的数组
printf("新数组元素: ");
for (int i = 0; i < newSize; i++)
{
printf("%d ", arr[i]);
}
printf("\n");
// 释放内存
free(arr);
return 0;
}
2.2 生命周期管理
手动管理内存:在堆中分配的内存块不会在程序结束时自动释放,开发者需要使用 free 函数显式释放不再需要的内存。
避免内存泄漏:如果未能适时释放已分配的内存,会导致内存泄漏,进而降低程序的性能,甚至导致系统崩溃。
最佳实践:在每次调用内存分配函数(如 malloc、calloc 或 realloc)后,确保在适当的时候使用 free 释放内存。
2.3 地址生长方向
堆的内存地址生长方向与栈相反,由低到高,但需要注意的是,后申请的内存空间并不一定在先申请的内存空间的后面,即 p2 指向的地址并不一定大于 p1 所指向的内存地址,原因是先申请的内存空间一旦被释放,后申请的内存空间则会利用先前被释放的内存,从而导致先后分配的内存空间在地址上不存在先后关系。堆中存储的数据若未释放,则其生命周期等同于程序的生命周期。
3. 堆与栈区别
3.1 管理方式不同
栈:由操作系统自动分配和释放,无需程序员手动管理。
堆:由程序员手动申请和释放,容易导致内存泄漏。
3.2 空间大小不同
栈:通常较小,受限于每个进程的栈大小(例如,Windows 默认 1MB,Linux 默认 10MB)。
堆:理论上可以使用的空间较大,取决于虚拟内存的大小。
3.3 生长方向不同
栈:向下生长,地址从高到低。
堆:向上生长,地址从低到高。
3.4 分配方式不同
栈:支持静态和动态分配。静态分配用于局部变量,动态分配通过 alloca() 函数实现。
堆:仅支持动态分配,由程序员通过库函数或运算符进行管理。
3.5 分配效率不同
栈:由于有硬件支持和专门指令,分配和释放效率较高。
堆:由C/C++提供的库函数或运算符来完成申请与管理,实现机制复杂,频繁分配可能导致内存 碎片,效率较低。
3.6 存放内容不同
栈:栈存放的内容,函数返回地址、相关参数、局部变量和寄存器内容等。当主函数调用另外一个函数的时候,要对当前函数执行断点进行保存,需要使用栈来实现,首先入栈的是主函数下一条语句的地址,即扩展指针寄存器的内容(EIP),然后是当前栈帧的底部地址,即扩展基址指针寄存器内容(EBP),再然后是被调函数的实参等,一般情况下是按照从右向左的顺序入栈,之后是被调函数的局部变量,注意静态变量是存放在数据段或者 BSS 段,是不入栈的。出栈的顺序正好相反,最终栈顶指向主函数下一条语句的地址,主程序又从该地址开始执行。
简单点来说,存放函数返回地址、参数、局部变量等。每个函数调用会创建一个新的栈帧。
堆:存放由程序员动态分配的数据,具体内容由程序员控制。