在 C++ 中,如果程序动态申请了一块堆内存,但在使用完毕后忘记释放,或者弄丢了这块内存的地址,导致该空间一直无法归还给操作系统,这种现象就称为 “内存泄漏(Memory Leak)”。
内存泄漏刚发生时往往不容易察觉。如果只是在一个几秒钟就运行结束的小程序里漏释放了一个整数,我们几乎感觉不到任何异常。
但如果这段代码放在循环里反复执行,或者运行在需要长期工作的服务器、桌面软件中,白白占着不放的内存就会越积越多,最终耗尽系统内存并导致程序崩溃。
常见的内存泄漏场景
平时在使用 new 和 delete 管理堆内存时,之所以会发生内存泄漏,往往不是因为我们不知道要释放内存,而是当指针指向改变或函数出现多个退出分支时,不小心漏掉了释放步骤。下面我们来看看最常见的 3 种场景。
1. 忘记执行 delete
最直接的一种情况,是在函数内部通过 new 创建了动态对象,使用完毕后却忘记在函数结束前执行 delete。由于 C++ 发生内存泄漏时不会像语法错误那样直接报错,我们可以通过观察类的析构函数有没有执行,来判断堆对象是否正常销毁。
示例 1:忘记释放堆对象
#include <iostream>
class Ticket {
private:
int id;
public:
Ticket(int n) : id(n) {
std::cout << "创建票据:" << id << "\n";
}
~Ticket() {
std::cout << "销毁票据:" << id << "\n";
}
};
void processTicket(int id) {
Ticket* p = new Ticket(id);
std::cout << "处理票据:" << id << "\n";
// 漏写 delete p;
}
int main() {
processTicket(1);
processTicket(2);
return 0;
}运行结果如下。
创建票据:1
处理票据:1
创建票据:2
处理票据:2分析:
在这个例子中,程序连续调用了两次 processTicket(),控制台依次输出了 1 号和 2 号票据的创建与处理信息。但是直到整个程序运行结束,都没有输出任何一行 “销毁票据”。
这是因为 processTicket() 函数末尾漏写了 delete p。每次函数执行结束时,栈上的局部指针 p 会自动销毁,但它指向的 Ticket 堆对象并不会跟着释放。
一旦 p 销毁了,程序里就再也没有变量保存那块堆内存的地址,我们也彻底失去了释放它的机会。要想修复这个问题,只需要在函数结束前补上对应的 delete 语句即可:
void processTicket(int id) {
Ticket* p = new Ticket(id);
std::cout << "处理票据:" << id << "\n";
delete p; // 用完及时释放
}提示 除了在当前函数里直接写 new 之外,如果某个函数内部通过 new 创建了堆对象,并把指针作为返回值交给我们,那么在使用完毕后同样需要由我们来执行 delete,否则也会造成内存泄漏。
2. 覆盖指针原有的地址
第二种情况要更隐蔽一些:代码里明明写了 delete,却依然发生了内存泄漏。这是因为指针原本保存着第一个堆对象的地址,但在释放它之前,我们又把另一个新地址赋给了同一个指针,导致第一个对象的地址丢失了。
示例 2:覆盖指针导致泄漏
#include <iostream>
class Ticket {
private:
int id;
public:
Ticket(int n) : id(n) {
std::cout << "创建票据:" << id << "\n";
}
~Ticket() {
std::cout << "销毁票据:" << id << "\n";
}
};
int main() {
Ticket* p = new Ticket(1);
p = new Ticket(2); // 直接覆盖原地址
delete p;
return 0;
}运行结果如下。
创建票据:1
创建票据:2
销毁票据:2分析:
从运行结果可以看出,1 号票据和 2 号票据都创建出来了,但最后只有 2 号票据正常销毁,1 号票据发生了内存泄漏。
问题就出在 p = new Ticket(2) 这句代码上。它把 2 号票据的新地址直接赋给了指针 p,将 p 原本保存的 1 号票据地址覆盖掉了。等到后面执行 delete p 时,销毁的只有 2 号票据,而 1 号票据因为弄丢了地址,只能一直留在堆内存中。
因此,当一个指针正独自管理某块堆内存时,在改变它的指向之前,我们必须先释放掉原来的旧对象:
// 错误方式
Ticket* p = new Ticket(1);
p = new Ticket(2);
delete p;
// 正确方式
Ticket* p = new Ticket(1);
delete p; // 先销毁 1 号票据
p = new Ticket(2); // 再指向 2 号票据
delete p; // 最后销毁 2 号票据3. 函数提前 return
第三种情况在实际项目中最常见:我们在函数开头写了 new,也在函数最后一行写了 delete。表面上看配对很完整,但如果函数中间的 if 条件成立触发了提前 return,程序就会直接跳出函数,根本走不到最后那行 delete。
示例 3:提前返回跳过 delete
#include <iostream>
class Ticket {
private:
int id;
public:
Ticket(int n) : id(n) {
std::cout << "创建票据:" << id << "\n";
}
~Ticket() {
std::cout << "销毁票据:" << id << "\n";
}
};
void checkTicket(int id, bool hasError) {
Ticket* p = new Ticket(id);
if (hasError) {
std::cout << id << " 号票据异常,提前退出\n";
return; // 跳过底部的 delete
}
std::cout << id << " 号票据处理完成\n";
delete p;
}
int main() {
checkTicket(1, true);
checkTicket(2, false);
return 0;
}运行结果如下。
创建票据:1
1 号票据异常,提前退出
创建票据:2
2 号票据处理完成
销毁票据:2分析:
对比两次调用的结果可以看到:第一次传入 true 时,程序在 if 分支直接 return 退出了函数,跳过了底部的 delete p,导致 1 号票据发生内存泄漏。第二次传入 false 时,函数顺利执行到末尾,2 号票据才正常销毁。
如果采用手动管理方式,要想补上这个漏洞,我们就必须在提前 return 之前也加上一句 delete p:
if (hasError) {
std::cout << id << " 号票据异常,提前退出\n";
delete p; // 退出前先释放内存
return;
}看到这里,小伙伴们就能体会到手动管理堆内存的痛点了:如果一个复杂函数里有五六个退出分支,我们就得在每一个出口前逐个检查有没有漏写 delete。
为了解决这个麻烦,现代 C++ 通常采用 RAII 或智能指针,把堆内存交给栈对象保管,离开函数时就能自动释放内存。
内存泄漏与悬空指针的区别
很多初学的小伙伴容易把 “内存泄漏” 和悬空指针搞混,其实它们发生的原因正好相反。
我们可以拿住酒店来打个比方:内存泄漏像是 “客人已经离开,却忘了办退房手续,房间一直白白占着”。而悬空指针则是 “房间已经退掉收回了,手里却还拿着旧房卡去开门”,如下图所示。

