面试问题:c++的内存管理方式,delete的使用,vector的resize和reverse,容量拓展
目录
c++的内存管理方式
内存分区框架
内存管理机制
delete的使用
vector中的一些知识
普通数组和 vector 的区别
vector中的容量拓展机制
1. 申请更大的内存空间
2. 复制/移动旧数据
3. 释放旧内存
4. 更新内部指针和容量
为什么不每次只增加一点点容量?
面试简洁版回答
vector中的resize和reserve
vector中的emplace_back VS push_back
迭代器失效问题
类的成员函数调用顺序的问题
c++的内存管理方式
内存分区框架
C++ 程序运行时,内存一般分为几个区域:
栈(stack)
- 局部变量、函数参数
- 生命周期:自动分配、自动释放(函数结束时销毁)
- 管理方式:由编译器自动完成
堆(heap)
- 需要程序员手动申请与释放 (new/delete, malloc/free)
- 生命周期:由开发者控制,易出错(内存泄漏、悬空指针)
全局/静态区(data segment)
- 存放全局变量、静态变量
- 生命周期:整个程序运行期间
常量区
- 存放字符串常量、const 全局常量等
- 生命周期:整个程序运行期间
代码区(text segment)
存放程序的可执行指令
内存管理机制
C++的内存管理主要分为两种方式:自动内存管理和动态内存管理。
-
自动内存管理:由编译器在程序编译时或运行时自动处理,无需程序员干预。
-
动态内存管理:需要程序员手动分配和释放内存。
栈内存(Stack)
这是自动内存管理的主要形式。
分配方式:当函数被调用时,其局部变量(包括基本类型、对象和指针)的内存在栈上自动分配。
特点:分配和释放速度非常快,因为这是一个“LIFO”(后进先出)结构,类似于栈。
生命周期:与变量的作用域绑定,一旦变量超出其作用域(如函数返回),内存会自动被回收。
缺点:大小有限,如果分配过多可能导致栈溢出(Stack Overflow)。
堆内存(Heap)
这是动态内存管理的主要区域。
分配方式:使用
new
运算符手动分配,new
会返回一个指向新分配内存的指针。特点:内存空间更大,可以动态调整大小,不受作用域限制。
生命周期:从
new
成功分配后开始,直到程序员使用delete
运算符手动释放为止。缺点:
需要手动管理:如果忘记使用
delete
释放,会导致内存泄漏(Memory Leak)。悬垂指针(Dangling Pointer):内存释放后,指针仍然指向该地址,如果继续使用,可能导致程序崩溃或不可预测的行为。
重复释放:对同一块内存重复使用
delete
,可能导致未定义行为。
静态/全局内存
除了栈和堆,静态和全局变量的内存管理如下:
分配方式:在程序启动时分配,直到程序结束时才释放。
生命周期:贯穿整个程序的执行周期。
现代C++的内存管理(智能指针)
智能指针(如 std::unique_ptr 和 std::shared_ptr)是C++11引入的,它们是封装了裸指针的类,可以自动管理动态分配的内存。
它们利用了RAII 机制。当智能指针对象超出作用域时,其析构函数会自动调用 delete 释放内存,从而有效避免了内存泄漏。
好处:
- 显著简化了内存管理。
- 降低了内存泄漏和悬垂指针等问题的风险。
delete的使用
✅ 安全用法(推荐习惯)
1. 用 delete / delete[] 与 new / new[] 配对
int* p = new int;
delete p; // 正确int* arr = new int[10];
delete[] arr; // 正确
2. 对 nullptr 调用 delete 安全无害!!!
int* p = nullptr;
delete p; // 什么也不会发生
delete[] p; // 也安全
3. delete 后置空,避免悬空指针(这个习惯一定要养成)
delete p;
p = nullptr; // 避免重复 delete 或误用
❌ 常见误区
1. 忘记区分 delete 和 delete[]
int* arr = new int[10];
delete arr; // ❌ 未定义行为
delete[] arr; // ✅ 正确
2. 对非 new 内存使用 delete
int x;
int* p = &x;
delete p; // ❌ 未定义行为
3. 混用 malloc/free 和 new/delete
int* p = (int*)malloc(sizeof(int));
delete p; // ❌ UB,应该 free(p)int* q = new int;
free(q); // ❌ UB,应该 delete q
4. 重复释放(double delete)
int* p = new int;
delete p;
delete p; // ❌ 未定义行为(悬空指针)
vector中的一些知识
普通数组和 vector 的区别
内存管理
- 普通数组: 内存是在栈上或堆上静态或动态分配的。如果是动态分配(new),需要手动管理内存。例如,int arr[10]; 的内存在栈上自动分配和释放;int* arr = new int[10]; 则需要在堆上手动分配和用 delete[] 手动释放。
- std::vector: 自动管理内存。它会在堆上动态分配内存,但其内部机制会自动处理内存的增长、收缩和释放。你无需关心 delete 操作,这有效避免了内存泄漏的风险。
大小和容量
- 普通数组: 大小是固定的,在声明时就确定了,且不能改变。
- std::vector: 大小是可变的。你可以随时向其中添加或删除元素。当现有容量不足时,vector 会自动分配一块更大的内存,并将旧数据迁移过去。你可以通过 size() 获取元素数量,通过 capacity() 获取当前分配的内存容量。
功能和操作
- 普通数组: 只是一个内存块,提供的操作非常有限。你只能通过索引访问元素。
- std::vector: 是一个强大的类模板,提供丰富的成员函数来操作数据。例如:
- 增删改查: push_back()、pop_back()、insert()、erase() 等。
- 迭代器: 支持迭代器,可以方便地与标准库算法(如 std::sort、std::find)配合使用。
vector中的容量拓展机制
当向 vector 中添加元素(比如通过 push_back())导致其当前元素数量(size)超过了当前分配的内存容量(capacity)时,vector 就会触发容量拓展。
这个过程主要包括以下几个步骤:
1. 申请更大的内存空间
vector 首先会向堆内存申请一块新的、更大的连续内存空间。通常,新容量是旧容量的 1.5 倍或 2 倍(具体倍数取决于不同的编译器实现,但通常是翻倍策略)。例如,如果旧容量是 10,新容量可能会是 20。
2. 复制/移动旧数据
接下来,vector 会将旧内存空间中的所有元素,逐一复制(copy)或移动(move)到新分配的内存空间中。
-
复制(Copy):在 C++11 之前,或对于没有移动构造函数的对象,会调用对象的复制构造函数来完成数据的迁移。
-
移动(Move):从 C++11 开始,如果对象支持移动语义,
vector
会优先使用移动构造函数。移动操作通常只涉及指针的重新赋值,因此比复制操作更高效。
3. 释放旧内存
数据迁移完成后,vector 会释放旧的内存空间。
4. 更新内部指针和容量
最后,vector 会更新其内部指向内存的指针,并更新其 capacity 值为新申请的空间大小。
为什么不每次只增加一点点容量?
你可能会想,为什么 vector
不每次只增加一个元素的空间?这是因为内存的重新分配和数据复制/移动操作都是有性能开销的。如果每次都只增加一点点,那么频繁的 push_back()
就会导致频繁的内存重新分配,使得性能变得非常差。
通过翻倍策略(或其他指数增长策略),vector
可以在均摊时间复杂度上达到O(1) 的性能。这意味着,虽然偶尔的扩容操作会很慢,但在长序列的添加操作中,平均下来,每次添加一个元素的时间开销是恒定的。这是一种以空间换取时间的典型设计。
面试简洁版回答
👉 vector 的扩容机制是按几何倍数(通常 1.5 倍或 2 倍)增长容量:当大小超过容量时,会重新分配一块更大的连续内存,将旧数据拷贝/移动过去,然后释放旧内存。这样保证了 push_back 的均摊复杂度是 O(1),但扩容会触发一次 O(n) 的整体搬迁。实际开发中,可以用 reserve 预分配来减少扩容开销。
vector中的resize和reserve
capacity():当前分配的容量
reserve(n):预分配容量,避免频繁扩容
resize(n):改变大小,多余元素被销毁,新增元素默认初始化
功能和目的
resize():用于改变 vector 中元素的数量(size)。
- 如果新大小大于当前大小,vector 会插入新元素,并用默认值或指定的值进行初始化。
- 如果新大小小于当前大小,vector 会删除多余的元素。
reserve():用于改变 vector 的容量(capacity),而不是元素的数量。它只是预留内存空间,但并不会改变 size 的值,也不会创建任何新的对象
对 size() 和 capacity() 的影响
resize(n):
- size() 变为 n。
- capacity() 可能会增加,但永远不会减少。如果 n > capacity(),vector 会扩容以满足新大小的需求。
reserve(n):
- size() 保持不变。
- capacity() 会变为至少 n。如果 n > capacity(),vector 会重新分配内存,使得 capacity() 变为 n 或一个更大的值。如果 n <= capacity(),则什么也不会发生。
resize 和 reserve 的核心区别在于:
resize 是改变实际存储的元素数量,
而 reserve 是改变底层分配的内存大小。
你可以用一个简单的比喻来帮助记忆:
resize 就像调整一个房间里的人数,人多了就请进来,人少了就请出去。
reserve 就像提前租一个更大的房间,但房间里的人数暂时不变。
使用场景
resize():当你需要精确地确定 vector 中元素的个数,并希望这些元素被初始化时,使用 resize()。例如,你需要一个包含 10 个零的 vector,你可以使用 vector<int> v; v.resize(10, 0);。
reserve():当你预先知道将要向 vector 中添加大量元素,但你还不确定具体的元素值时,使用 reserve()。这可以避免多次不必要的内存重新分配,从而提高性能。例如,如果你需要从文件中读取 1000 行数据,可以先 v.reserve(1000);,然后循环 push_back()。
vector中的emplace_back VS push_back
在 C++11 标准之前,我们只能使用 push_back() 来向 vector 末尾添加元素。C++11的emplace 相关的成员函数(如emplace_back()提供了一种更高效的方式来向vector 末尾添加元素)
push_back():先创建后复制/移动
push_back() 的工作流程是这样的:
- 在外部构造一个临时对象。
- 将这个临时对象通过拷贝构造或移动构造的方式,添加到 vector 的末尾。
- 简单来说,push_back() 需要一个已经存在的对象作为参数。
emplace_back() 的工作流程更直接:
- 它接收的参数是构造新元素所需的参数。
- 它会直接在 vector 内部的内存空间上构造这个新元素,避免了不必要的临时对象的创建和随后的拷贝/移动操作。
核心区别:创建方式
- push_back():拷贝/移动一个对象到 vector。
- emplace_back():就地构造一个对象在 vector 中。
使用建议:
对于大多数情况,尤其是当你的元素类型是复杂的对象时,emplace_back() 的性能通常优于 push_back()。因为它减少了一次临时对象的创建和销毁,以及一次拷贝或移动的开销。
对于基本数据类型(如 int 或 double),push_back() 和 emplace_back() 的性能几乎没有区别,因为拷贝这些简单类型非常快。
因此,作为 C++ 程序员的最佳实践,在添加新元素时,优先考虑使用 emplace_back()。
迭代器失效问题
在 vector 中,迭代器失效主要有两类原因:
1. 导致容量变化的写操作:push_back()、insert()、emplace()、resize() 和 clear() 都可能导致迭代器失效,因为它们可能触发扩容。当 vector
发生扩容后,所有指向其内部元素的普通指针、引用和迭代器都会失效。
2. 删除操作:erase()、pop_back()。erase() 会使所有指向被删除元素之后(包括被删除元素本身)的迭代器失效。pop_back() 会使指向被删除元素的迭代器失效。
良好的编程习惯
1.尽量避免扩容,预先估计规模并 reserve(n):
2. 尽量避免在持有迭代器时做可能触发重分配的操作,比如:避免拿着迭代器遍历时又 push_back/insert/resize。
3. 如果需要保存一些迭代器,在使用迭代器执行了删除操作之后,及时更新迭代器。
类的成员函数调用顺序的问题
构造函数调用顺序:
当创建一个派生类(子类)的对象时,构造函数的调用顺序是:
- 先调用父类(基类)的构造函数。 如果存在多层继承,则从最顶层的基类开始,依次调用到直接父类的构造函数。
- 然后调用成员对象(如果存在)的构造函数。 按照成员对象在类定义中的声明顺序进行调用。
- 最后调用子类自身的构造函数体。
简单来说,构造顺序是:基类 -> 成员对象 -> 派生类自身。
析构函数调用顺序:
当销毁一个派生类(子类)的对象时,析构函数的调用顺序与构造函数的调用顺序完全相反:
- 先调用子类自身的析构函数体。
- 然后调用成员对象(如果存在)的析构函数。 按照成员对象在类定义中声明顺序的逆序进行调用。
- 最后调用父类(基类)的析构函数。 如果存在多层继承,则从直接父类开始,依次调用到最顶层的基类的析构函数。
简单来说,析构顺序是:派生类自身 -> 成员对象 -> 基类。
具体解释可以看:
c++中的构造函数&析构函数调用顺序-CSDN博客