去年我负责一个后台服务,跑在线上三天后内存从 2GB 涨到 8GB,最后被 OOM killer 干掉。
我第一反应是用 Valgrind 跑泄漏检测。结果出来一看,结果没有内存泄漏。
代码里没有一处 new,全部用的 std::shared_ptr。诱导我入坑的,正是这些智能指针。
没有 new,也没有泄漏报告,内存为什么还被吃掉了?
我们先看这段看起来完全没问题的代码:
struct Node {
std::string data;
std::shared_ptr<Node> next;
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a; // 循环引用!
return 0;
}

这段代码执行完,两个 Node 的内存永远不会被释放。但你用 Valgrind 检查,它不会报告任何泄漏。
因为从 Valgrind 的观点看,每个 new 都有对应的 delete。问题在于,对应的 delete 永远不会被触发。
先理解 shared_ptr 的工作原理。
std::shared_ptr 使用引用计数(reference counting)管理生命周期:
- 每当一个
shared_ptr 指向对象,计数 +1。
- 每当一个
shared_ptr 被析构或重新赋值,计数 -1。
- 当计数降到 0,自动调用
delete。
这套机制在大多数情况下工作得很好。但它有一个导致死亡的设计缺陷:循环引用导致计数永远不能变成 0。
我画了一个简单的示意图帮助理解:
内存块 A 内存块 B
[数据] [数据]
[引用计数 = 2] [引用计数 = 2]
↓ ↗
[指向 B] →→→→→→→→→→→ [next 指向 A]
a 和 a->next 都指向 A,所以 A 的计数是 2。
b 和 b->next 都持向 B,所以 B 的计数是 2。
当 main 结束时,局部变量 a 和 b 被销毁,但 a->next 和 b->next 依然存在。A 的计数变成 1,B 的计数也变成 1。
都不是 0,所以两个对象永远不会被删除。
这就是最伟大的讽刺:你用了最安全的智能指针,结果悄无声息地造成了一场无法收拾的内存泄漏。
解决方案是什么?
C++11 提供了 std::weak_ptr,专门用来打破这种循环。
struct Node {
std::string data;
std::weak_ptr<Node> next; // 用 weak_ptr 替代 shared_ptr
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a;
return 0;
}
weak_ptr 不会增加引用计数。当 main 结束时,a 和 b 被销毁,A 的计数变成 0,等于 0 就释放,B 也一样。死结打开了。
需要使用的时候,用 lock() 转换成 shared_ptr:
if (auto shared = node->next.lock()) {
shared->data = "new value";
}
这段代码是线程安全的。如果对象已经被释放,lock() 返回空指针,不会触发悬垂引用。
再讲一个更隐蔽的
有一次我的同事在做一个消息队列,用 std::vector 存消息。代码大概是这样的:
std::vector<Message> queue;
while (running) {
auto msg = receive();
queue.push_back(msg);
if (queue.size() > 1000) {
queue.erase(queue.begin(), queue.begin() + 500);
}
}
看起来没问题对吧?队列长度永远不超过 1000。
但运行了一周后,内存又爆了。
原因在于 std::vector 的容量(capacity)特性。erase 只是删除元素,不会释放多余的内存。
queue.erase(queue.begin(), queue.begin() + 500);
// size() 变成 500,但 capacity() 可能还是 2048
这不是内存泄漏……从技术上讲。但如果消息队列每天经历“增长 → 删除” 循环,capacity 会一直涨不降,内存越占越多。
修复方案是:
queue.erase(queue.begin(), queue.begin() + 500);
queue.shrink_to_fit(); // 尝试归还多余内存
或者更好的方案是不用 vector,直接用 std::deque。
面试题小结
这类问题在 C++ 面试里属于“高级内存管理”考察,出现频率越来越高。
核心要点记住:
shared_ptr 不万能:循环引用会导致引用计数永远不清零,内存永不释放。
weak_ptr 是解药:它不增加引用计数,用 lock() 安全访问。
- 执行路径很重要:Valgrind 看不出来循环引用,因为底层的 new/delete 是成对的。
vector 的 capacity 陷阱:erase 不会收缩内存,需要 shrink_to_fit 或使用 deque。
- 被动管理不代表可以放心:智能指针也需要理解其底层机制才能用对。
总结一下
C++ 中最危险的不是那些显而易见的错误——new 了忘记 delete、数组越界、空指针解引用,这些问题编译器和静态分析工具都能帮你抓出来。
最危险的是那些代码看起来正确、编译器不报错、Valgrind 看不出问题的隐蔽问题。shared_ptr 循环引用就是典型。你以为自己交给了机器,结果还是把程序送进了坑里。
被动管理不代表你可以不管不顾。智能指针不智能,智能的是使用它的人。
更多 C++ 内存管理知识,欢迎访问 云栈社区。
