一、堆内存和栈内存到底有什么区别
先看最简单的代码:
void fun()
{
int a = 10;
int *p = new int(20);
delete p;
}
这里一般可以理解成:
栈:
┌──────────────┐
│ a = 10 │
├──────────────┤
│ p │──────┐
└──────────────┘ │
↓
堆:
┌──────────────┐
│ int = 20 │
└──────────────┘
注意一个非常重要的地方:
int *p = new int(20);
不是说:
p在堆上
而是:
p这个指针变量
↓
在栈上
p指向的int对象
↓
在堆上
因为:
p
本身是:
fun()中的局部变量
而:
new int(20)
才是在动态存储区申请的对象。
所以:
局部变量
↓
通常跟随函数栈帧存在
动态分配资源
↓
通常位于堆上
两者主要区别可以整理成:
| 对比 | 栈 | 堆 |
|---|---|---|
| 分配方式 | 通常自动管理 | 动态申请 |
| 生命周期 | 随作用域结束自动销毁 | 由资源管理方式决定 |
| 分配速度 | 通常较快 | 通常开销更高 |
| 空间大小 | 一般较小 | 通常更大 |
| 常见对象 | 局部变量 | new/malloc 的动态资源 |
| 管理方式 | 编译器/运行时维护 | 程序或分配器管理 |
例如:
void fun()
{
int a = 10;
double b = 20.0;
}
可以简单理解成:
fun()栈帧:
┌─────────┐
│ a │
├─────────┤
│ b │
└─────────┘
函数:
fun();
结束以后:
栈帧销毁
↓
a和b自动失效
但是:
void fun()
{
int *p = new int(10);
}
函数结束后:
p这个指针变量
↓
消失
但是:
new出来的int
↓
不会因为p离开作用域自动释放
如果没有:
delete p;
就可能产生:
内存泄漏
所以:
栈
↓
主要体现自动生命周期
堆
↓
主要用于动态生命周期和动态容量
不过现代 C++ 更推荐:
std::unique_ptr
std::shared_ptr
std::vector
std::string
通过 RAII 管理堆资源,而不是到处手动 new/delete。
二、什么情况下会发生栈溢出
栈空间不是无限大的。
每个线程通常都会拥有自己的:
线程栈
如果使用的栈空间超过限制,就可能出现:
Stack Overflow
栈溢出
1. 无限递归
最典型:
void fun()
{
fun();
}
调用过程:
fun()
↓
fun()
↓
fun()
↓
fun()
↓
...
每次函数调用都会创建新的:
栈帧
其中可能保存:
局部变量
函数参数
返回地址
寄存器状态
所以:
递归深度不断增加
↓
栈空间不断消耗
↓
最终超过栈大小
↓
Stack Overflow
即使不是无限递归:
void dfs(int n)
{
if (n == 0)
return;
dfs(n - 1);
}
如果:
dfs(10000000);
递归层数过深,同样可能栈溢出。
2. 定义特别大的局部数组
例如:
void fun()
{
int arr[10000000];
}
如果:
一个int = 4字节
那么大概:
10000000 × 4
≈ 40MB
这个数组作为局部自动对象,可能尝试占用很大的栈空间。
而线程栈可能只有几 MB。
于是:
局部数组太大
↓
直接栈溢出
这种场景更适合使用:
std::vector<int> arr(10000000);
为什么?
因为:
vector对象本身
↓
很小
真正10000000个int
↓
动态存储区
后面会详细讲。
3. 局部对象本身非常大
例如:
struct BigData
{
char data[10 * 1024 * 1024];
};
void fun()
{
BigData data;
}
这里:
BigData对象本身
≈ 10MB
如果它作为局部变量存在,就可能直接占用很大栈空间。
所以要区分:
std::vector<int> v(1000000);
和:
int arr[1000000];
虽然都能保存很多整数,但内存布局完全不同。
前者:
vector控制对象
↓
栈
大量元素
↓
堆
后者:
整个数组
↓
栈
这也是 STL 容器非常重要的特点之一。
三、std::string到底占栈还是堆
这是一个特别典型的面试问题。
例如:
void fun()
{
std::string str = "hello world";
}
有人会说:
string在堆上
也有人说:
string在栈上
其实都不够准确。
更准确的说法是:
str****这个 std::string****对象本身是局部变量,所以对象本身通常位于当前函数的栈帧中;但它管理的字符数据是否位于堆上,要看字符串长度和具体标准库实现。
可以先忽略优化,把 string 简化理解成:
class String
{
private:
char *data_;
size_t size_;
size_t capacity_;
};
那么:
std::string str = "hello world";
大致可以理解成:
栈:
str
┌────────────────┐
│ data_ │───────┐
│ size_ │ │
│ capacity_ │ │
└────────────────┘ │
↓
堆:
┌───────────────┐
│ hello world\0 │
└───────────────┘
所以:
string对象本身
↓
栈
字符串动态缓冲区
↓
通常在堆
但是这里还有一个重要优化:
SSO
Small String Optimization
小字符串优化
很多标准库实现对于非常短的字符串,不会马上申请堆内存。
而是把字符:
直接存进string对象内部
例如概念上可能类似:
class String
{
private:
size_t size_;
union
{
char small[16];
char *heapPtr;
};
};
当字符串比较短:
std::string str = "abc";
可能变成:
栈中的string对象:
┌───────────────────────┐
│ size = 3 │
│ "abc\0" │
└───────────────────────┘
也就是:
没有额外堆分配
如果字符串非常长:
std::string str =
"abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz";
超过 SSO 容量以后:
string对象
↓
栈
真正长字符串
↓
堆
所以面试中问:
std::string 是在栈还是堆?
最好回答:
如果
std::string是函数局部变量,那么 string 对象本身通常在栈上。它管理的字符数据是否需要堆分配取决于实现和字符串长度,较长字符串通常使用动态内存,而很多标准库对短字符串会使用 SSO,直接把字符存放在 string 对象内部。
不要简单回答:
string一定在堆上
四、std::string发生复制拷贝以后,内存怎么变化
假设:
void fun()
{
std::string a = "abcdefghijklmnopqrstuvwxyz";
std::string b = a;
}
这里:
a
b
都是函数内部局部对象。
所以:
a对象
↓
栈
b对象
↓
栈
如果字符串足够长,需要动态内存,那么可以简化理解为:
栈:
a
┌──────────┐
│ ptr ───────────────┐
└──────────┘ │
↓
堆:
┌──────────────────┐
│ abcdefghijkl... │
└──────────────────┘
b
┌──────────┐
│ ptr ───────────────┐
└──────────┘ │
↓
堆:
┌──────────────────┐
│ abcdefghijkl... │
└──────────────────┘
通常:
a和b拥有各自独立的字符串内容
也就是说:
std::string b = a;
是:
拷贝语义
修改:
b[0] = 'X';
不会把:
a[0]
一起改掉。
所以对于长字符串,拷贝一般意味着:
新的string对象
+
新的字符缓冲区
+
复制字符数据
因此大字符串的频繁拷贝可能有性能开销。
如果改成:
std::string b = std::move(a);
就是:
移动
对于使用动态缓冲区的字符串,实现通常可以把:
a原来管理的堆资源
转移给:
b
概念上:
移动前:
a ─────→ "abcdefghijklmnopqrstuvwxyz"
b
移动后:
a
↓
合法但状态未指定
b ─────→ 原来的字符串缓冲区
通常不需要把整个字符串重新复制一遍。
这也是移动语义的意义。
不过如果字符串使用:
SSO
那么小字符串数据本来就在对象内部,实际移动过程可能仍然需要复制那几个小字符。
所以性能上:
move一定是简单交换一个指针
也不能绝对这么说。
五、vector对象和push_back进去的对象到底存在哪
这也是非常重要的一部分。
假设:
void fun()
{
std::vector<int> a;
a.push_back(10);
a.push_back(20);
a.push_back(30);
}
首先:
a
是:
fun()中的局部变量
所以:
vector对象本身
↓
通常位于栈上
vector 本身可以简化理解成:
class Vector
{
private:
int *start_;
int *finish_;
int *end_;
};
所以:
栈:
a
┌──────────────────┐
│ start_ │──────┐
│ finish_ │ │
│ end_ │ │
└──────────────────┘ │
↓
堆:
┌────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ │
└────┴────┴────┴────┘
也就是说:
vector对象本身
↓
栈
vector内部动态数组
↓
堆
这就是:
std::vector<int> a;
最需要理解的地方。
再看一个对象类型:
class Student
{
public:
int id;
double score;
};
然后:
void fun()
{
std::vector<Student> students;
Student s;
s.id = 1;
s.score = 90;
students.push_back(s);
}
此时:
s
↓
局部对象
↓
栈
而:
students
↓
vector对象
↓
栈
但是:
students.push_back(s);
会把一个 Student 元素构造到:
vector管理的动态数组
里面。
也就是:
栈:
┌──────────────────────┐
│ Student s │
│ id = 1 │
│ score = 90 │
└──────────────────────┘
┌──────────────────────┐
│ vector students │───────┐
│ start │ │
│ finish │ │
│ end │ │
└──────────────────────┘ │
↓
堆:
┌──────────────────────────┐
│ Student │
│ id = 1 │
│ score = 90 │
└──────────────────────────┘
所以会有:
两个Student对象
一个:
局部变量s
↓
栈
另一个:
vector中的元素
↓
vector动态存储区
↓
通常在堆
执行:
students.push_back(s);
因为:
s
是左值,所以一般会调用:
Student拷贝构造
将它复制到 vector 的动态空间中。
如果:
students.push_back(std::move(s));
则可能调用:
Student移动构造
如果直接:
students.emplace_back();
则可以:
直接在vector内部存储区域构造对象
再来看:
void fun()
{
std::vector<Student> a;
a.push_back(Student{1, 90});
}
其中临时:
Student{1, 90}
会被移动或构造进 vector 的元素存储区。
最终长期留下的是:
vector内部的Student元素
↓
动态存储区
而不是说:
vector里只存了一个指向栈对象的指针
vector<Student> 存的是:
Student对象本身
这一点很重要。
但是如果定义:
std::vector<Student *> students;
情况就不同了。
vector 的动态数组里面保存的是:
Student*
也就是指针。
例如:
Student s;
students.push_back(&s);
布局:
栈:
Student s
↑
│
│
vector对象
│
↓
堆中的vector数组:
┌───────────────┐
│ Student* ─────┘
└───────────────┘
这里:
vector内部保存的是指针
真正的 Student:
仍然在栈上
如果这样:
students.push_back(new Student);
那么:
vector对象
↓
栈
vector保存指针的数组
↓
堆
new Student
↓
也是堆
结构:
栈:
vector
│
↓
堆区域1:
┌──────────────┐
│ Student* │──────┐
└──────────────┘ │
↓
堆区域2:
┌──────────────┐
│ Student对象 │
└──────────────┘
因此一定要看:
vector<T>
中的:
T到底是什么
如果:
vector<Student>
存的是:
Student对象本身
如果:
vector<Student *>
存的是:
Student指针
如果:
vector<std::unique_ptr<Student>>
存的是:
unique_ptr对象
这些 unique_ptr 位于 vector 的动态存储区,而它们所管理的 Student 通常又是另一块动态分配内存。
最后把最容易混淆的几个情况放到一起:
void fun()
{
int a = 10;
int *p = new int(20);
std::string str = "hello";
std::vector<int> nums;
nums.push_back(100);
}
可以概念性地画成:
fun()栈帧
┌────────────────────────────┐
│ int a = 10 │
├────────────────────────────┤
│ int* p ─────────────────────────────┐
├────────────────────────────┤ │
│ std::string str │ │
│ 可能直接SSO存hello │ │
├────────────────────────────┤ │
│ std::vector<int> nums │───┐ │
│ start / finish / end │ │ │
└────────────────────────────┘ │ │
│ │
↓ ↓
堆内存
┌───────────┐
│ 100 │
└───────────┘
┌───────────┐
│ int = 20 │
└───────────┘
如果 str 很长:
栈中的string对象
│
↓
堆中的字符缓冲区
面试如果问:
堆和栈有什么区别?
可以回答:
栈通常用于保存函数调用栈帧和局部自动对象,由系统随着作用域自动管理,分配释放速度较快但空间有限;堆主要用于动态分配,容量通常更大,生命周期可以跨越函数作用域,但需要通过
delete/free或 RAII 对象正确管理,分配释放的成本通常也高于栈。
如果问:
什么情况下会栈溢出?
可以回答:
最常见的是递归层数过深或者在函数中定义特别大的局部数组、局部对象。因为每个线程的栈空间有限,函数栈帧不断累积或者单个栈帧占用过大,都可能导致 Stack Overflow。
如果问:
std::string在栈还是堆?
可以回答:
如果 string 是局部变量,那么 string 对象本身通常在栈上。对于较长字符串,它管理的字符缓冲区通常动态分配;但很多标准库存在 SSO,小字符串可以直接存放在 string 对象内部,因此不一定发生堆分配。
如果问:
string复制一份以后呢?
可以回答:
两个局部 string 对象本身都在各自的栈位置。发生普通拷贝时,从语义上看两个 string 拥有各自独立的内容;如果字符串需要动态存储,通常会分别管理自己的动态缓冲区。移动构造则可能直接转移原有动态缓冲区的所有权,减少大数据复制。
如果问:
void fun(){ std::vector<int> a; a.push_back(10); }中内存怎么分布?
可以回答:
a是局部 vector 对象,因此 vector 对象本身通常位于fun的栈帧中,内部主要维护指向元素存储区的指针或类似状态。push_back(10)后,真正的 int 元素通常存放在 vector 动态申请的连续内存中,也就是通常所说的堆内存。当 vector 扩容时,会重新申请更大的动态存储区,并把原有元素移动或拷贝过去。
最核心的理解可以总结为:
局部STL对象
不等于
所有数据都在栈上
例如:
std::string
std::vector
本身只是:
一个管理对象
真正的大量数据通常由它们内部:
动态管理
所以判断一个对象到底占用哪里的内存时,应该分成两层:
第一层
↓
对象本身放在哪里?
第二层
↓
对象内部管理的资源放在哪里?
只要把这两层分开,string、vector、智能指针以及各种动态容器的内存布局就不会容易混淆。