☰
C++内存管理入门:new/delete与malloc/free的区别及避坑指南
2026/10/21 19:50:01 网站建设 项目流程

初学C++的人,很多都是从C语言过渡过来的。用习惯了malloc/free,第一次看到new/delete的时候,总会冒出一个疑问:C++的内存管理,为什么还要额外搞这么一套新东西?等自己踩过几次内存泄漏、几次程序崩溃之后,才会意识到,这一套机制不是炫技,而是为了解决类类型构造和析构的问题。这篇文章我打算从初阶视角把C++内存管理整理一遍,涵盖内存分布、new/delete的用法和底层逻辑、以及初学者最容易踩的坑,争取让你看完就能直接上手写代码,并且知道自己的内存去哪了、什么时候该释放、不释放会怎样。

内容主要面向两类人:一类是刚学完C、准备过渡到C++的初学者,另一类是写过一点C++、但对new/delete的理解只停留在“会背语法”阶段的同学。不管你是哪一类,读完应该都能建立起一张完整的内存管理地图。

1. C++程序的内存分布:先搞清楚你的变量到底住在哪个区

1.1 四大区域的职责划分

讨论动态内存管理之前,我建议先把一个C++程序在内存里的整体布局弄清楚。很多初学者学到指针、学到堆区之后,会把栈和堆搞混,或者忽略了全局区、常量区的存在。其实整个进程的虚拟地址空间一般可以分成几个大区域,每个区域管的事不一样。

简单列一下:

区域存放什么生命周期
栈区函数局部变量、函数参数、返回值等函数调用期间自动分配和释放
堆区通过new/malloc动态申请的空间手动申请,手动释放;不释放会一直占用
全局区(静态区)全局变量、static修饰的变量程序启动到程序结束
常量区字符串字面量、const修饰的某些全局数据程序运行期间只读,程序结束时回收
代码段编译后的机器指令程序运行期间只读

栈区是由编译器自动管理的。函数一进去,局部变量马上分配;函数一返回,这些内存立刻回收。这个过程非常高效,不需要程序员干预。堆区就不同了,你申请它,它给你;你不释放,它就一直占着。这也是为什么动态内存管理是C/C++程序员绕不开的话题。

全局区和静态区的生命周期是整个程序运行期。很多人觉得静态变量很神秘,其实它的核心特点就是“活到程序结束”。常量区里面存的是只读数据,典型的例子是字符串字面量,比如你在代码里写的"hello world",它并不是在栈上随便飘着,而是放在只读区域里,所以试图修改字符串字面量的内容会导致未定义行为。

1.2 写个小程序,看看变量都住在哪里

光说理论容易飘,上代码最直接。看下面这个例子:

#include <iostream> int global_var = 10; // 全局变量,存放在全局区 static int file_static = 20; // 静态变量,也在全局区 int main() { int local = 30; // 栈区 const char* str = "hello"; // str本身在栈上,“hello”字符串在常量区 int* heap_ptr = new int(40); // 指针在栈上,指针指向的对象在堆区 std::cout << global_var << std::endl; std::cout << file_static << std::endl; std::cout << local << std::endl; std::cout << str << std::endl; std::cout << *heap_ptr << std::endl; delete heap_ptr; // 堆内存必须手动释放 return 0; }

这段代码里,local是一个局部变量,压入栈帧,生命周期只在main函数内部。str指针本身在栈上,但"hello"这个字符串内容是对着常量区存的。heap_ptr本身在栈上,但new int(40)会在堆区申请一块能放int的空间,并把地址交给heap_ptr。

对比一下就能发现,栈内存的释放是自动触发的,你根本不用管。但堆内存不会平白无故消失,代码里写delete heap_ptr,这块4字节的空间才会真正归还给系统。你如果把这个delete注释掉,程序运行结束后,操作系统会收回整个进程的内存,单次运行好像看不出什么。可如果这个代码在一个循环里反复申请堆内存而不释放,内存占用就会持续上涨,这个过程就叫内存泄漏。

1.3 为什么堆区需要你格外小心

