ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++ 内存管理详解:从 malloc/free 到 new/delete

C++ 内存管理详解:从 malloc/free 到 new/delete



🎬 个人主页:道尔柯南

❄专栏传送门:《C语言》《C++》《Linux操作系统》
昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。

文章目录

    • 一、为什么要学习 C++ 内存管理?
    • 二、C/C++ 程序的内存分布
      • 2.1 程序运行时,内存是如何划分的?
      • 2.2 栈区:存放普通局部变量等数据
      • 2.3 堆区:存放动态申请的内存
      • 2.4 数据段:存放全局变量和静态变量
      • 2.5 代码段与只读常量
    • 三、通过代码练习判断变量存储位置
    • 四、C 语言的动态内存管理:malloc、calloc、realloc 和 free
      • 4.1 malloc:申请动态内存
      • 4.2 calloc:申请内存并清零
      • 4.3 realloc:调整动态内存大小
      • 4.4 free:释放动态内存
      • 4.5 四个函数的区别
    • 五、C++ 的动态内存管理:new 和 delete
      • 5.1 new:动态申请并初始化对象
      • 5.2 new[]:申请连续的多个对象
      • 5.3 new 和 delete 如何处理自定义类型?
      • 5.4 malloc/free 与自定义类型
    • 六、operator new 与 operator delete:理解底层原理
      • 6.1 operator new 是什么?
      • 6.2 operator new 与 malloc 的关系
      • 6.3 operator delete 与 free 的关系
    • 七、new 和 delete 的实现原理
      • 7.1 内置类型的实现原理
      • 7.2 自定义类型的 new 原理
      • 7.3 自定义类型的 delete 原理
      • 7.4 new[] 和 delete[] 的原理
    • 八、定位 new 表达式:在已有内存中构造对象
      • 8.1 定位 new 的基本语法
      • 8.2 定位 new 示例
      • 8.3 定位 new 有什么应用场景?
    • 九、malloc/free 与 new/delete 的区别总结
      • 初学者应该优先掌握什么?
    • 十、常见错误与避坑指南
      • 错误一:申请后忘记释放
      • 错误二:重复释放同一块内存
      • 错误三:释放后继续访问
      • 错误四:混用申请和释放方式
      • 错误五:认为所有指针都在堆上
    • 十一、总结:建立完整的 C++ 内存管理知识体系
    • 十二、课后练习
  • 结尾

一、为什么要学习 C++ 内存管理?

学习 C++ 时,我们经常会看到这样的代码:

int a = 10; int* p = new int(20); delete p;

这段代码看起来很简单,但它涉及几个重要的问题:

  • 变量a和指针p存储在哪里?
  • new申请的内存来自哪里?
  • new和malloc有什么区别?
  • 为什么使用new申请的内存要使用delete释放?
  • 如果申请内存后忘记释放,会发生什么?

这些问题都属于 C++ 内存管理 的范畴。

简单来说,内存管理就是:了解程序运行时内存的组织方式,并正确地申请、使用和释放内存。

掌握这部分知识,不仅有助于理解 C++ 的运行机制,也是学习类与对象、指针、构造函数、析构函数以及面试题的重要基础。

二、C/C++ 程序的内存分布

2.1 程序运行时,内存是如何划分的?

程序运行时,并不是所有变量都存放在同一个地方,可以把相关内存区域概括为以下几部分:

栈区(Stack)

局部变量、函数参数等

堆区(Heap)

程序运行时动态申请的内存

数据段(静态区)

全局变量、静态变量

代码段(常量区)

可执行代码、只读常量等

内存映射区域

可用于共享库映射、共享内存等场景

实际进程的内存布局由操作系统、编译器和平台决定。

下面分别介绍这些区域。

2.2 栈区:存放普通局部变量等数据

例如:

void Test() { int a = 10; int b = 20; }

这里的a和b是普通局部变量。

在常见的 C++ 实现中,它们的自动存储期对象通常由函数调用所使用的栈来承载。函数执行结束后,这些局部变量的生命周期也随之结束。

栈的特点:

  • 主要用于函数调用相关的数据和自动存储期对象。
  • 一般不需要程序员手动释放局部变量占用的存储空间。
  • 栈空间通常有限,递归过深或局部对象过大可能导致栈溢出。

