C++ 的栈与堆:变量到底住在哪
一句话:普通局部变量住在栈上 — 纳秒级分配、离开作用域自动销毁。堆给那些必须活得比作用域久、或运行期才知道大小的东西 — 更大更灵活,但要你分配、你释放。
亲眼看生命周期差异
下面的函数有个局部变量,程序有个全局变量。把函数调三次,看谁记得住什么:
#include <iostream>
using namespace std;
int counter = 0; // 全局:活整个程序
void bump() {
int local = 0; // 栈:每次调用重新出生
local++;
counter++;
cout << "local=" << local << " counter=" << counter << endl;
}
int main() {
bump();
bump();
bump();
return 0;
}
local 每次都打出 1 — 它在 bump() 的一次调用里出生、存活、死亡,在栈上。counter 跨调用保持自己的值,因为它的生命周期是整个程序。生命周期就是栈/堆之别的核心。
并排对比
| 栈 | 堆 | |
|---|---|---|
| 谁来分配 | 自动 — 声明即得 | 你 — new(或容器/智能指针) |
| 生命周期 | 随作用域结束 | 直到 delete(或它的主人死) |
| 速度 | 极快(挪一个指针) | 较慢(记账、找空位) |
| 大小 | 小 — 一般 ~1–8 MB | 基本等于"可用内存" |
| 何时死 | 作用域退出 — 有保证 | 你释放才死 — 不放就是泄漏 |
| 典型用途 | 局部变量、函数参数 | 大数据、运行期定大小的数据、共享对象 |
堆分配:new 与 delete
(浏览器引擎暂不支持 new/delete — 这段在桌面编译器上跑。)
int main() {
int* p = new int(42); // 在堆上分配,p 装着地址
cout << *p << endl; // 42
delete p; // 还内存 — 你的活,不是作用域的
return 0;
}
每个 new 必须恰好配一个 delete。漏掉 delete → 内存泄漏(再也无法使用的内存)。delete 两次,或删完还继续用指针(悬垂指针)→ 未定义行为。
现代答案:几乎不手写 new/delete
C++ 对这个问题的杀手锏是 RAII — 资源获取即初始化:把资源的生命周期绑到对象的作用域上,作用域保证清理。你其实早就在用 RAII 只是没叫它名字:std::vector 自动管理它的堆数组。单个对象由智能指针做同样的事:
#include <memory>
int main() {
auto p = std::make_unique<int>(42); // 堆内存,p 持有它
cout << *p << endl; // 42
return 0; // p 在这里析构 — 内存自动释放
}
现代代码的经验法则:默认栈;要堆就通过主人(vector、string、unique_ptr)— 几乎不碰裸 new/delete。
两个著名的翻车方式
- 栈溢出:栈很小。够不到基例的递归(或
int big[10000000]这种巨型局部数组)会把它榨干 — 程序崩溃。Stack Overflow 这个网站名就是从这来的。 - 返回局部变量的指针:
int* f() { int x = 5; return &x; }—x在右花括号处就死了;调用方拿到的是一个尸体的地址。未定义行为,也是 C++ 面试里最经典的生命周期 bug。
这个主题的面试题
- "什么住在栈上,什么住在堆上?" — 局部/参数 vs 显式分配的数据(以及 vector/string 背后替你管的东西)。
- "栈分配为什么快?" — 只是挪一个栈指针;堆分配要记账还要找空位。
- "一句话说清内存泄漏?" — 再也够不着、永远不会被释放的堆内存。
- "什么是 RAII?" — 把资源生命周期绑到对象作用域,清理自动化;vector、string、fstream、智能指针全是这么工作的。
- "返回局部变量的引用/指针为什么坏?" — 本文开头那个生命周期问题,武器化版本。
去练习场练手
- 改生命周期演示:再加一个全局和一个局部,运行前预测全部六行输出。
- 写
int* broken() { int x = 7; return &x; }— 别运行(未定义行为!);写一行注释,解释桌面编译器为什么会警告它。 - 递归深度:斐波那契示例的
fib(10)安全;推算fib的栈大约多深,以及fib(100000)为什么会栈溢出。