栈区和全局区的管理,要么靠编译器,要么靠系统,程序员基本不用操心。麻烦就麻烦在堆区:申请和使用完全由程序员主导。这意味着你拥有了很大的自由,同时也背上了很大的责任。

你可以在堆上创建数组、创建对象、甚至创建结构体链表,堆区的大小受限于可用物理内存和系统虚拟内存,比栈要大很多。但堆有几个特点决定它必须被谨慎对待:第一,申请的操作很昂贵,和栈的一两条指令相比,堆分配往往需要走更复杂的分配算法;第二,释放时机不确定,你可以在任意点释放,但没有释放好就会出现泄漏或者崩溃;第三,使用不当会引发未定义行为,比如重复释放一片内存,程序可能当场报错,也可能在很久之后才崩溃。

初学者最容易犯的错误是只盯着“申请到了空间”这个动作,却从没想过“什么时候还回去”。所以这一整套内存管理的核心,其实就是一句话:对堆上的每一块内存,你都要回答清楚三个问题——在哪申请?用多久?由谁释放?

2. 为什么要用new/delete替代malloc/free

2.1 malloc/free的两个短板:不知道类型,也不会初始化

C语言里动态分配内存只有一套方案:malloc申请、free释放。这个方案在C语言时代用得很顺手,因为C语言里的结构体没有构造和析构的概念,分配一块内存之后,你想怎么用就怎么用。只要记得释放,基本上不会有什么大问题。

但到了C++,类被引入了,类里面有构造函数、析构函数。问题就来了:malloc只是分配原始字节空间,它根本不知道你分配的是一个int还是一个Student对象,所以它不会调用构造函数。free也是同理,只负责把空间还给系统,不调用析构函数。

看一个典型场景:

struct Student { int id; Student(int i) : id(i) {} }; int main() { // 用malloc创建一个Student Student* s1 = (Student*)malloc(sizeof(Student)); // s1->id 此时是什么值?没人知道,可能是随机值 // 用new创建一个Student Student* s2 = new Student(1001); // s2->id 被构造函数设置为1001 free(s1); delete s2; return 0; }

用malloc创建Student,内存倒是有了,但内存里的id是未初始化的随机值。很多人会强行辩解“我赋值一下不就行了”:可以,但你每次用malloc创建对象都要手动做一遍初始化,遇到更复杂的类可能要手动调用成员初始化函数,写起来又长又容易漏。更麻烦的是,如果这个类有好几个成员、又有资源需要申请,你还要记住每一步都要手动执行一次。这显然违背了C++封装和自动化的初衷。

2.2 new/delete弥补了什么:类型推导与构造析构调用

new/delete是C++的运算符,它们在语言层面上就是为了解决上面的问题而存在的。

new关键字做了两件事:第一,根据你写出来的类型大小,在堆上分配一片空间;第二,立即在这片空间上调用构造函数,把对象真正地“造”出来。malloc能做的第一步、第二步它都能做,而且还会主动做第三步初始化。delete同理,它会先调用析构函数,把对象内部管理的资源释放掉,再把这片内存归还给系统。

这个区别对内置类型可能不明显,但对自定义类型是决定性的。一个类里面有vector、string、或者有自己的动态资源时,构造函数不执行,对象就是残缺的;析构函数不执行,对象占用的内部资源就永远得不到释放。用new创建的对象,整个生命周期完全可控,构造和析构都被自动安排好,这就是C++引入new/delete的最根本原因。

2.3 同样是动态内存,两者最直观的三个差异

抛开底层实现不谈,使用者最直观的感受有三个:

第一,语法上的差距。malloc需要传入字节数,而且返回的是void*,必须强制转换为目标类型指针;new直接在类型后面写参数,编译器自动推导类型和大小,不需要强转。写代码的时间成本,new更省。

第二,初始化的差距。malloc分配之后,内存里的内容是什么不确定;new可以在分配的同时提供初始化参数,比如new int(42)、new Student(1001)。就算不想给初值,写成new int()依然会得到一个稳定的0值初始化结果。

第三,错误处理方式的差距。malloc失败返回NULL,你写完分配之后通常要加一个判断。new失败直接抛异常,你不需要在每一行分配代码旁边都写失败判断。两种方案没有绝对优劣,但它们背后的设计哲学完全不同。初学阶段,把new/delete当作首选,把malloc/free当作配合C代码时的补充,是一个比较合理的策略。

3. new/delete的完整用法:从最简单的int到数组和定位new

3.1 内置类型的new:默认初始化、值初始化、指定初值

先看内置类型,也就是int、double、char这些基本类型用new是什么样子。

int* p1 = new int; // 默认初始化,p1指向的空间内是不确定的值 int* p2 = new int(); // 值初始化,p2指向的空间被置为0 int* p3 = new int(42); // 指定初始值42 double* d1 = new double(3.14); char* c1 = new char('A'); delete p1; delete p2; delete p3; delete d1; delete c1;

这里很多初学者容易忽略一个细节:new int和new int()看起来差不多,实际区别在初始化策略上。new int采用默认初始化,对于内置类型来说,意味着不初始化,里面的值是随机的;new int()采用值初始化,内置类型会被置0。如果你有“分配int之后就立即读取再赋值”的需求,最好用new int()或new int(0),不然调试的时候会看到一些莫名其妙的随机数,平白增加排查成本。

C++11之后还可以用花括号统一初始化,写成new int{42}或new Student{1001},效果上类似,我自己的习惯是遇到基本类型直接用圆括号,遇到结构体或类时使用花括号语法,风格统一,看起来也更清晰。

3.2 自定义类型的new:构造与析构是灵魂

内置类型的new和malloc差异有限,真正体现new价值的场景是自定义类型。这里用一个简单类演示:

#include <iostream> class Timer { public: Timer() { std::cout << "Timer构造" << std::endl; start = 0; } ~Timer() { std::cout << "Timer析构" << std::endl; } private: int start; }; int main() { Timer* t = new Timer(); delete t; return 0; }

运行这段代码,你会看到控制台先打印“Timer构造”,再打印“Timer析构”。也就是说,new不仅仅分配内存,它让对象经历了一次完整的生命周期。如果用malloc替换new、用free替换delete,那么这两行日志都不会出现。你可能觉得:不就少打印两行字吗?但假设Timer类的构造函数里初始化了一批内部数据,析构函数里要关闭文件和网络连接,少了构造和析构,整个对象就等于坏了。

还有一个细节:delete只能作用于new申请出来的对象,delete一个malloc出来的指针是不对的,这一点后面讲踩坑时还会展开。

3.3 数组内存:new[]和delete[]为什么必须配套

动态分配数组也有一对专门的运算符:new[]和delete[]。

int* arr = new int[10]; // 分配10个int for (int i = 0; i < 10; ++i) { arr[i] = i * i; } delete[] arr; // 必须用delete[]

这里的关键规则是:new配delete,new[]配delete[],不能混。为什么这么严格?因为new[]不只是一个分配动作,它在实现上可能需要额外的空间记录“到底分配了多少个元素”。当你用delete[]释放数组时,编译器需要根据这个记录去逐个调用元素的析构函数。如果你用delete去释放new[]创建的内存,编译器会把这块内存当作单个对象来处理,可能导致析构次数不对,也可能在释放阶段读出错误的对象个数,运行结果完全没有保障,属于未定义行为。

对于内置类型数组,错配delete和delete[]有时候看着没崩,但那只是一种侥幸。对于自定义类型数组,错配带来的问题会直接显现,比如只调用了第一个对象的析构函数,其他对象析构函数全被跳过,资源泄漏就已经发生了。所以我的原则很简单:写new[]的那一行旁边,直接写好配对的delete[],再补一句注释,这样基本不会错。

3.4 placement new:在已有的内存上“造”对象

C++里还有一种special到不太“内存”的用法,叫定位new,也就是placement new。它不在堆上申请内存,而是在你已经准备好的这一段内存上调用构造函数。

#include <new> void* mem = operator new(sizeof(Timer)); // 手动分配一块原始内存 Timer* t = new (mem) Timer(); // 在这块内存上构造Timer对象 t->~Timer(); // 手动调用析构函数 operator delete(mem); // 释放原始内存

初阶阶段用不上它,但了解一下没有坏处。等以后写内存池、对象池、或者某些嵌入式场景,需要在固定地址反复创建对象时,placement new就是标准答案。它的意义在于把“分配内存”和“构造对象”拆成两步,给你更精细的控制力。学到这里,你只需要记住:new (address) Type(args)表示在address处构造对象,析构时不会自动释放那块地址空间,需要你自己管理。

4. new和delete动作拆解:语言表达式背后的两个函数

4.1 new表达式到底做了什么

很多初学者把new当成一个函数,其实它是语言层面的表达式。但真正干活的时候,new会被拆成几个固定步骤。拿new Student(1001)来说:

第一步,调用一个叫operator new的全局函数,按照sizeof(Student)大小申请堆内存。第二步,在申请到的内存上调用Student构造函数,传入1001。第三步,把这块内存的地址返回给你。

对应地,delete Student对象也分两步:先调用Student的析构函数,再调用operator delete函数释放内存。

这里面的operator new和operator delete是一组全局函数,它们负责真正的内存分配和释放。不管是谁写的new表达式,最终都绕不开这两个函数。

4.2 operator new与malloc的关系

讲到这里就很有意思了:operator new函数内部到底是怎么分配内存的?答案可能让你觉得绕了一圈又回来了——最常见的实现方式,就是对malloc进行封装。

我们可以这样理解标准库的一个典型实现思路:

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

也就是说,new表达式的底层最终还是会调用malloc来拿到一块原始内存。区别在于,在malloc外面包了一层异常处理,在malloc之上还叠加了构造函数的调用。所以如果你问“new能不能替代malloc”,答案是能;如果你问“new是不是在malloc基础上改的”,也可以这么理解。

这个知识对初学者的价值是:它让你明白new和malloc不是完全割裂的两套系统,而是C++对底层内存管理的一层抽象。理解了这层关系,后面看内存池实现、看标准库容器的分配器,就不会一头雾水了。

4.3 失败处理和异常:一个很低调但很重要的差异

malloc失败返回NULL,new失败抛bad_alloc异常。这是两者在可靠性层面比较大的一个差异。

C语言程序员通常这样写:

int* p = (int*)malloc(sizeof(int) * 100); if (p == nullptr) { // 处理分配失败 return -1; }

C++的new场景下,你很少写失败分支,因为抛异常会把控制权交给上面的catch。如果你确实想沿用“返回NULL”的风格,C++也提供了不抛异常的版本:

int* p = new (std::nothrow) int(100); if (p == nullptr) { // 分配失败,p为nullptr }

看到没有,这是new表达式的一种变体,它让new在失败时不要抛异常,而是返回nullptr,适合那些不希望因为分配失败中断流程的场景。初学阶段可能用不到,但面试时老师和你聊new和malloc的区别,这个点经常能问到。

4.4 初阶阶段关注底层的意义

有的读者可能会问:我就想学会什么场景用new、什么场景用delete,有必要知道operator new吗?我的看法是,不必深挖,但要有概念。原因很简单:后续学习STL容器的内存分配、学习重载operator new、或者阅读源码时,如果完全没有底层概念,读一行就会卡一行。反而先理解了“new是申请内存+构造对象”这个框架,后面再看各种高级特性,都只是在这个框架上加东西。

举一个很常见的例子:构造函数抛异常会怎样?很多初学者以为new已经分配了内存,如果构造函数抛出异常,这块内存会泄漏。实际上标准库帮你想好了:如果构造函数抛出异常,new表达式会自动调用operator delete释放掉已经分配的内存,再继续往外抛异常。这个机制恰恰是建立在“new分两步走”这个底层模型上的。只看语法层面,你根本理解不了这种保护机制为什么存在。

5. new/delete和malloc/free,一张表看清选型

5.1 核心对比表

把new/delete和malloc/free的关键差异整理一下,方便你以后用的时候快速判断。

对比项malloc/freenew/delete
属性库函数C++运算符/表达式
内存大小需要手动传入字节数编译器根据类型自动计算
返回值void*,需要强转直接返回对应类型指针
初始化不初始化,内容随机可以指定初值或触发构造函数
自定义类型不调用构造函数/析构函数自动调用构造/析构函数
失败处理返回NULL默认抛bad_alloc异常
数组支持可用malloc配合手动计算有专门的new[]/delete[]
底层关系原始分配函数内部封装malloc并叠加构造逻辑

如果你问我“两者是不是二选一的关系”,我会说:它们在C++里是共存关系。new/delete是C++的主要选择,malloc/free在特定场景下依然有存在的价值。

5.2 什么场景还值得使用malloc/free

首先是和纯C代码打交道的时候。很多底层接口,比如写一些系统调用、解析某些二进制库,它们只认malloc申请的内存,要求你用free释放。这种场景强行走new反而容易破坏对方的约定。

其次是做纯内存分配的时候。如果你只是想临时开一块缓冲区,不涉及任何类的构造,而且后续可能需要用realloc来调整大小。realloc可以原地扩容或者搬到更大的内存区域,这个功能new/delete不提供,所以类似“动态扩容的原始内存块”这种场景,malloc和realloc依然是好选择。

还有一个场景是写自定义内存池。内存池为了高效管理一大块内存,通常直接操作原始内存,用malloc开一大块,再用placement new在其上构造对象。这个流程很自然地会把malloc和new结合起来使用。初阶阶段你接触不到这么复杂的场景,但要知道它们不是敌人。

5.3 不要忘记RAII:善用栈上的对象

说完动态内存,我还要多提一句:在C++里,能用栈对象解决问题,就不要优先考虑堆对象。比如你临时需要一个vector,直接写std::vector v,它内部会在堆上申请元素空间,但对象本身在栈上,函数结束时析构函数被自动调用,内存自动释放。这就是RAII的思想:把资源的生命周期绑定到对象的生命周期上。

初学阶段最容易养成的坏习惯就是用裸指针到处new,然后到处delete,每写一个new还要担心自己忘了释放。其实标准库已经替你封装好了很多资源管理类,vector、string、unique_ptr等等。学会使用它们,不仅代码更短,内存问题也会成倍减少。

6. 初学内存管理最容易踩的四类坑

6.1 只有new没有delete:泄漏从哪开始

第一个坑也是最常见的坑:new了但没delete。看这段:

void func() { int* p = new int(10); // 这里忘了delete }

这个函数结束之后,指针变量p会消失,但堆上的那块int内存不会自动释放,而且你已经没有任何变量记着它的地址了。这下完了,想释放都找不到它,这块内存就“泄漏”了,直到进程结束才被操作系统统一回收。

更隐蔽的是循环里的泄漏:

void loop_leak() { for (int i = 0; i < 10000; ++i) { int* p = new int(i); // 每次循环都new一块 // 没有delete } }

每次循环分配4个字节,看着不多,但如果这个函数被频繁调用,内存占用会像滚雪球一样涨。反映到现实里就是程序越跑越慢,内存占用越来越高,最终卡死或崩溃。排查的时候还不容易定位,因为你只记得这个函数当时“跑得通”。

我自己现在的习惯是:写new的那一瞬间,就先把delete写上去,哪怕只是写一个空壳,最后再补中间的逻辑。这叫“先锁门再进屋子”,防止自己忘。

6.2 new匹配delete、new[]匹配delete[]:配对错误

第二个坑是配错。看这段比较隐蔽的错误代码:

int* arr = new int[100]; delete arr; // 应该用delete[]

很多初学者觉得new[]创建的就是一块更大的内存,delete可以把它一起释放。但前面提过,new[]的内存布局中可能需要额外记录数组长度,delete按单个对象来处理,读到的数组长度可能不对,释放过程就可能出错。在我实际见到的例子中,有的人当场没崩,但换了编译器或者换了更复杂的类型就崩了。它属于典型的未定义行为,不能用“现在没崩”来判断代码的合法性。

正确做法是让配对清单一目了然:new配delete,new[]配delete[]。如果你把一个类单独new出来了,配delete;如果把一个数组new出来了,配delete[]。也可以借助工具或者代码审查来排查,但最有效的还是写代码时保持警觉。

6.3 delete之后再delete:重复释放与悬空指针

第三个坑是重复释放。很多初学者delete一个指针后,觉得它还在,顺手又写了一次delete,或者因为逻辑复杂导致同一指针在两条分支里被各delete一次。

int* p = new int(5); delete p; // 后面某个分支又执行一次 delete p; // 重复释放,未定义行为

第一次delete之后,p变成了一个悬空指针,它指向的内存已经被归还,但p本身的值还在,依然保存着那块地址。第二次delete会对同一地址再次执行释放操作,后果完全取决于底层分配器的状态:可能直接崩溃,可能破坏堆管理结构,也可能暂无症状但内部已经被污染了。

等你排查到问题,浪费的时间已经不少了。我处理重复释放的经验很简单:每次delete之后,立刻把指针置为nullptr:

delete p; p = nullptr;

因为对nullptr执行delete是安全的,属于空操作,不会引发问题。这样即使后面误删了一次,也不会重复释放。这个习惯操作极简,却可以省下你大量后期排查时间。

6.4 malloc/new、free/delete混用:不属于你的接口

第四个坑是混用接口。比如malloc申请空间,最后却用delete释放;或者new创建对象,最后却用free释放。

int* p1 = (int*)malloc(sizeof(int)); delete p1; // 错误:malloc分配的应使用free int* p2 = new int(10); free(p2); // 错误:new分配的应使用delete

这种写法的错误在哪里?可能一时半会不崩,但malloc和new底层基于不同的分配器,配错会直接破坏内存管理结构,导致不可预测的行为。尤其是用free释放new出来的对象时,析构函数完全没被调用,如果对象内部有动态资源,那些资源就全泄漏了。

如果看到这类代码出现在项目里,不要抱着“先跑一下试试”的心态,直接改成配对的接口。

7. 排查泄漏的实用手段:从自检习惯到调试工具

7.1 写代码时的自检清单

内存问题往往不是一次就能写好的,但你可以用一套自检流程把出错概率降到最低。我每次写完涉及动态内存的代码,都会按这个顺序检查:

第一步,数一数所有的new和new[],确认每个都有对应的delete或delete[]。

第二步,检查new和delete的配对是否正确:普通对象用delete,数组用delete[]。

第三步,检查每个delete执行后是否正确置空,避免悬空指针。

第四步,检查有没有malloc和new混用的地方,确认接口统一。

第五步,想一想对象生命周期是否清晰:这个对象该在什么时候创建?谁负责释放?如果创建和释放分布在两个函数里,它们之间的约定是否明确?

这套清单花不了两分钟,但能拦截大部分初学阶段的内存错误。

7.2 用工具把泄漏“揪出来”

自检之后,还有一道防线:内存检测工具。在Linux下有一类很常用的工具,运行你的程序后会输出一份内存泄漏报告,包含泄漏的字节数和调用栈,能看到每个泄漏点在哪个文件和哪一行。基本用法是:先编译出带调试信息的可执行文件,再在终端用工具运行它,程序结束后阅读“leak summary”这一段,里面会明确告诉你多少块内存没有释放。

Windows开发环境也提供了类似的检测能力。你在调试状态下编译运行程序,程序退出时可以去输出窗口看内存泄漏报告,双击某条泄漏就能跳到对应的分配代码。打开这个选项之后,你的调试版本会记录每次new的来源,代价是运行稍慢,但排查期完全值得。

对初学者来说,最大的心理门槛是觉得用工具很麻烦。实际上把这些命令和开关记住之后,无非就是多敲一行命令、多开一个选项。它带来的信息量是你干瞪眼代码猜原因根本比不了的。

7.3 我的一个小习惯

写到最后,再分享一个小技巧。我每写完一个小程序,比如一个练习、一个小工具,都会用检查工具跑一遍,看输出报告里有没有“内存泄漏”或者“非法释放”的提示。如果报告是干净的,就可以放放心心地去写下一段逻辑。如果报告里有问题,立刻趁代码规模还小去修,因为这时候定位成本最低。

这个习惯让我在写后续那些复杂项目时从来没被莫名其妙的内存崩溃折磨过。内存管理这种东西,最怕的不是初期犯错,而是错误隐藏得很深。只要你在代码规模很小的时候就把整个流程练熟,后面遇到再大的项目,你也能够从容处理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询