初学者可以先记住:普通局部变量通常具有自动存储期,不需要使用delete释放。

2.3 堆区:存放动态申请的内存

如果程序需要在运行过程中申请内存,就可以使用动态内存分配。

例如:

int* p = new int(10); delete p; p = nullptr;

这里需要区分两个概念:

  • p是一个指针变量,保存动态分配对象的地址。
  • new int(10)创建了一个动态分配的int对象,并将其初始化为10。

在常见实现中,指针变量p如果是普通局部变量,通常存放在栈上;它所指向的动态分配对象则通常位于堆上。

栈区:指针变量p

保存对象的地址

p 指向

动态分配对象:int(10)

保存整数值 10

图 2:指针变量与动态分配对象是两个不同的东西。

注意:动态分配的对象不会因为指针变量离开作用域就自动释放。使用普通裸指针管理动态内存时,需要正确释放资源,否则可能造成内存泄漏。

2.4 数据段:存放全局变量和静态变量

例如:

int globalVar = 1; static int staticGlobalVar = 2; void Test() { static int staticVar = 3; }

这里的三个变量都具有静态存储期。

  • globalVar:全局变量。
  • staticGlobalVar:文件作用域的静态变量。
  • staticVar:函数内部的静态局部变量。

它们的生命周期贯穿程序的整个运行期间,但作用域和可见性并不完全相同。

需要注意:static不一定代表变量只能在某个函数内使用。变量定义的位置不同,作用域也会不同。

2.5 代码段与只读常量

例如:

const char* p = "abcd";

这段代码中:

  • p是一个指针变量。
  • "abcd"是字符串字面量。
  • p指向字符串字面量的首字符。

在常见实现中,字符串字面量通常位于只读存储区域;但局部指针变量p本身并不会因为指向字符串字面量,就自动进入只读区域。

此外,const char*表示不能通过该指针修改所指字符,并不意味着指针变量自身不能改变指向。

例如:

const char* p = "abcd"; p = "hello"; // 可以:改变指针指向 // p[0] = 'A'; // 不可以:不能通过 p 修改字符

三、通过代码练习判断变量存储位置

综合示例:

int globalVar = 1; static int staticGlobalVar = 1; void Test() { static int staticVar = 1; int localVar = 1; int num1[10] = {1, 2, 3, 4}; char char2[] = "abcd"; const char* pChar3 = "abcd"; int* ptr1 = (int*)malloc(sizeof(int) * 4); int* ptr2 = (int*)calloc(4, sizeof(int)); int* ptr3 = (int*)realloc(ptr2, sizeof(int) * 4); free(ptr1); free(ptr3); }

对于这段代码,可以这样理解:

变量或对象常见存储位置说明
globalVar数据段全局变量
staticGlobalVar数据段静态全局变量
staticVar数据段静态局部变量
localVar栈区普通局部变量
num1栈区局部数组本身
char2栈区局部字符数组本身
*char2栈区中的数组元素第一个字符'a'
pChar3栈区普通局部指针变量
*pChar3通常在只读存储区域指向字符串字面量的首字符
ptr1栈区普通局部指针变量
*ptr1堆区动态分配的整数空间中的第一个元素

表格中的“栈区”“堆区”是常见实现下的便于理解的描述,并非 C++ 标准对所有对象物理地址的统一规定。

这一部分最重要的结论是:判断内存位置时,要区分指针变量本身与它所指向的对象。

四、C 语言的动态内存管理:malloc、calloc、realloc 和 free

C 语言提供了四个常用的动态内存管理函数。C++ 也可以继续使用它们。

4.1 malloc:申请动态内存

malloc用于申请指定字节数的内存空间。

语法:

void* malloc(size_t size);

示例:

#include <cstdlib> #include <iostream> using namespace std; int main() { int* p = (int*)malloc(sizeof(int)); if (p == nullptr) { cout << "内存申请失败" << endl; return 1; } *p = 10; cout << *p << endl; free(p); p = nullptr; return 0; }

运行逻辑如下:

  1. sizeof(int)计算一个int所占的字节数。
  2. malloc申请对应大小的内存。
  3. 检查返回的指针是否为空。
  4. 通过指针写入并读取数据。
  5. 使用free释放内存。

