C++的诗行:一文掌握内存管理中 new/delete 接口正确调用与常见场景适配



目录
一、C++中的动态内存管理
1.1 new与delete
1.1.1 内置类型
1.1.2 自定义类型(分配空间+构造对象)
a、单个对象的new
b、数组对象的new[ ]
c、单个对象的delete
d、数组对象的delete[ ]
1.1.3 new失败的情况
1.2 new与delete的底层原理
1.2.1 operator new与operator delete
1.3 经典的面试题
1.4 定位new(了解)
一、C++中的动态内存管理
C++ 动态内存管理是指程序在运行时手动分配和释放内存的机制,核心工具是new和delete运算符
1.1 new与delete
new负责在堆上分配内存并初始化对象,delete负责释放内存并销毁对象,二者必须成对使用,是动态内存管理的基础,两者的关系类似于C语言中malloc与free之间的关系。
与C语言中malloc不同的是,new除了向堆开辟一段内存空间外还支持相应的初始化操作;delete也类似除了释放内存空间外还会根据情况自动调用相应的析构函数。
new与delete操作内置类型与自定义类型时分别会有不同的表现,下面我们来详细介绍一下:
1.1.1 内置类型
new单个内置类型的操作主要会分为以下三种情况:
- 默认初始化(不赋初值):
Type* ptr = new Type; - 值初始化(置 0):
Type* ptr = new Type(); - 显式初始化(直接赋予初始值):
Type* ptr = new Type(初始化参数);
int* p1 = new int; // 内置类型默认初始化,p1 指向的内存值随机
int* p2 = new int(); // 内置类型值初始化,p2 指向的内存值为 0
int* p3 = new int(10); // 显式初始化,p3 指向的内存值为 10
除了new单个对象外,还支持同时分配连续数组内存,对应的操作是new type[ 数组元素个数 ]分为以下情况:
a、不做初始化,数组中的初始值随机
语法:Type* arr = new Type[N];(N 为数组长度)
- 内置类型的数组元素不会被初始化,内存中是随机值(取决于该内存块之前的使用情况)。
- 本质是只分配内存,不执行任何初始化操作,效率最高,但元素值不可预测。
int* arr = new int[3]; // 分配3个int的连续内存,元素值随机(可能是垃圾值)
// 此时 arr[0]、arr[1]、arr[2] 的值不确定
b、全初始化为默认值
语法:Type* arr = new Type[N]();(在数组长度后加 ())
- 内置类型的数组元素会被强制初始化为零(数值类型为
0,指针类型为nullptr,bool为false等)。 - 适用于需要确保初始值为零的场景,会额外执行初始化操作,效率略低于默认初始化。
int* arr = new int[3](); // 分配3个int的连续内存,元素均初始化为0
// 此时 arr[0] = 0,arr[1] = 0,arr[2] = 0double* dArr = new double[2](); // 元素均为 0.0
char* cArr = new char[4](); // 元素均为 '\0'(ASCII码0)
c、手动初始化(完全初始化&部分初始化)
当我们new一个数组后也可以对其进行手动初始化,一种方法是后续通过遍历数组依次对元素进行初始化,C++11 允许直接在定义的时候利用初始化列表对数组元素进行初始化这属于列表初始化,而非new[ ]本身的特性:
int* arr = new int[3]{1, 2, 3}; // C++11及以上支持,元素分别为1、2、3
int* arr2 = new int[5]{10}; // 第一个元素为10,剩余元素值初始化(为0)
new操作的对象是堆内存,而堆内存不会编译器自动管理,需手动释放。若不调用delete,这块内存会一直被占用,直到程序结束(对于长期运行的程序,会逐渐耗尽内存)。
因为对于内置类型来讲没有存在析构操作一讲,所以delete操作就是释放堆内存本身而已而非需要调用什么析构操作,delete操作内置类型时分为以下两种情况:
a、单个内置类型:new 对应delete
int* p = new int(10); // 堆上分配单个int
// 使用...
delete p; // 必须释放,否则内存泄漏
p = nullptr; // 建议置空,避免野指针
b、内置类型数组:new[ ] 对应delete[ ]
int* arr = new int[5]; // 堆上分配int数组
// 使用...
delete[] arr; // 必须用delete[]释放数组,否则内存泄漏
arr = nullptr;
1.1.2 自定义类型(分配空间+构造对象)
对于new来讲在操作自定义类型时会主要围绕:分配空间+构造对象两部分来进行,以下则对所有可能情况进行总结和讨论:
a、单个对象的new
语法:类名* ptr = new 类名(构造参数);
- 若省略构造参数,会调用默认构造函数(无参构造或全缺省构造),如果没有则会初始化为默认值(数值为0,指针为nullptr)
- 若构造参数非空,会调用对应参数的构造函数(匹配参数类型和数量)如果没有就报错。


