【C++面试】堆内存与栈内存:string、vector和对象到底存在哪里

一、堆内存和栈内存到底有什么区别

先看最简单的代码:

复制代码
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、智能指针以及各种动态容器的内存布局就不会容易混淆。

0voice · GitHub

相关推荐
谢亮_vipxieliang1 小时前
Go defer、panic与recover核心知识点
开发语言·后端·golang
xlq223221 小时前
全面复习3
c++
lcj25111 小时前
【Linux】 Makefile 详解
linux·笔记·面试
迪康Defender1 小时前
终端安全事后追溯能力解析:本地审计模块设计思路与落地实践
运维·开发语言·人工智能·安全·php·安全威胁分析·运维开发
(Charon)1 小时前
【C++面试】迭代器失效是什么?vector、list、map中如何避免迭代器失效
c++·算法·面试
yujunl1 小时前
开发一个可以用APIFOX来调试的接口
开发语言
怕浪猫1 小时前
面试官让我手写一个 Tool Use,我用了 3 种方案
设计模式·面试·程序员
苏辰澈2 小时前
C++自己的族谱规则:继承详解下
c语言·开发语言·c++·visualstudio
hunter1990102 小时前
英语作文题目
开发语言