需要注意,malloc申请的空间不会自动将内容初始化为0,因此不能直接假设其中已经存放了有效的整数值。

4.2 calloc:申请内存并清零

calloc的作用是申请指定数量、指定大小的内存,并将申请到的内存字节初始化为零。

语法:

void* calloc(size_t num, size_t size);

示例:

#include <cstdlib> #include <iostream> using namespace std; int main() { int* p = (int*)calloc(4, sizeof(int)); if (p == nullptr) { return 1; } for (int i = 0; i < 4; i++) { cout << p[i] << " "; } free(p); p = nullptr; return 0; }

在常见平台上,这个程序会输出:

0 0 0 0

calloc(4, sizeof(int))表示申请能够容纳四个int的空间,并将这些空间的字节清零。

对于常见的整数类型,这样得到的值为0。但要注意,将内存字节清零并不等同于适用于所有 C++ 对象的通用初始化方式,尤其不能用它替代对象构造函数。

4.3 realloc:调整动态内存大小

当原先申请的空间不够用时,可以使用realloc调整空间大小。

void* realloc(void* ptr, size_t new_size);

例如:

#include <cstdlib> #include <iostream> using namespace std; int main() { int* p = (int*)malloc(4 * sizeof(int)); if (p == nullptr) { return 1; } for (int i = 0; i < 4; i++) { p[i] = i + 1; } int* tmp = (int*)realloc(p, 10 * sizeof(int)); if (tmp == nullptr) { // realloc 失败时,原来的 p 仍然有效 free(p); return 1; } p = tmp; for (int i = 0; i < 4; i++) { cout << p[i] << " "; } free(p); p = nullptr; return 0; }

这里有一个特别重要的细节:不要直接把realloc的返回值覆盖到原指针上。

不推荐这样写:

p = (int*)realloc(p, 10 * sizeof(int));

如果realloc失败并返回空指针,原来的地址就可能丢失,导致原内存无法正常释放。

使用临时指针tmp,能够在调整失败时保留原指针。

此外,realloc可能在原地址调整空间,也可能分配新空间并搬移原有数据。成功后,应当使用它返回的新指针。

4.4 free:释放动态内存

free用于释放由 C 动态内存分配函数申请的空间。

int* p = (int*)malloc(sizeof(int)); if (p != nullptr) { *p = 100; free(p); p = nullptr; }

使用free后,原来指向该空间的指针不会自动变为空指针。因此,将其设置为nullptr是一个有用的习惯。

但是要记住:free不会自动修改其他指向同一内存的指针。 如果还有别的指针指向已经释放的空间,就需要避免继续通过它们访问该空间。

4.5 四个函数的区别

函数主要作用初始化特点
malloc申请指定字节数的内存不初始化
calloc申请指定数量的内存单元将申请到的字节清零
realloc调整已有动态内存的大小保留原有数据中能够保留的部分
free释放动态内存不负责对象的析构

这四个函数的使用规则可以概括为:申请、使用、释放。 申请成功后,必须确保程序在适当的时候释放内存,并正确处理失败的情况。

五、C++ 的动态内存管理:new 和 delete

C++ 保留了 C 的动态内存管理函数,同时提供了更符合面向对象编程特点的new和delete。

5.1 new:动态申请并初始化对象

先看一个简单的例子:

#include <iostream> using namespace std; int main() { int* p1 = new int; int* p2 = new int(10); cout << *p2 << endl; delete p1; delete p2; return 0; }

其中:

int* p1 = new int;

表示动态分配一个int对象。对于这种没有显式初始化的写法,不能假设它的值为0。

而:

int* p2 = new int(10);

则表示动态分配一个int对象,并将其初始化为10。

这里可以看到,new不仅涉及内存分配,还可以完成对象初始化。

5.2 new[]:申请连续的多个对象

如果希望申请一组连续的整数,可以使用new[]:

int* p = new int[5]; for (int i = 0; i < 5; i++) { p[i] = i + 1; } for (int i = 0; i < 5; i++) { cout << p[i] << " "; } delete[] p; p = nullptr;

输出:

1 2 3 4 5

注意下面两个写法的区别:

