面试问题:c++的内存管理方式,delete的使用,vector的resize和reverse,容量拓展

目录

c++的内存管理方式

内存分区框架

内存管理机制

delete的使用

vector中的一些知识

[普通数组和 vector 的区别](#普通数组和 vector 的区别)

vector中的容量拓展机制

[1. 申请更大的内存空间](#1. 申请更大的内存空间)

[2. 复制/移动旧数据](#2. 复制/移动旧数据)

[3. 释放旧内存](#3. 释放旧内存)

[4. 更新内部指针和容量](#4. 更新内部指针和容量)

为什么不每次只增加一点点容量?

面试简洁版回答

vector中的resize和reserve

[vector中的emplace_back VS push_back](#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[] 配对
cpp 复制代码
int* p = new int;
delete p;          // 正确

int* arr = new int[10];
delete[] arr;      // 正确
  1. 对 nullptr 调用 delete 安全无害!!!
cpp 复制代码
int* p = nullptr;
delete p;      // 什么也不会发生
delete[] p;    // 也安全
  1. delete 后置空,避免悬空指针(这个习惯一定要养成)
cpp 复制代码
delete p;
p = nullptr;   // 避免重复 delete 或误用

❌ 常见误区

  1. 忘记区分 delete 和 delete[]
cpp 复制代码
int* arr = new int[10];
delete arr;      // ❌ 未定义行为
delete[] arr;    // ✅ 正确
  1. 对非 new 内存使用 delete
cpp 复制代码
int x;
int* p = &x;
delete p;   // ❌ 未定义行为
  1. 混用 malloc/free 和 new/delete
cpp 复制代码
int* p = (int*)malloc(sizeof(int));
delete p;     // ❌ UB,应该 free(p)

int* q = new int;
free(q);      // ❌ UB,应该 delete q
  1. 重复释放(double delete)
cpp 复制代码
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):

  1. 尽量避免在持有迭代器时做可能触发重分配的操作,比如:避免拿着迭代器遍历时又 push_back/insert/resize。

  2. 如果需要保存一些迭代器,在使用迭代器执行了删除操作之后,及时更新迭代器。

类的成员函数调用顺序的问题

构造函数调用顺序:

当创建一个派生类(子类)的对象时,构造函数的调用顺序是:

  1. 先调用父类(基类)的构造函数。 如果存在多层继承,则从最顶层的基类开始,依次调用到直接父类的构造函数。
  2. 然后调用成员对象(如果存在)的构造函数。 按照成员对象在类定义中的声明顺序进行调用。
  3. 最后调用子类自身的构造函数体。

简单来说,构造顺序是:基类 -> 成员对象 -> 派生类自身。

析构函数调用顺序:

当销毁一个派生类(子类)的对象时,析构函数的调用顺序与构造函数的调用顺序完全相反:

  1. 先调用子类自身的析构函数体。
  2. 然后调用成员对象(如果存在)的析构函数。 按照成员对象在类定义中声明顺序的逆序进行调用。
  3. 最后调用父类(基类)的析构函数。 如果存在多层继承,则从直接父类开始,依次调用到最顶层的基类的析构函数。

简单来说,析构顺序是:派生类自身 -> 成员对象 -> 基类。

具体解释可以看:

c++中的构造函数&析构函数调用顺序-CSDN博客

相关推荐
南北是北北4 小时前
为什么会出现有声无画/黑屏,以及如何避免与优化
前端·面试
南北是北北4 小时前
VSync 是什么、ExoPlayer 怎么对齐 VSync 与音画同步、常见问题与调参要点
前端·面试
前端fighter4 小时前
前端开发中的模块化:从 CommonJS 到 ES6 模块
前端·javascript·面试
hansang_IR4 小时前
【题解】洛谷P1776 宝物筛选 [单调队列优化多重背包]
c++·算法·动态规划·题解·背包·多重背包·单调队列
jndingxin4 小时前
c++多线程(1)------创建和管理线程td::thread
开发语言·c++·算法
SuperCandyXu4 小时前
洛谷 P3128 [USACO15DEC] Max Flow P -普及+/提高
c++·算法·图论·洛谷
凝孑·哒哒哒4 小时前
从一道面试题开始:如何让同时启动的线程按顺序执行?
java·开发语言·面试
hqiangtai5 小时前
面试复习题-Flutter
flutter·面试·职场和发展
拾光Ծ5 小时前
【STL】C++ 开发者必学字符类详解析:std::string
开发语言·c++