C++内存泄漏远比我们想象的更隐蔽、更反直觉。很多时候,问题并非源于简单的忘记 delete,而是隐藏在看似正确的代码逻辑之下,例如混乱的资源所有权、失控的异常安全或者对底层机制的误用。
接下来,我们将深入探讨导致C++内存泄漏的十个经典场景,并给出相应的解决思路。

一、不是忘了delete,而是根本没给机会
最原始的错误往往最为致命。并非只有新手才会写 new 不写 delete。请看这段代码:
void handle_request(Request req) {
auto* ctx = new Context();
if (!auth(req)) return; // 提前返回,ctx 永远不会被释放
process(ctx, req);
delete ctx;
}
当控制流变得复杂时,资源的释放路径很容易被绕过。异常路径的情况则更为棘手:
void init() {
resource_a = malloc(1024);
risky_call(); // 抛异常,resource_a 泄漏
resource_b = new char[512];
}
解决方案:永远不要手动管理资源。使用RAII思想,问题便会自动消失:
void handle_request(Request req) {
auto ctx = std::make_unique<Context>();
if (!auth(req)) return; // 析构自动触发
process(ctx.get(), req);
}
二、释放方式不匹配
new 与 delete、new[] 与 delete[]、malloc 与 free,这些分配与释放的方式必须严格匹配成对出现。一旦混用,轻则导致未定义行为,重则损坏堆结构,引发程序崩溃。
更隐蔽的情况是跨模块分配与释放。例如在Windows平台上,如果DLL A使用 new 分配内存,DLL B使用 delete 来释放,而两个DLL都静态链接了C运行时库(CRT),那么它们各自拥有独立的堆管理器。A在堆1上分配,B却试图在堆2上释放,结果要么是内存泄漏,要么是程序崩溃。
即便使用动态链接CRT,我们也强烈建议资源的分配与释放在同一个模块内完成,以避免潜在的风险。
三、指针丢失
int* p = new int(42);
p = get_next(); // 原始地址彻底丢失
这段代码看起来犯了低级错误,但在状态机、回调链或指针数组的管理中,这种“覆盖即遗忘”的情况极为常见。
唯一可靠的解决方案是使用 std::unique_ptr。在赋值时,它会自动析构旧对象,确保地址永远不会以裸指针的形式存在。
四、异常安全
在构造函数中分配多块资源时,若中途抛出异常,这便是一个经典陷阱,已分配的资源将无法被回收。
class BadManager {
char* buf1;
char* buf2;
public:
BadManager() {
buf1 = new char[100]; // 成功
throw std::runtime_error("fail"); // buf1 永远不会被释放
buf2 = new char[200];
}
~BadManager() { delete[] buf1; delete[] buf2; }
};
此处的析构函数永远不会被调用,因为对象构造尚未完成就发生了异常。
解决方案是让所有成员都使用RAII类型。编译器会保证已成功构造的成员按照与构造相反的顺序进行析构,这正是RAII的魔力所在。
class GoodManager {
std::unique_ptr<char[]> buf1;
std::unique_ptr<char[]> buf2;
public:
GoodManager()
: buf1(std::make_unique<char[]>(100))
{
throw std::runtime_error("fail"); // buf1 自动析构
buf2 = std::make_unique<char[]>(200);
}
};
五、容器存储裸指针
std::vector<MyObj*> list;
list.push_back(new MyObj());
list.clear(); // 指针被清空,MyObj 实例永存堆上
std::vector 只管理指针本身,并不管理指针所指向的对象。更糟糕的是进行部分清理:只删除了一半,剩下的却忘了。
解决方案:容器中应存放值对象,或存放智能指针:
std::vector<std::unique_ptr<MyObj>> list;
list.push_back(std::make_unique<MyObj>());
list.clear(); // 所有对象自动释放
如果接口强制要求使用裸指针,至少要用注释明确标明所有权(例如“调用方负责释放”),并将其封装在RAII包装器中。
六、三五法则处理不当
当自定义类管理堆内存时,如果只编写了析构函数,却没有正确处理拷贝或移动语义,就会引发双重释放或内存泄漏。
class String {
char* data;
public:
String(const char* s) { data = new char[strlen(s)+1]; strcpy(data, s); }
~String() { delete[] data; }
// 缺少拷贝构造 → 浅拷贝 → 双重 delete
};
不过在现代C++中,如果你的成员变量全部是RAII类型,编译器会自动生成正确的移动语义,你甚至不需要编写析构函数。
七、非虚析构函数
class Base { public: ~Base() {} };
class Derived : public Base {
char* extra = new char[100];
public:
~Derived() { delete[] extra; }
};
Base* p = new Derived();
delete p; // 只调用 Base::~Base(),Derived 部分既不析构,内存也不完整释放
这不仅仅是派生类资源泄漏的问题,更是未定义行为。堆管理器可能只释放了 sizeof(Base) 字节,而 Derived 实际占用更多内存,多出来的字节将永远卡在堆中,形成内存碎片。
因此,只要一个类有可能被继承,其析构函数就必须声明为 virtual。
八、shared_ptr 循环引用
struct Node {
std::shared_ptr<Node> parent;
std::vector<std::shared_ptr<Node>> children;
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->children.push_back(b);
b->parent = a; // 循环形成,引用计数永不归零
由于对象之间相互持有强引用,引用计数永远不会降为零。像AddressSanitizer (ASan) 和 Valgrind 这类工具通常无法检测出这种泄漏。它如同慢性毒药,导致进程的私有脏内存(Private_Dirty)缓慢增长,最终可能引发内存耗尽。
解决方案是使用 std::weak_ptr。但需要注意,并非所有反向引用都必须使用 weak_ptr,如果父节点的生命周期明确长于子节点,使用 shared_ptr 仍然是安全的。
九、自定义删除器使用错误
void* raw = malloc(100);
auto ptr = std::shared_ptr<void>(raw); // 默认使用 delete,但 raw 是 malloc 分配的
这将导致未定义行为。正确的写法是指定匹配的释放函数:
auto ptr = std::shared_ptr<void>(raw, free);
同理,对于COM对象、CUDA显存、mmap映射区域等,都必须使用其对应的正确释放函数。
智能指针并非万能,必须显式指定与分配方式匹配的删除器。
十、误解第三方API约定
许多C语言库函数返回的指针需要调用方负责释放,例如 strdup 返回的字符串必须用 free 释放;而 sqlite3_column_text 返回的指针则由 sqlite3_finalize 管理。混淆这些规则就会导致泄漏。
COM组件模型是另一个典型:每次 AddRef 必须对应一次 Release,少一次就等同于对象泄漏。
应对策略是用RAII封装所有外部资源:
template<typename T>
class ComPtr {
T* p_;
public:
explicit ComPtr(T* p = nullptr) : p_(p) {}
~ComPtr() { if (p_) p_->Release(); }
// 禁用拷贝,或实现引用计数转移
};
需要纳入检查清单的4条核心规则
为了系统性减少内存泄漏,建议将以下4条规则写入团队的代码审查清单。
- RAII是底线:能使用
unique_ptr、shared_ptr、vector、string,就绝不直接操作裸指针。
- 所有权要清晰:在接口设计时,必须明确注明“谁创建、谁销毁”。
- 用 weak_ptr 打破循环引用:只要存在对象间双向持有的可能,就默认将其中一方设为弱引用。
- 工具兜底:在开发阶段启用AddressSanitizer (ASan)/LeakSanitizer (LSan),在持续集成(CI)中加入泄漏回归测试,在压力测试时监控
/proc/[PID]/smaps 中的 Private_Dirty 数据。
最健壮的C++项目,往往遵循着“从不使用裸new,所有资源都有明确唯一的所有者”的原则,即使在嵌入式设备上也能实现零泄漏。
以上是对内存管理中常见陷阱的梳理。你在进行代码审查时,最常发现的是裸指针问题,还是 shared_ptr 循环引用呢?欢迎在技术社区交流你的实践经验。