int* p1 = new int; // 一个 int 对象 int* p2 = new int[5]; // 五个 int 对象组成的数组

对应的释放方式也不同:

delete p1; delete[] p2;

申请与释放必须匹配:

  • new对应delete。
  • new[]对应delete[]。

不能把new[]申请的数组交给普通的delete释放,否则会产生未定义行为。

5.3 new 和 delete 如何处理自定义类型?

这是 C++ 内存管理中非常重要的知识点。

假设我们定义一个类:

#include <iostream> using namespace std; class A { public: A(int a = 0) : _a(a) { cout << "A():" << this << endl; } ~A() { cout << "~A():" << this << endl; } private: int _a; };

这个类包含:

  • 构造函数A():创建对象时初始化对象。
  • 析构函数~A():对象生命周期结束时执行清理工作。

使用new和delete:

int main() { A* p = new A(10); delete p; p = nullptr; return 0; }

程序执行时,主要经历两个过程:

第一步:new A(10)

申请足够的存储空间,并在该空间中构造A对象,将成员变量_a初始化为10。

第二步:delete p

调用对象的析构函数,执行对象的清理工作,然后释放该对象所占用的动态存储空间。

图 3:new/delete 对自定义类型的基本处理过程。

需要特别注意:析构函数负责清理对象管理的资源,但不意味着它会自动释放所有外部资源。比如类中持有的裸指针,如果其指向的资源需要释放,仍然需要编写相应的清理逻辑。

5.4 malloc/free 与自定义类型

对比下面两段代码:

A* p1 = (A*)malloc(sizeof(A)); free(p1);

以及:

A* p2 = new A(10); delete p2;

第一段代码仅仅申请和释放原始存储空间,不会自动调用A的构造函数和析构函数。

第二段代码则会在对象创建时调用构造函数,在对象销毁时调用析构函数。

因此,对于需要执行构造和析构逻辑的 C++ 自定义类型,通常应该使用new和delete,或者更推荐使用标准库的智能指针及容器管理资源。

还要注意,malloc得到的内存并不会因为强制转换成A*就自动变成一个已经构造好的A对象。

六、operator new 与 operator delete:理解底层原理

前面我们知道,new和delete可以创建和销毁动态对象。那么,它们底层是如何完成内存分配的呢?

接下来介绍了两个重要的全局函数:

  • operator new
  • operator delete

6.1 operator new 是什么?

先区分两个概念:

名称作用
new表达式申请存储空间,并初始化对象
operator new函数负责提供所需的原始存储空间

例如:

int* p = new int(10);

可以从概念上理解为:

  1. 调用适当的operator new分配存储空间。
  2. 在分配的空间中初始化int对象。
  3. 返回指向该对象的指针。

需要注意,这是一种帮助理解的流程描述;实际调用会受到重载、分配形式等因素影响。

6.2 operator new 与 malloc 的关系

常见实现中,operator new会通过malloc申请内存。

如果申请成功,就返回相应的地址;如果申请失败,则可能按照相应的处理机制继续尝试,或者抛出std::bad_alloc异常。

示意代码如下:

void* operator new(size_t size) { void* p = malloc(size); if (p == nullptr) { throw std::bad_alloc(); } return p; }

这段代码只是用于理解原理的简化示例,并不是完整的标准库实现。实际的全局operator new还涉及分配失败处理机制等细节。

6.3 operator delete 与 free 的关系

同样,在常见实现中,operator delete最终会通过free释放相应的存储空间。

可以用下面的示意代码理解:

void operator delete(void* p) noexcept { free(p); }

这同样是简化的原理示例,不应直接当作完整的标准库实现。

这里最重要的结论是:

new表达式不等于operator new函数,delete表达式也不等于operator delete函数。

new和delete还承担着对象初始化、对象销毁等工作,而底层分配函数主要负责原始存储空间的获取与释放。

七、new 和 delete 的实现原理

掌握前面的内容后,我们可以进一步理解普通类型、数组以及自定义类型的内存管理流程。

7.1 内置类型的实现原理

对于int等内置类型:

int* p = new int(10); delete p;

可以理解为:

new int(10)

申请存储空间

将对象初始化为 10

delete p

释放动态存储空间