我们可以通过以下 2 点把这两者区分清楚:
- 内存泄漏:堆对象一直没有销毁。原因是漏写
delete或丢失了堆对象的地址,导致可用内存不断减少,严重时会耗尽系统内存。 - 悬空指针:堆对象已经销毁了,但指针还留着旧地址。原因是释放内存后没有将指针置空,后续又通过旧指针去读写数据,导致读出乱码或引发程序崩溃。
简单来说,预防内存泄漏的关键是保证动态申请的内存都能及时释放,或者直接交给智能指针管理。而预防悬空指针的关键,则是在手动执行 delete 之后立即将原指针赋值为 nullptr。
常见问题
1. 程序运行期间占用的内存很高,就一定是发生了内存泄漏吗?
不一定。“内存占用高” 和 “内存泄漏” 是两码事,判断的关键不在于当前用了多少内存,而在于不再使用的内存能不能正常归还:
正常的内存占用高:比如图像软件同时打开了十几张高清大图,或者游戏加载了大型地图场景。只要这些数据在当前阶段确实需要使用,并且在关闭图片或切换场景后内存占用能够正常回落,就不算内存泄漏。
真正的内存泄漏:哪怕每次函数调用只漏掉几十个字节,只要这块内存已经不再使用且失去了释放途径,随着程序成千上万次循环运行,内存占用量只增不减、无法回落,这就属于内存泄漏。
2. 程序运行结束时操作系统不是会自动回收内存吗?为什么还要处理内存泄漏?
当整个程序退出时,现代操作系统确实会收回该程序占用的全部内存空间,但这并不代表我们可以放任内存泄漏。一方面,操作系统直接回收内存时不会调用 C++ 对象的析构函数,如果析构函数里写有保存数据、关闭连接等收尾操作,这些代码都会直接跳过。
另一方面,很多功能函数在后续开发中都会放进循环或长期运行的软件里反复调用。平时养成及时释放内存或使用智能指针的好习惯,才能避免把内存隐患带进真实项目。
