C++ 内存到底分配在哪?------ 栈 vs 堆 全景指南
这是一个看似简单却能把人问倒的问题。很多人会脱口而出"new 就是堆、局部变量就是栈",其实远不止这样 ------ C++ 里还有全局/静态区、thread_local 区、字面量区(.rodata)、以及对象"成员嵌套"带来的混合情况。
本文按"存储期 (storage duration)"为主线梳理,这是 C++ 标准真正用的术语。
1. C++ 标准的四种存储期
C++ 规定每个对象都有一种"存储期",决定了它的生命周期,也间接决定了它的存放位置:
| 存储期 | 典型位置 | 什么时候分配 / 释放 |
|---|---|---|
| 自动存储期 (automatic) | 栈 | 进入 / 离开作用域 |
| 动态存储期 (dynamic) | 堆(自由存储区) | new / malloc → delete / free |
| 静态存储期 (static) | 全局/静态区 (.bss/.data) | 程序启动 / 程序退出 |
| 线程存储期 (thread) | TLS 区 | 线程创建 / 线程退出 |
注意:标准只规定存储期,不规定"栈"或"堆"。这些只是主流实现的惯例。编译器理论上可以把所有自动变量都放到堆上,只要遵守"离开作用域就销毁"的语义。
2. 栈上分配:自动存储期
2.1 谁走栈?
- 函数的局部非 static 变量
- 函数的形参
- 函数的返回地址 / 被调用者保存寄存器(ABI 细节,你一般看不见)
- 临时对象(包括
f() + g()产生的中间结果)
cpp
void foo() {
int x = 42; // 栈
double arr[1024]; // 栈(8KB)
std::string s = "hi"; // 对象本身在栈;字符可能 SSO 内联在栈,也可能在堆
Point p{1, 2}; // 栈
} // 所有上面这些,这一刻一起销毁
2.2 栈上分配的特点
- 极快 :只是
sub rsp, N一条汇编指令。 - LIFO:后进先出,释放顺序严格反向。
- 大小受限 :主线程栈通常 1~8 MB,子线程更小(
pthread默认 2 MB,有的平台 64 KB)。 - 不能跨作用域存活:函数一返回,栈上对象立即析构 ------ 返回指向它的指针/引用就是经典 UB。
2.3 容易踩的坑
cpp
int* bad() {
int x = 42;
return &x; // 💥 栈变量离开作用域,指针悬空
}
std::string_view bad2() {
std::string s = "hi";
return s; // 💥 隐式转成 string_view,指向一个马上析构的栈对象
}
3. 堆上分配:动态存储期
3.1 谁走堆?
唯一的入口是这几个:
new/new[](会调用对象构造函数)malloc/calloc/realloc(C 风格,不调构造)std::allocator::allocate(STL 容器底层走它)- 一些操作系统 API:
mmap、VirtualAlloc(大块内存时)
cpp
int* p = new int(42); // 堆
auto sp = std::make_shared<Foo>(); // 堆(对象 + 控制块常常一起在堆)
auto up = std::make_unique<Foo>(); // 堆
void* m = std::malloc(1024); // 堆
3.2 隐式走堆的 STL 容器
这是最容易被忽视的一类。你没写 new,但容器内部替你写了:
cpp
std::vector<int> v; v.push_back(1); // 缓冲区在堆
std::string s; s = "xxxxxxxxxxxxxxxxxxx"; // 超出 SSO → 堆
std::unordered_map<...> m; m[k] = v; // 桶数组、节点都在堆
std::list<T> l; // 每个节点在堆
std::deque<T> d; // 分块缓冲区在堆
std::function<void()> f = [big_obj]{ ... }; // 闭包太大 → 堆
std::any a = BigStruct{}; // 对象太大 → 堆
std::shared_ptr<T> sp; // 控制块在堆
反之不上堆的 STL:
std::array<T, N>:大小编译期固定,全部内联。- 短串
std::string:命中 SSO,内联在对象里。 - 小闭包
std::function:命中 SBO(Small Buffer Optimization),内联在对象里。 std::pair/std::tuple/std::optional<T>本身:就是普通聚合,跟着外层对象走(外层在栈它就在栈)。
3.3 堆的特点
- 慢 :
malloc几十~上百 ns,还可能触发mmap/锁竞争。 - 生命周期自由 :只要不
delete,就一直活着;这也意味着容易泄漏。 - 容量大:一般只受进程地址空间和系统内存限制。
- 内存碎片:频繁分配/释放不同大小的对象会让堆变得零散。
3.4 RAII:让堆"看起来像栈"
纯 new/delete 容易出事,现代 C++ 几乎总用智能指针 / 容器包一层:
cpp
{
auto p = std::make_unique<Foo>(); // 堆上构造 Foo
...
} // unique_ptr 析构 → delete Foo
这叫 RAII:堆资源的生命周期绑定在一个栈对象上。这是 C++ 管理堆内存的主流范式。
4. 全局 / 静态区:静态存储期
4.1 谁走这里?
- 全局变量
- namespace 作用域变量
- 函数内的
static变量 - 类的
static成员变量 - 字符串字面量
"hello"(放在.rodata只读段)
cpp
int g = 42; // .data 段(有初值、非零)
int g0; // .bss 段(零初始化)
const char* msg = "hi"; // 指针在 .data;"hi" 在 .rodata
void foo() {
static int counter = 0; // 仅第一次进入 foo 时初始化
++counter;
}
struct S { static int x; };
int S::x = 10; // .data
4.2 特点
- 程序启动时初始化,退出时销毁,整个程序生命周期有效。
- 不进栈、不进堆,占独立的段。
- 不同翻译单元的全局变量初始化顺序未定义 ------ 著名的 "static initialization order fiasco",尽量避免互相依赖。
5. 线程存储期:TLS
cpp
thread_local int tid_cache = -1;
- 每个线程有一份独立副本。
- 底层实现通常是:主线程放
.tdata/.tbss,线程创建时为其 TLS 段分配一块(通常来自堆或特殊的 TLS 管理区)。 - 生命周期:线程开始到线程结束。
6. 成员对象:跟着"外层"走
这是初学者最容易混淆的一点 ------ 成员变量不独立分配,而是嵌入在外层对象里:
cpp
struct Point { int x, y; }; // 8 字节聚合
struct Foo {
int n;
Point p; // 内嵌 8 字节
double arr[4]; // 内嵌 32 字节
}; // sizeof(Foo) ≈ 48
Foo f1; // f1 整个在栈 → n, p, arr 全在栈
auto f2 = std::make_unique<Foo>(); // 整个在堆 → 成员全在堆
std::vector<Foo> v(10); // vector 的缓冲在堆 → 10 个 Foo 全在堆
static Foo f3; // 全在 .bss/.data
换句话说:"成员在哪"只取决于"它的最外层对象在哪"。
但!如果成员本身是指针或含堆分配的类型 (string、vector......),那成员是内联的,成员指向的数据仍然在堆:
cpp
struct Bar {
int n; // 嵌在 Bar 里
std::string s; // string 对象本身嵌在 Bar 里
// 短串:字符在 s 的 inline buffer(跟 Bar 走)
// 长串:字符在堆上另一块
std::vector<int> v; // vector 对象嵌在 Bar 里,元素永远在堆
};
Bar b; // Bar 在栈;但 v 的元素一定在堆
7. 临时对象 / 返回值:栈 + RVO
cpp
std::string make() { return std::string("hello"); }
std::string s = make(); // 临时对象在哪?
- 概念上 :
make里的临时对象在make的栈帧,返回时要拷贝/移动出来。 - 实际上 :C++17 起的 RVO / 强制 copy-elision 通常让编译器直接在调用方
s的位置构造,一次构造,零拷贝。
对延长临时对象生命周期的经典规则:
cpp
const std::string& r = make(); // 临时对象被绑到 const 引用 → 生命周期延长到 r
std::string_view sv = make(); // ❌ string_view 不享受这个延长规则,立刻悬空
8. 一张综合速查表
| 代码形式 | 对象本身 | "内部数据" |
|---|---|---|
int x;(局部) |
栈 | --- |
int x;(全局/static) |
.bss/.data | --- |
int* p = new int; |
p 在栈 |
*p 在堆 |
std::string s = "hi"; |
栈 | 字符:SSO 命中在栈,否则堆 |
std::string s = std::string(100,'x'); |
栈 | 字符:超出 SSO → 堆 |
std::vector<int> v; |
栈 | 元素:堆 |
std::array<int, 10> a; |
栈 | 元素:栈(内联) |
auto p = std::make_unique<Foo>(); |
p 在栈 |
Foo 在堆 |
auto sp = std::make_shared<Foo>(); |
sp 在栈 |
Foo + 控制块:堆 |
thread_local int t; |
TLS | --- |
"hello"(字面量) |
.rodata | --- |
| 类成员变量 | 跟外层对象 | 若成员自身是容器/智能指针,再间接到堆 |
9. 实用判断流程
当你拿到一行代码想问"它在哪?",按这三步走:
- 这个对象的"宿主"是谁?
- 局部变量 → 栈
- 全局 / static → 静态区
- 成员变量 → 追到最外层对象,重新从第 1 步开始
new/ 容器内部 → 堆
- 这个对象里有没有"指向堆的字段"? (比如内嵌
vector、长string、unique_ptr)
若有,那元数据在步骤 1 的位置,真正的数据在堆。 - 它是不是字面量 /
static const? 若是,多半在.rodata。
举例:std::vector<std::string> v; 里第 3 个 string 的字符在哪?
v是局部 → 在栈。v的元素数组是容器内部分配 → 堆上一段std::string[n]。- 第 3 个
string对象本身就在那段堆里。 - 它的字符呢?短串 → 就在那段堆内联 buffer 里;长串 → 另一块堆。
10. 小结
- 栈 vs 堆的区别不在"用了什么语法",而在"这个对象的存储期是什么"。
- 所有
new/malloc/ STL 容器内部分配的数据结构,都上堆。 - 局部变量、形参、临时对象、成员变量(跟随外层)------ 通常在栈。
- 全局/static/字面量 ------ 在静态区,跟栈堆无关。
- 混合最多的类型是
std::string / std::vector / std::function这类"对象内联 + 数据堆分配"的结构,看待它们时要把"外壳"和"数据"分开想。
一句话记忆:
栈管"作用域能活多久",堆管"你让我活多久",静态区管"全程都活"。