需要注意的是,这里还存在一种可能的情况:
语法:类名* ptr = new 类名;
也就是在类名后面不加(),此时编译器会第一时间确定该自定义类型是否存在默认构造函数(无参构造或者全缺省构造),如果有则会调用默认构造函数对对象进行初始化此时的行为完全等同于类名* ptr = new 类名():

如果用户未显式定义默认构造函数(无参构造或者全缺省构造)其成员变量则会是随机的垃圾值。

b、数组对象的new[ ]
语法:类名* arr = new 类名[N];
此时会为每个元素调用默认构造函数(无参构造或全缺省构造),如果该类类型没有默认构造函数则会将其中的成员变量初始化为垃圾值(包括指针成员)。

语法:类名* arr = new 类名[N]();
会为每个元素调用默认构造函数(无参构造或全缺省构造),如果该类类型没有默认构造函数则会将其中的成员变量初始化为默认值(数值为 0,指针为 nullptr)

语法:类名* arr = new 类名[N]{参数1,参数2,参数3......};
此时如果假设参数的总数为M(参数1+参数2+参数3......),如果M=N则会调用N次对应的带参构造函数(如果没有就报错);如果M<N则会调用M次对应的带参构造函数,再调用N-M次默认构造函数初始化剩余的元素,此时如果没有默认构造函数就会报错。


delete操作自定义类型的核心行为是先销毁对象(调用析构函数),再释放内存,这与自定义类型通常持有资源(如动态内存、文件句柄等)的特性紧密相关,是避免资源泄漏的关键步骤。
c、单个对象的delete
delete对自定义类型单个对象的处理分为两步,首先会调用该类类型的析构函数确保资源先清理然后再释放内存:
#include <iostream>
class MyClass {
public:int* data; // 持有动态资源// 构造函数:分配资源MyClass(int val) {data = new int(val); // 分配堆内存std::cout << "构造函数:分配 data = " << *data << "\n";}// 析构函数:清理资源~MyClass() {std::cout << "析构函数:释放 data = " << *data << "\n";delete data; // 释放动态资源}
};// 使用 new 创建对象
MyClass* obj = new MyClass(100); // 调用构造函数,分配 data
// ... 使用对象 ...
delete obj; // 先调用析构函数(释放 data),再释放 obj 本身的内存
obj = nullptr; // 建议置空,避免野指针
析构函数的调用是自动的,无需手动触发,这是delete相比free(C 语言)的核心优势(free 仅释放内存,不调用析构函数)。在这里若不调用delete,析构函数不会执行,data指向的内存会泄漏(即使程序结束,长期运行的程序会逐渐耗尽内存)。
d、数组对象的delete[ ]
对于new[ ]创建的自定义类型数组,必须用delete[ ]释放,其行为是:
- 为数组中的每个元素调用析构函数(从最后一个元素到第一个,顺序与构造相反);
- 再释放整块连续内存。
// 创建包含2个元素的数组
MyClass* arr = new MyClass[2]{10, 20}; // 调用2次构造函数
// ... 使用数组 ...
delete[] arr; // 先为2个元素调用析构函数,再释放内存
arr = nullptr;
注意:如果使用delete释放new[ ]开辟的对象数组,此时会只释放第一个元素后面的元素未被释放会造成内存泄漏。
1.1.3 new失败的情况
当我们new的空间太大内存不足以分配时就会失败,常见的情况是抛出std::bad_alloc 异常(定义在 <new> 头文件中)。此时需要我们捕获异常(使用try catch语句)否则就会导致程序崩溃:

1.2 new与delete的底层原理
1.2.1 operator new与operator delete
经过上述的讨论后我们了解到,对于自定义类型来讲调用new时会分成两个主要步骤来进行:
- 在堆上开辟空间
- 调用构造函数初始化
调用构造函数我们比较容易理解,这里的开辟空间是如何实现的呢?难道是new自己本身创立了一套开辟空间的接口吗?其实这里开辟空间的操作主要是调用operator new来实现的,下面我们来介绍一下operator new。
operator new是一个全局函数(或类的静态成员函数),作用是向系统申请原始内存(未初始化的字节块),与malloc类似,但行为更符合 C++ 特性。

在operator new中会通过malloc开辟空间,如果malloc开辟失败则会直接抛异常否则会将开辟后内存空间的起始地址返回给new。
delete接口的底层原理也类似,首先delete会调用析构函数清理资源之后会调用operator delete来释放内存,operator delete的源码实例如下:

所以new、delete、operator new、operator delete、malloc、free的关系可以总结为一种层次关系:
- new->operator new->malloc完成空间开辟
- delete->operator delete->free完成空间的释放
那为什么new不直接调用malloc完成空间开辟?delete不直接调用free完成空间释放?
new/delete不直接使用malloc/free,本质是 C++ 为了适配面向对象特性(构造 / 析构调用)。C++要求new失败时必须抛出std::bad_alloc 异常,而malloc失败后则会返回空指针。所以在这方面讲,operator new/operator delete就是对malloc/free的再封装使其满足C++的面向对象编程的特性和灵活扩展的需求。new/delete运算符在此基础上,增加了构造 / 析构函数的自动调用,完成对象从创建到销毁的完整生命周期管理。
1.3 经典的面试题
场景一:
int main()
{int* arr = new int[10];free(arr);//或为delete(arr)return 0;
}
在理解底层原理后我们来看一下这段代码是否符合要求,会不会导致报错或崩溃呢?
对于内置类型来讲不存在所谓的构造与析构一说,所以delete[ ]与delete/free的操作时一致的,就是简单地将开辟的内存空间释放。所以在这里并不会导致报错或崩溃,只有一个警告(但我们的操作并不会导致什么问题)。

场景二:
class A
{
public:A(int a=10):value(a){cout << "A()" << std::endl;}~A(){cout << "~A()" << std::endl;}int value;
};
int main()
{A* arr = new A;free(arr);return 0;
}
运行结果:

对于new单个自定义类型对象来讲,如果不使用delete而是使用free会导致析构函数不会调用,如果该自定义类型中存在等待释放的资源那么就会造成内存泄漏。对于该示例来讲,A中只有内置类型的成员变量不存在待释放的资源所以本质上不会造成内存泄漏。所以对于是否造成内存泄漏我们应该根据代码的行为进行合理的判断。
场景三:
class B
{
public:int value1;int value2;
};
int main()
{B* arr = new B[10];free(arr);return 0;
}
运行结果:

令我们感到惊讶的是,这段代码居然也不会发生崩溃。我们来解释一下:
代码中我们只是定义了一个自定义类型B,其中并未显式地定义构造函数与析构函数。那么此时当我们用“合理”地方式定义和释放(使用new[ ]和delete[ ])对象数组时,操作仅仅只是开辟空间/释放空间而已并不会涉及到构造与析构行为。而delete [ ]的底层释放空间的操作是通过调用free实现的,所以我们直接调用free释放理论上并不会导致报错或崩溃,但是这种代码属于内存管理错误可能存在潜在风险我们并不提倡。
场景四:
class A
{
public:A(int a=4):value(a){cout << "A()" << std::endl;}~A(){cout << "~A()" << std::endl;}int value;
};int main()
{A* arr = new A[10];free(arr);return 0;
}
与上述场景三不同的是,这里的自定义类型A我们显式地定义了构造函数与析构函数那么代码地运行结果会是怎样的呢?
运行结果:

此时我们发现程序崩溃了,构造函数被调用了10次但是析构函数一次也没有被调用且导致了程序崩溃,我们来解释一下:
一个自定义类型显式地定义了析构函数时,创建该类型地对象数组时会在数组“头部(4字节)” 额外存储对象数量用于在调用delete[ ]时确定要调用多少次析构函数。所以在代码中对象数组arr的大小不会是10*8=80字节而是84字节(因为头部的4字节空间需要存储10),这一点我们可以用内存窗口看到:

而此时arr指针的值是0x00C0D7AC说明在new对象数组的时候一共开辟了84字节的空间而arr指向的是后80字节的起始地址,前4字节只供编译器在释放空间调用析构函数的时候使用并不想让用户修改和看到。而我们通过free释放arr时,虽然A中没有存在待释放的资源,但是实际上我们只会释放后80字节的空间前4字节的空间不会释放,我们的编译器敏锐地察觉到了这一点会直接导致运行时崩溃。
如果我们去掉析构函数后,arr数组的大小就又会变成80字节此时使用free释放arr不会导致报错或崩溃(类似场景三)。这说明对象数组头部会不会增加4字节只与该自定义类型是否显式定义析构函数有关,如果我们显式定义了析构函数那么在释放一个对象数组的时候必须使用delete [ ]。
class A
{
public:A(int a=4):value(a){cout << "A()" << std::endl;}int value;
};int main()
{A* arr = new A[10];free(arr);return 0;
}

内存窗口:

1.4 定位new(了解)
在 C++ 中定位new(placement new)是一种特殊的new 操作符用法,它允许在已分配的内存空间上构造对象,而不是由new自行分配新内存。其核心作用是 “复用内存”,常用于内存池、性能优化等场景。
语法:
// 在指定内存地址 ptr 上构造一个 T 类型的对象
new (ptr) T(构造函数参数);
ptr:指向已分配内存的指针(需确保内存大小足够容纳T类型对象,且对齐方式正确)。- 构造完成后,对象的生命周期从调用处开始,直到显式调用析构函数结束。
#include <iostream>
#include <new> // 定位 new 所需头文件class MyClass {
public:MyClass(int val) : data(val) {std::cout << "构造函数:data = " << data << std::endl;}~MyClass() {std::cout << "析构函数:data = " << data << std::endl;}int data;
};int main() {// 1. 分配一块原始内存(大小至少为 MyClass 对象的大小)char* rawMemory = new char[sizeof(MyClass)]; // 分配足够的字节数// 2. 使用定位 new 在 rawMemory 上构造 MyClass 对象MyClass* obj = new (rawMemory) MyClass(100); // 在指定内存构造对象// 3. 使用对象std::cout << "对象数据:" << obj->data << std::endl;// 4. 显式调用析构函数(定位 new 不会自动触发析构)obj->~MyClass();// 5. 释放原始内存(注意:用 delete[],因为分配时用了 new[])delete[] rawMemory;return 0;
}
定位new 构造的对象不会被 delete自动销毁(因为 delete 会释放内存,而这里内存是手动管理的),必须显式调用析构函数(obj->~MyClass()),否则会导致资源泄漏。
与普通 new 的区别:普通new= 分配内存 + 构造对象;定位new = 仅构造对象(内存已提前分配)。
