【C++ 知识点回顾】C++ 工程中真正常见的内存泄漏:不是忘记 delete,而是生命周期失控
目录标题
- C++ 工程中真正常见的内存泄漏:不是忘记 delete,而是生命周期失控
- 一、工程里的内存泄漏不只有一种
- 二、`shared_ptr` 滥用导致对象无法释放
- 三、`shared_ptr` 循环引用:最经典的隐性泄漏
- 四、回调注册后没有解除,导致对象被长期持有
- 五、异步任务持有对象,导致对象生命周期被拉长
- 六、线程没有退出,导致资源一直无法释放
- 七、缓存无限增长:最常见的“逻辑性泄漏”
- 八、队列堆积:生产速度大于消费速度
- 九、`vector::clear()` 不等于释放内存
- 十、内存池和对象池:看起来像泄漏,但不一定是泄漏
- 十一、单例和全局对象导致释放时机不清楚
- 十二、析构函数没有执行,才是定位问题的关键线索
- 十三、工具能发现一部分问题,但不能替代生命周期设计
- 十四、如何写出不容易泄漏的 C++ 代码?
- 十五、总结:C++ 内存泄漏的本质是生命周期问题
- 结语

C++ 工程中真正常见的内存泄漏:不是忘记 delete,而是生命周期失控
很多人一提到 C++ 内存泄漏,第一反应就是:
new 之后忘记 delete
但在真实工程里,这类问题反而不是最难的。
因为它简单、直接、工具容易发现,代码审查也容易看出来。真正麻烦的是另一类问题:
对象表面上没有泄漏,指针也还在,程序也没有崩溃,但它就是一直不释放,内存越来越高。
这种问题通常不是“忘记释放”,而是生命周期设计不清楚。
换句话说,C++ 工程里真正高频、难查的内存问题,往往不是内存丢了,而是对象被某些地方“悄悄持有”了,导致它永远活着。
一、工程里的内存泄漏不只有一种
从实际现象看,C++ 项目里的内存问题大概可以分成三类。
1. 真正的泄漏
对象已经无法访问,也无法释放。
这种是最传统的泄漏,工具通常比较容易查到。
2. 生命周期泄漏
对象明明理论上该销毁了,但还有某个地方持有它,导致析构函数迟迟不执行。
这类问题在工程中更常见,比如:
shared_ptr循环引用- 回调中心持有对象
- 异步任务持有对象
- 线程没有退出
- 全局管理器没有清理
这类问题最麻烦,因为从工具角度看,对象仍然“可达”,不一定会被直接判定为泄漏。
3. 逻辑性内存增长
内存一直涨,但不是传统意义上的泄漏。
比如:
- 缓存只增不删
- 队列消费速度低于生产速度
vector清空后容量没有释放- 内存池回收了对象,但没有还给系统
- 日志、消息、任务堆积
这类问题在服务端、嵌入式、车载系统、GUI 程序里都很常见。
二、shared_ptr 滥用导致对象无法释放
现代 C++ 很多项目会用智能指针,但智能指针并不等于不会泄漏。
其中最常见的问题是:
该用
unique_ptr的地方用了shared_ptr,导致对象所有权变得模糊。
比如一个对象本来只应该由管理器持有:
class Manager {
public:
std::shared_ptr<Task> createTask();
};
如果返回的是 shared_ptr,调用方可能会长期保存这个对象。
结果就是:
- 管理器认为任务已经结束
- 业务认为任务可以清理
- 但某个模块还拿着
shared_ptr - 析构函数一直不执行
这种问题表面上没有泄漏,因为引用计数确实还存在。
但从业务角度看,它就是泄漏。
更好的原则是:
默认使用
unique_ptr表达独占所有权,只有确实需要共享生命周期时,才使用shared_ptr。
例如:
std::unique_ptr<Task> createTask();
这表示对象只有一个明确拥有者。
如果只是访问对象,可以使用引用或裸指针:
void process(Task& task);
void observe(Task* task);
裸指针并不一定危险,危险的是裸指针表达了所有权。
在现代 C++ 中,裸指针更适合作为“观察者”:
Task* task
它的含义应该是:
我只是看一下这个对象,不负责释放,也不延长生命周期。
三、shared_ptr 循环引用:最经典的隐性泄漏
shared_ptr 使用引用计数管理对象。
只要引用计数不为 0,对象就不会析构。
问题是,如果两个对象互相持有 shared_ptr,它们的引用计数就永远不会归零。
struct Session;
struct Connection;
struct Session {
std::shared_ptr<Connection> conn;
};
struct Connection {
std::shared_ptr<Session> session;
};
当外部不再持有 Session 和 Connection 时,它们本该销毁。
但由于:
Session持有ConnectionConnection又持有Session
它们互相保活,谁也不会释放。
解决方式通常是让其中一方使用 weak_ptr:
struct Session {
std::shared_ptr<Connection> conn;
};
struct Connection {
std::weak_ptr<Session> session;
};
weak_ptr 不增加引用计数,只表示“我知道这个对象,但不负责延长它的生命周期”。
在实际项目中,常见模式是:
- 父对象持有子对象:
shared_ptr或unique_ptr - 子对象回看父对象:
weak_ptr - 管理器持有对象:强引用
- 回调、观察者、临时访问:弱引用或裸指针
一句话总结:
双向关系里,不能两边都用强引用。
四、回调注册后没有解除,导致对象被长期持有
很多 C++ 项目都会有事件系统、消息中心、观察者模式。
比如:
eventBus.subscribe("onMessage", callback);
问题是,回调函数经常会捕获对象。
auto self = shared_from_this();
eventBus.subscribe("onMessage", [self](const Message& msg) {
self->handleMessage(msg);
});
这段代码很常见,也很危险。
因为 lambda 捕获了 self,也就是一个 shared_ptr。
只要 eventBus 还保存这个回调,self 的引用计数就不会归零。
结果是:
- 对象注册了回调
- 回调中心保存 lambda
- lambda 捕获
shared_ptr shared_ptr保活对象- 对象析构不了
- 析构不了就没机会取消注册
这就形成了一个生命周期闭环。
更安全的写法是捕获 weak_ptr:
std::weak_ptr<MyObject> weakSelf = shared_from_this();
eventBus.subscribe("onMessage", [weakSelf](const Message& msg) {
if (auto self = weakSelf.lock()) {
self->handleMessage(msg);
}
});
这样事件中心不会强行保活对象。
对象如果已经销毁,weakSelf.lock() 会失败,回调直接跳过。
这类问题在以下场景非常常见:
- GUI 事件回调
- 网络连接回调
- 定时器回调
- 消息总线
- 观察者模式
- 异步任务框架
- 信号槽机制
五、异步任务持有对象,导致对象生命周期被拉长
异步编程里也很容易出现类似问题。
例如:
void Worker::start()
{
auto self = shared_from_this();
threadPool.post([self] {
self->doWork();
});
}
这段代码本身不一定错。
因为异步任务执行期间,确实需要保证对象还活着。
但问题是,如果任务队列积压,或者任务永远不执行,self 就会一直存在。
更严重的是,如果任务内部又继续投递任务:
void Worker::doWork()
{
auto self = shared_from_this();
threadPool.post([self] {
self->doWork();
});
}
这就可能形成持续保活。
对象表面上没有任何外部引用,但异步任务队列一直持有它。
这种问题尤其容易出现在:
- 线程池
- 定时任务
- 网络重连
- 心跳任务
- 重试机制
- 异步状态机
解决这类问题时,核心不是简单地避免 shared_ptr,而是要明确:
异步任务是否真的应该延长对象生命周期?
如果只是尝试执行一次,可以使用 weak_ptr:
std::weak_ptr<Worker> weakSelf = shared_from_this();
threadPool.post([weakSelf] {
if (auto self = weakSelf.lock()) {
self->doWork();
}
});
如果任务必须保证对象存活,则应该有明确的取消机制:
worker->stop();
threadPool.cancel(workerId);
否则异步系统很容易变成对象的“隐藏所有者”。
六、线程没有退出,导致资源一直无法释放
在 C++ 工程中,线程生命周期失控也是一种常见的内存问题。
比如一个对象启动了后台线程:
class Worker {
public:
void start() {
thread_ = std::thread([this] {
while (running_) {
process();
}
});
}
private:
bool running_ = true;
std::thread thread_;
};
如果对象销毁时没有正确停止线程,就可能出现几个问题:
- 线程继续运行,访问已经销毁的对象
- 对象为了避免被销毁,被某些机制长期持有
- 线程内部资源无法释放
- 线程栈、任务队列、缓存一直存在
更好的方式是让析构过程负责停止线程:
class Worker {
public:
~Worker() {
stop();
}
void stop() {
running_ = false;
if (thread_.joinable()) {
thread_.join();
}
}
private:
std::atomic_bool running_{true};
std::thread thread_;
};
C++20 之后可以考虑 std::jthread,它比 std::thread 更适合表达自动管理线程生命周期。
线程相关问题的关键是:
创建线程容易,难的是定义它什么时候结束、谁来结束、结束前如何释放资源。
如果线程生命周期没有设计清楚,内存泄漏往往只是表象,真正的问题是任务生命周期失控。
七、缓存无限增长:最常见的“逻辑性泄漏”
很多线上程序内存上涨,并不是因为对象丢了,而是缓存没有边界。
比如:
std::unordered_map<std::string, UserInfo> userCache;
如果程序不断插入:
userCache[userId] = userInfo;
但从来不淘汰,内存自然会越来越高。
这类问题严格来说不是传统内存泄漏,因为数据仍然在 map 里,程序也能访问它。
但从系统运行效果看,它和内存泄漏非常像。
常见场景包括:
- 用户缓存
- 消息缓存
- 图片缓存
- 配置缓存
- 连接缓存
- 任务结果缓存
- 失败重试队列
- 日志缓冲区
缓存设计一定要有边界。
至少要考虑:
| 维度 | 问题 |
|---|---|
| 容量限制 | 最多缓存多少条? |
| 时间限制 | 多久不用就清理? |
| 内存限制 | 最多占多少内存? |
| 淘汰策略 | LRU、FIFO,还是按优先级? |
| 清理时机 | 定时清理,还是插入时清理? |
一个没有淘汰策略的缓存,本质上就是慢性内存泄漏。
八、队列堆积:生产速度大于消费速度
还有一种很常见的内存增长来自队列。
比如:
std::queue<Message> queue;
生产者不断写入:
queue.push(msg);
消费者不断处理:
queue.pop();
如果生产速度长期大于消费速度,队列会持续增长。
这类问题在通信系统、日志系统、消息系统里非常常见。
比如:
- 网络消息接收太快
- 日志写文件太慢
- 图像帧处理不过来
- 任务线程池处理能力不足
- 下游模块阻塞,导致上游堆积
这不是传统泄漏,但会造成内存持续上涨。
解决方式通常包括:
- 设置队列最大长度
- 超过阈值后丢弃低优先级数据
- 使用背压机制
- 增加消费者处理能力
- 合并重复消息
- 对实时数据只保留最新值
例如对于视频帧、感知数据、状态刷新类消息,很多时候不应该无限排队。
如果旧数据已经过期,继续处理反而没有意义。
这时可以使用“只保留最新值”的策略:
latestFrame = frame;
而不是:
frameQueue.push(frame);
在实时系统中,积压本身就是问题。
九、vector::clear() 不等于释放内存
很多人会误以为:
vec.clear();
之后内存就释放了。
实际上,clear() 只是清空元素数量,通常不会释放底层容量。
比如:
std::vector<int> vec;
vec.reserve(1000000);
vec.clear();
此时:
vec.size() == 0
但:
vec.capacity()
可能仍然是 1000000。
这在高峰期数据量很大的场景里很常见。
比如程序某一刻处理了大量数据,vector 扩容到很大。之后业务量下降,size() 变小了,但 capacity() 仍然保留。
从外部看,进程内存没有降下来。
这不一定是泄漏,而是容器保留容量以便下次复用。
如果确实希望释放容量,可以使用:
std::vector<int>().swap(vec);
或者:
vec.clear();
vec.shrink_to_fit();
不过要注意,频繁释放和重新申请也会带来性能开销。
所以这里要看场景:
- 如果后面还会复用,保留容量是合理的
- 如果只是一次性峰值,应该考虑释放
- 如果内存敏感,就要主动控制容量
类似问题也存在于:
std::string
std::vector
std::deque
std::unordered_map
尤其是 unordered_map,清空元素后桶数量也可能不会立刻缩小。
十、内存池和对象池:看起来像泄漏,但不一定是泄漏
很多 C++ 项目会使用内存池或对象池来提高性能。
对象释放时,并不直接还给系统,而是回收到池子里。
例如:
objectPool.release(obj);
这不代表内存真的还给操作系统,而是留给后续复用。
所以你可能看到:
- 业务对象已经释放
- 池子里也显示空闲
- 但进程内存没有下降
这不一定是 bug。
因为内存池的目的本来就是减少频繁申请和释放。
但是内存池也可能出问题。
常见问题有:
- 池子只扩不缩
- 峰值过后没有回收策略
- 不同规格内存块碎片化严重
- 对象归还失败
- 对象池里对象状态没有重置
- 池子生命周期过长
所以判断内存池是否有问题,不能只看进程 RSS,而要看:
- 池子当前容量
- 已使用数量
- 空闲数量
- 峰值容量
- 是否允许收缩
- 是否存在长期无法归还的对象
内存池本身不是泄漏,但一个没有上限、没有收缩策略的内存池,可能会变成另一种形式的内存黑洞。
十一、单例和全局对象导致释放时机不清楚
很多工程里会有全局管理器:
Logger::instance()
ConfigManager::instance()
ConnectionManager::instance()
单例本身不一定有问题,但它经常让资源生命周期变得模糊。
例如:
class Manager {
public:
void add(std::shared_ptr<Object> obj) {
objects_.push_back(obj);
}
private:
std::vector<std::shared_ptr<Object>> objects_;
};
如果这个 Manager 是全局单例,那么它持有的对象也可能一直活到进程结束。
结果是:
- 业务流程结束了
- 模块认为对象应该释放
- 但单例里还保存着引用
- 对象直到进程退出才释放
这类问题在测试环境尤其明显。
比如单元测试中,某个全局对象没有清理,导致下一个测试用例受到影响。
解决方式包括:
- 给单例提供明确的
clear()或shutdown()接口 - 避免单例持有复杂对象所有权
- 模块退出时主动解除注册
- 测试用例结束时清理全局状态
全局对象最大的问题不是它存在,而是它让资源释放时机变得不透明。
十二、析构函数没有执行,才是定位问题的关键线索
排查这类内存问题时,一个非常有效的思路是:
不要只问“哪里申请了内存”,要问“为什么析构函数没有执行”。
对于 C++ 对象来说,析构函数是否执行,是判断生命周期是否结束的重要信号。
可以在关键对象里临时加日志:
class Session {
public:
Session() {
LOG_INFO("Session create");
}
~Session() {
LOG_INFO("Session destroy");
}
};
如果创建日志很多,但销毁日志很少,就说明对象生命周期异常。
继续追踪:
- 谁持有了这个对象?
- 引用计数是多少?
- 是否注册了回调?
- 是否进入了全局容器?
- 是否被异步任务捕获?
- 是否存在循环引用?
- 是否有线程还在使用它?
对于 shared_ptr,也可以临时观察:
LOG_INFO("use_count = {}", ptr.use_count());
虽然 use_count() 不适合作为正式业务逻辑依据,但调试时可以帮助判断对象是否被异常持有。
十三、工具能发现一部分问题,但不能替代生命周期设计
内存工具很重要,比如:
- ASan
- LSan
- Valgrind
- heaptrack
- massif
- pprof
- perf
- top / pmap / smaps
但工具不是万能的。
因为有些内存增长从工具角度看是“合法的”。
例如:
cache[userId] = userData;
工具不会说这是泄漏。
因为这块内存仍然被程序持有。
再比如:
eventBus.subscribe(callback);
工具也不会自动知道这个 callback 已经过期。
所以工具能回答的是:
内存在哪里分配?
但工程师还要回答:
这块内存为什么还活着?它是否应该继续活着?
这就是 C++ 内存问题真正考验人的地方。
不是会不会释放,而是能不能设计清楚对象生命周期。
十四、如何写出不容易泄漏的 C++ 代码?
可以总结成几条工程原则。
1. 默认使用明确所有权
优先级一般是:
局部对象 > unique_ptr > shared_ptr
不要一上来就 shared_ptr。
shared_ptr 方便,但会隐藏所有权关系。
2. 双向关系必须有弱引用
如果 A 持有 B,B 又需要访问 A,那么通常应该是:
A -> shared_ptr/unique_ptr -> B
B -> weak_ptr/raw pointer -> A
不能两边都是强引用。
3. 回调注册必须有解除机制
注册和注销应该成对出现。
可以使用 RAII token:
auto token = eventBus.subscribe(...);
当 token 析构时,自动取消订阅。
这比手动 unsubscribe() 更安全。
4. 异步任务不要无脑捕获 shared_ptr
捕获 shared_ptr 会延长对象生命周期。
有些场景是必要的,但要明确知道它的后果。
如果只是尝试执行,优先考虑 weak_ptr。
5. 缓存和队列必须有上限
任何长期运行的系统,都不应该有无限增长的数据结构。
尤其是:
map
unordered_map
queue
vector
list
只要它长期存在,就必须考虑边界。
6. 模块退出时要有 shutdown 流程
复杂模块不能只依赖析构函数。
通常需要显式关闭流程:
stop();
unsubscribe();
clear();
join();
release();
尤其是有线程、回调、异步任务、连接、缓存的模块。
十五、总结:C++ 内存泄漏的本质是生命周期问题
真实 C++ 工程里的内存泄漏,最值得关注的不是简单的 new/delete 问题,而是这些:
| 类型 | 本质问题 |
|---|---|
shared_ptr 滥用 |
所有权变模糊 |
| 循环引用 | 引用计数无法归零 |
| 回调未注销 | 对象被事件系统持有 |
| 异步任务捕获对象 | 生命周期被任务队列延长 |
| 线程未退出 | 资源释放流程不完整 |
| 缓存无限增长 | 没有容量和淘汰策略 |
| 队列堆积 | 生产消费失衡 |
| 容器容量不释放 | 内存被保留复用 |
| 内存池只扩不缩 | 峰值容量长期存在 |
| 单例长期持有对象 | 释放时机不透明 |
C++ 内存管理的核心,不是简单地记住“申请了就释放”,而是要回答三个问题:
- 谁拥有这个对象?
- 谁可以临时访问它?
- 它什么时候必须结束生命周期?
只要这三个问题没有设计清楚,哪怕全项目都用了智能指针,也一样会出现内存泄漏。
真正高质量的 C++ 代码,不是到处使用 shared_ptr,也不是到处手动释放,而是让每个对象的生命周期都清晰、可控、可验证。
结语
在我们的编程学习之旅中,理解是我们迈向更高层次的重要一步。然而,掌握新技能、新理念,始终需要时间和坚持。从心理学的角度看,学习往往伴随着不断的试错和调整,这就像是我们的大脑在逐渐优化其解决问题的“算法”。
这就是为什么当我们遇到错误,我们应该将其视为学习和进步的机会,而不仅仅是困扰。通过理解和解决这些问题,我们不仅可以修复当前的代码,更可以提升我们的编程能力,防止在未来的项目中犯相同的错误。
我鼓励大家积极参与进来,不断提升自己的编程技术。无论你是初学者还是有经验的开发者,我希望我的博客能对你的学习之路有所帮助。如果你觉得这篇文章有用,不妨点击收藏,或者留下你的评论分享你的见解和经验,也欢迎你对我博客的内容提出建议和问题。每一次的点赞、评论、分享和关注都是对我的最大支持,也是对我持续分享和创作的动力。
最后,想特别推荐一下我出版的书籍——《C++编程之禅:从理论到实践》。这是对博主C++ 系列博客内容的系统整理与升华,无论你是初学者还是有经验的开发者,都能在书中找到适合自己的成长路径。从C语言基础到C++20前沿特性,从设计哲学到实际案例,内容全面且兼具深度,更加入了心理学和禅宗哲理,帮助你用更好的心态面对编程挑战。
本书目前已在京东、当当等平台发售,推荐前往“清华大学出版社京东自营官方旗舰店”选购,支持纸质与电子书双版本。希望这本书能陪伴你在C++学习和成长的路上,不断精进,探索更多可能!感谢大家一路以来的支持和关注,期待与你在书中相见。
阅读我的CSDN主页,解锁更多精彩内容:泡沫的CSDN主页
更多推荐

所有评论(0)