对于内置类型,new和malloc、delete和free在基本用途上存在相似之处,但它们并不能随意混用。

另外,分配失败时的处理方式也不同:普通的new通常抛出std::bad_alloc,而malloc返回空指针。

7.2 自定义类型的 new 原理

对于前面定义的A类:

A* p = new A(10);

其基本过程为:

  1. 调用适当的分配函数申请存储空间。
  2. 在该空间上执行构造过程。
  3. 完成A对象的初始化。

其中,构造函数会执行成员变量初始化以及编写在构造函数中的其他操作。

7.3 自定义类型的 delete 原理

当执行:

delete p;

基本过程为:

  1. 调用对象的析构函数,完成资源清理。
  2. 调用相应的释放函数,释放对象占用的存储空间。

这也是为什么new/delete比简单的内存申请和释放更适合管理具有复杂生命周期的 C++ 对象。

7.4 new[] 和 delete[] 的原理

如果要创建多个对象:

A* p = new A[3]; delete[] p;

创建数组时,需要为数组中的对象申请存储空间,并完成各个元素的构造。

释放数组时,需要销毁相应的元素,再释放数组所占用的存储空间。

一般情况下,数组元素的析构顺序与构造顺序相反。

可以将整个过程总结为:

new A[3]

申请数组所需的存储空间

构造 A[0]、A[1]、A[2]

delete[] p

销毁数组元素,再释放存储空间

图 4:自定义类型数组的创建与销毁流程。

注意:new[]和delete[]是一组匹配的操作。对于需要执行析构函数的对象数组,使用正确的释放方式尤其重要。

八、定位 new 表达式:在已有内存中构造对象

它主要用于:在一块已经分配好的原始存储空间中,构造一个对象。

普通的new通常负责申请空间并创建对象,而定位 new 使用调用者提供的地址,不负责重新申请一块独立的存储空间。

8.1 定位 new 的基本语法

new (place_address) type;

也可以带初始化参数:

new (place_address) type(initializer);

其中,place_address是提供给定位 new 使用的地址。

8.2 定位 new 示例

继续使用前面的A类:

#include <cstdlib> #include <iostream> #include <new> using namespace std; class A { public: A(int a = 0) : _a(a) { cout << "A()" << endl; } ~A() { cout << "~A()" << endl; } private: int _a; }; int main() { // 先申请原始存储空间 void* buffer = malloc(sizeof(A)); if (buffer == nullptr) { return 1; } // 在已有空间中构造 A 对象 A* p = new (buffer) A(10); // 显式调用析构函数 p->~A(); // 释放原始存储空间 free(buffer); return 0; }

理解这段代码时,要注意三个步骤:

  1. malloc只负责申请原始存储空间。
  2. new (buffer) A(10)在这块空间中构造对象。
  3. p->~A()显式调用析构函数,随后使用free释放原始存储空间。

特别注意:定位 new 创建的对象,不能简单地使用普通的delete p来释放。 因为这块存储空间不是通过普通new表达式获得的。这里应该先结束对象的生命周期,再通过与原始内存分配方式相匹配的方式释放空间。

8.3 定位 new 有什么应用场景?

定位 new 通常与内存池配合使用。

内存池可以预先申请一大块存储空间,在需要创建对象时,再从中划分出合适的区域。

由于这些区域最初可能只是原始存储空间,因此需要通过定位 new 构造相应的 C++ 对象。

这有助于理解 C++ 中存储空间的管理和对象生命周期管理是两个相关但不同的概念。

九、malloc/free 与 new/delete 的区别总结

这是本章最值得掌握的一组对比,也是常见的 C++ 面试知识点。

对比维度malloc/freenew/delete
本质函数运算符及表达式
申请大小需要指定字节数根据类型确定
初始化malloc不初始化可以在创建时初始化
返回值void*返回相应类型的指针
申请失败malloc返回空指针普通new通常抛出异常
构造函数不会自动调用创建类对象时会调用
析构函数不会自动调用销毁类对象时会调用
数组操作申请合适的总字节数使用new[]和delete[]
释放匹配malloc对应freenew对应delete

