29. 内存泄漏常见模式:全局/静态容器持有指针

说实话,全局变量和静态变量在C++里就像家里的杂物间——你总觉得以后会用上,结果越堆越多,最后连门都关不上。我见过太多项目,内存泄漏的根源就是某个全局容器里塞满了指针,程序退出了,容器析构了,但里面的指针指向的资源却没人管。

今天咱们就聊聊这个经典问题。你想想看,一个全局的 std::vector<Widget*>,或者一个静态的 std::map<int, Resource*>,它们生命周期贯穿整个程序。如果你只是往里塞 new 出来的对象,却从不 delete,那泄漏几乎是必然的。

问题根源:谁负责清理?

全局/静态容器本身会析构,但容器只负责销毁它存储的元素——也就是指针本身。指针指向的那块内存,容器不管。这就好比一个储物柜,柜子坏了会被搬走,但柜子里的东西没人收拾。

核心矛盾:容器析构时,不会自动调用 delete 释放指针指向的内存。这是C++所有权的经典问题——容器持有指针,但容器不拥有指针指向的对象。

我在项目中遇到过这样一个案例:一个后台服务,用全局的 std::list<Task*> 来管理所有待处理任务。每次新任务来了就 new Task 塞进去,处理完了从列表里移除指针,但忘了 delete。服务跑了三天,内存占用从 200MB 涨到了 2GB。嗯,这就是典型的「只进不出」。

常见泄漏场景

我总结了几种最容易踩坑的模式,你看看自己中过几个:

模式 代码示例 泄漏原因
全局容器存裸指针 static std::vector<Foo*> g_list; 程序退出时,容器析构但指针指向的内存未释放
单例类持有指针容器 Singleton::instance().getCache() 单例生命周期长,容器内指针从未清理
静态局部变量容器 static std::map<int, Bar*> s_map; 函数多次调用,容器不断增长但旧指针未释放
模块级全局注册表 extern std::set<Handler*> g_handlers; 注册时 new,但注销时只移除指针不 delete

为什么容易忽略?

说白了,全局/静态容器的生命周期太长,你很难在代码里直观地看到「谁该负责释放」。局部变量的泄漏,跑一次单元测试可能就发现了。但全局容器的泄漏,往往要等到程序跑上几个小时甚至几天,内存暴涨时才暴露。

我曾经接手过一个遗留系统,里面有个全局的 std::unordered_map<std::string, Session*>,用来管理用户会话。每次用户登录就 new 一个 Session 对象塞进去,登出时从 map 里 erase 掉指针。看起来没问题对吧?但问题是,如果用户异常断开(比如网络闪断),登出逻辑没执行,那个 Session 指针就永远留在 map 里了。更糟的是,没人去 delete 它。结果就是,线上服务每过一周就得重启一次,不然内存就爆了。

注意:即使你在程序退出时遍历容器去 delete 所有指针,也不能完全解决问题。因为程序可能被异常终止(比如 SIGKILL),析构函数根本不会执行。更可靠的做法是:从一开始就避免在全局/静态容器中存储裸指针。

解决方案:用智能指针替代裸指针

解决这个问题,最直接的办法就是——别用裸指针。用 std::shared_ptrstd::unique_ptr 包装一下,让容器自动管理生命周期。

// 错误做法:裸指针,容易泄漏
static std::vector<Widget*> g_widgets;

void addWidget() {
    g_widgets.push_back(new Widget());
}
// 程序退出时,g_widgets 里的 Widget 对象全部泄漏

// 正确做法:使用 shared_ptr
static std::vector<std::shared_ptr<Widget>> g_widgets;

void addWidget() {
    g_widgets.push_back(std::make_shared<Widget>());
}
// 程序退出时,g_widgets 析构,shared_ptr 引用计数归零,自动 delete

我个人习惯用 std::shared_ptr 来处理全局容器,因为多个模块可能同时持有同一个对象的引用。如果你确定对象只有一个所有者,用 std::unique_ptr 更轻量。

避坑指南:weak_ptr 解决循环引用

这里有个坑要注意。全局容器用 shared_ptr 虽然解决了泄漏问题,但可能引入循环引用。比如,容器里的对象 A 持有一个 shared_ptr<B>,而 B 又持有一个 shared_ptr<A>,那这两个对象就永远无法释放了。

我曾经在做一个缓存系统时踩过这个坑。全局缓存容器里存的是 shared_ptr<CacheEntry>,每个 CacheEntry 又持有一个指向缓存管理器的 shared_ptr。结果缓存管理器析构了,但 CacheEntry 还活着,因为全局容器还持有它们的引用。而 CacheEntry 又持有管理器的引用,导致管理器也无法析构。最后整个程序退出时,一堆对象泄漏。

解决方案很简单:把其中一个方向的引用改成 weak_ptr。比如,CacheEntry 持有管理器的 weak_ptr,使用时再 lock 提升为 shared_ptr

class CacheManager;

class CacheEntry {
    std::weak_ptr<CacheManager> m_manager;  // 弱引用,避免循环
public:
    void doSomething() {
        if (auto mgr = m_manager.lock()) {
            mgr->process(this);
        }
    }
};

// 全局容器
static std::vector<std::shared_ptr<CacheEntry>> g_cache;

另一种思路:RAII 容器封装

如果你不想用智能指针,或者项目里禁止使用 C++11 以上的特性,那可以自己封装一个 RAII 容器。说白了,就是让容器在析构时自动清理所有指针。

template<typename T>
class PointerVector {
    std::vector<T*> m_data;
public:
    ~PointerVector() {
        for (auto p : m_data) {
            delete p;
        }
    }
    void push_back(T* p) { m_data.push_back(p); }
    // ... 其他接口
};

// 使用
static PointerVector<Widget> g_widgets;

但说实话,我建议还是用智能指针。自己封装容易漏掉拷贝构造、赋值操作符等细节,搞不好反而引入 double delete 的问题。

总结一下

全局/静态容器持有指针,是 C++ 内存泄漏的重灾区。原因很简单:容器生命周期太长,且不负责指针指向的内存。解决方案有三个层次:

  • 最推荐:shared_ptrunique_ptr 替代裸指针,让智能指针自动管理生命周期
  • 注意循环引用:weak_ptr 打破闭环
  • 备选方案:封装 RAII 容器,在析构时统一清理

嗯,其实说到底,就是一句话:别让裸指针在全局容器里「裸奔」。给它穿上智能指针的外衣,内存泄漏自然就少了。

小技巧:如果你在代码审查时看到有人往全局容器里 push_back(new Xxx),直接打回去让他改。这几乎是个铁律——全局容器 + 裸指针 = 内存泄漏。

全局/静态容器持有指针 — 内存泄漏模式与解决方案 问题:裸指针泄漏 全局容器 裸指针 容器析构时,指针指向的内存未释放 解决方案 shared_ptr/unique_ptr RAII容器封装 自动管理生命周期,避免泄漏 三种常见泄漏模式 模式一 全局容器存裸指针 程序退出时泄漏 模式二 单例类持有指针容器 生命周期贯穿程序 模式三 静态局部变量容器 函数多次调用累积

这张图把问题、解决方案和常见模式串起来了。你从左边看,裸指针 + 全局容器 = 泄漏。往右看,用智能指针或 RAII 封装就能解决。底部是三种最常见的泄漏场景,你可以对照自己的代码检查一下。

最后说一句:内存泄漏这东西,防患于未然比事后排查省事得多。写代码时多花一分钟用智能指针,可能就省下未来一整天调试内存问题的时间。我个人是深有体会的。