找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3582

积分

0

好友

474

主题
发表于 3 小时前 | 查看: 3| 回复: 0

去年我负责一个后台服务,跑在线上三天后内存从 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;
}

shared_ptr 循环引用示意图

这段代码执行完,两个 Node 的内存永远不会被释放。但你用 Valgrind 检查,它不会报告任何泄漏。

因为从 Valgrind 的观点看,每个 new 都有对应的 delete。问题在于,对应的 delete 永远不会被触发

先理解 shared_ptr 的工作原理。

std::shared_ptr 使用引用计数(reference counting)管理生命周期:

  1. 每当一个 shared_ptr 指向对象,计数 +1。
  2. 每当一个 shared_ptr 被析构或重新赋值,计数 -1。
  3. 当计数降到 0,自动调用 delete

这套机制在大多数情况下工作得很好。但它有一个导致死亡的设计缺陷:循环引用导致计数永远不能变成 0

我画了一个简单的示意图帮助理解:

内存块 A          内存块 B
[数据]                [数据]
[引用计数 = 2]       [引用计数 = 2]
    ↓                      ↗
[指向 B] →→→→→→→→→→→ [next 指向 A]
  • aa->next 都指向 A,所以 A 的计数是 2。
  • bb->next 都持向 B,所以 B 的计数是 2。

main 结束时,局部变量 ab 被销毁,但 a->nextb->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 结束时,ab 被销毁,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++ 内存管理知识,欢迎访问 云栈社区

coding meme




上一篇:The Gentlemen勒索组织内部数据泄露全景剖析:RaaS模式、渗透手法与内网横向移动
下一篇:Chaos Mesh 混沌工程实战:从零构建韧性测试体系
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-5-17 05:29 , Processed in 0.794265 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表