补充两点:

  • 如果使用new (std::nothrow),分配失败时可以返回空指针,而不是抛出分配失败异常。
  • new和delete的调用规则还会受到自定义分配函数、重载以及对象类型等因素影响。上表概括的是初学阶段最常见的情况。

初学者应该优先掌握什么?

建议记住以下四条:

  1. malloc申请原始存储空间,free释放这类空间。
  2. new创建对象,delete销毁对象并释放相应存储空间。
  3. 自定义类型通常需要通过构造函数初始化,通过析构函数清理资源。
  4. new[]必须与delete[]匹配,不能随意混用。

在现代 C++ 项目中,还应优先考虑std::vector、std::string、std::unique_ptr等资源管理工具,尽量避免手动管理裸指针带来的错误。

十、常见错误与避坑指南

错误一:申请后忘记释放

int* p = new int(10); // 忘记 delete

如果程序反复执行类似操作,就可能发生内存泄漏。

建议: 让动态资源具有明确的所有权和生命周期,优先使用智能指针或标准容器。

错误二:重复释放同一块内存

int* p = new int(10); delete p; // delete p; // 错误:重复释放

重复释放同一块已经释放的内存会产生未定义行为。

将p置为nullptr有助于避免某些误用,但不能替代对所有权的正确管理。

错误三:释放后继续访问

int* p = new int(10); delete p; // cout << *p; // 错误:访问已释放的对象

释放对象后,原指针可能变成悬空指针。继续解引用会产生未定义行为。

错误四:混用申请和释放方式

int* p = new int[10]; // delete p; // 错误 delete[] p;

数组应该使用delete[]释放。

类似地,使用malloc申请的空间应当使用相匹配的释放方式,而不是直接交给普通delete。

错误五:认为所有指针都在堆上

void Test() { int a = 10; int* p = new int(20); }

这里,a是普通局部变量;p是局部指针变量;new int(20)创建的是动态分配对象。

在常见实现中,a和p通常由栈承载,而动态分配对象通常位于堆上。Test()返回后,动态对象不会因为p消失就自动释放,因此这段代码存在内存泄漏风险。

十一、总结:建立完整的 C++ 内存管理知识体系

通过本章学习,我们可以把 C++ 内存管理串联成一条完整的知识链。

第一步:理解内存分布

栈区、堆区、数据段、代码段

第二步:掌握 C 动态内存函数

malloc、calloc、realloc、free

第三步:掌握 C++ 动态内存操作

new、delete、new[]、delete[]

第四步:理解底层机制

operator new、operator delete

第五步:理解对象生命周期

构造、析构、定位 new、资源释放

图 5:C++ 内存管理知识体系。

对于刚开始学习 C++ 的同学来说,不需要一开始就研究复杂的内存分配器实现。先理解不同内存区域的用途,熟练掌握malloc/free和new/delete的区别,再逐步理解构造函数、析构函数和底层分配机制,就能建立扎实的知识基础。

最后送给初学者一句话:内存管理不仅是申请和释放空间,更重要的是理解对象的生命周期,并确保资源能够被正确管理。

把这句话真正理解透彻,后续学习 C++ 类与对象、智能指针、容器以及资源管理时,就会轻松很多。

十二、课后练习

练习 1:下面哪种方式适合释放new int[10]申请的数组?

A.free(p)

B.delete p

C.delete[] p

练习 2:使用malloc申请A类对象的空间,会自动调用A的构造函数吗?

A. 会

B. 不会

练习 3:int* p = new int(10);中,指针p与它指向的整数对象有什么区别?

A. 指针和整数对象是同一个东西

B. 指针保存地址,整数对象保存值 10

C. 指针一定在堆上,整数对象一定在栈上


结尾

uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!

道尔柯南

C/C++ & Linux 底层探索者 | 一个正在努力学习的技术博主

👀【关注】跟随我一起深耕技术领域,见证每一次成长。
❤️【点赞】让优质内容被更多人看见,让知识传递更有力量。
⭐【收藏】把核心知识点存好,在需要时随时查、随时用。
💬【评论】分享你的经验或疑问,评论区一起交流避坑!

不要忘记给博主“一键四连”哦!

“今日目标达成!”

结语:希望对学习C++相关内容的uu有所帮助,不要忘记给博主“一键四连”哦!

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进