内存泄露

1️⃣ 什么是内存泄漏?

  在 C++ 中,内存泄漏 指的是程序通过 newmalloc 等方式向 堆区 申请了内存,却在不再需要时 没有及时释放,或者由于 指针丢失异常中断所有权管理混乱 等原因,导致这块内存再也无法被程序有效访问和回收。

2️⃣ 内存泄漏的危害

  • 性能退化: 泄漏内存会持续占用物理内存,当物理内存不足时,系统会启用 swap 分区,磁盘 IO 速度远低于内存,导致程序响应变慢、CPU 等待时间增加。
  • 程序崩溃: 泄漏内存耗尽系统可用内存(包括 swap)时,操作系统会触发 “内存溢出(OOM)”,强制终止占用内存最多的进程。
  • 资源耗尽: 对于长期运行的服务(如服务器程序),泄漏会持续累积,可能导致服务在运行一段时间后突然宕机,且难以复现根因。

3️⃣ 内存泄漏的分类

   对于 Valgrind 来说,其主要将 内存泄漏 分为四类:

  • Definitely Lost
    • 定义: 没有指针 p 可以指向一块已经分配的堆内存,100% 确定为泄漏;
  • Indirectly Lost
    • 定义: 子对象本身没有直接丢失指针,但由于父对象已泄漏,导致它也随之不可访问;
  • Possibly Lost
    • 定义: 模糊指针引用,比如 p 一开始指向堆区的开头,但是之后对 p 进行了移动,p 指向内存中间位置;
  • Still Reachable
    • 定义: 程序在运行过程中申请了一块堆内存,并始终由 全局指针静态指针 保存其地址。

4️⃣ 内存泄漏和内存溢出的区别

对比维度 内存泄漏(Memory Leak) 内存溢出(Out Of Memory, OOM)
触发条件 长期运行中内存渐进累积,无立即风险 单次内存分配超过系统可用内存
表现形式 程序运行越久,内存占用越高,最终可能引发 OOM 立即崩溃,报错 Cannot allocate memory 或被 OS 杀死
根因 内存未释放(如 new 后无 delete、循环引用) 内存分配需求超过系统上限(如数组过大申请、数据量过大)
解决方向 排查未释放的内存分配点,修复释放逻辑 优化内存分配方式(如分块分配、减少单次申请量)

5️⃣ 常见原因

  • new/delete 不匹配

    • 方式: new [] 分配内存,用 delete 释放

    • 根因:

      • new[] 会在堆内存头部存储 “数组元素个数”
      • delete[] 会读取该值并释放所有元素;
      • delete 会忽略该信息,仅释放指针指向的单个对象,导致数组剩余元素泄漏。
    • 案例

      1
      2
      3
      4
      5
      int main() {
      int* arr = new int[10]; // 分配数组,内存中会存储数组大小(供delete[]使用)
      delete arr; // 错误:用delete释放数组,仅释放第一个元素,其余9个元素泄漏
      return 0;
      }
  • 异常处理引发的泄漏

    • 根因: 异常抛出时,程序会跳过当前函数的后续代码(包括资源释放逻辑),若未用 RAII 保护资源,会直接导致泄漏。

    • 案例

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      void leakOnException() {
      int* data = new int[100];
      try {
      // 模拟异常抛出(如文件打开失败、网络错误)
      throw std::runtime_error("error");
      // 异常抛出后,以下 delete 永远不会执行
      delete[] data;
      } catch (...) {
      // 未处理 data 的释放,直接重新抛出
      throw;
      }
      }
  • 智能指针使用陷阱

    • 根因:
      • shared_ptr 循环引用,直接导致内存泄漏;
      • weak_ptr 未调用 expired() 来检查 shared_ptr 是否存在,而是直接将 weak_ptr 通过 lock() 直接返回,并将返回的结果直接解引用,使得直接崩溃;
      • shared_ptr 初始化陷阱,使用多个 shared_ptr 直接管理同一个对象,使其进行 double free
  • STL 容器与指针的隐患

    • 根因:
      • STL 容器存储了 指向堆内存的指针,但是 未遍历释放
      • STL 容器保存了 动态数组
      • STL 临时容器保存 指针
  • 全局/静态变量泄漏

    • 根因:
      • 全局变量/静态变量 的生命周期与程序一致,若其内部持有 动态资源(如指针、容器),程序退出前未清理,会导致泄漏(工具通常标记为 Still Reachable

6️⃣ 排查手段

  面对复杂的泄漏场景,手动排查几乎不可能,需依赖专业工具。

1️⃣ Valgrind

  ValgrindLinux 下最经典的内存调试工具,核心组件 memcheck 可检测 内存泄漏越界访问双重释放 等问题,精度极高。

原理

  Valgrind 并非直接运行程序,而是将程序翻译成 “中间代码”,在模拟 CPU 环境中执行。
  通过跟踪每一块堆内存的 “分配记录”(分配地址、大小、调用栈)和 “引用状态”(是否有指针指向),在程序退出时对比 “已分配未释放” 的内存,确定泄漏类型和位置。

常用参数详解

  • --leak-check=full

    • 作用: 开启完整泄漏检测;
    • 示例: 排查生产环境泄漏时,需用 valgrind --leak-check=full ./program
  • --log-file=leak.log

    • 作用: 将检测报告输出到文件,避免终端刷屏,便于后续分析;
    • 示例: 长时间运行程序时,用 --log-file=leak.log 保存报告;
  • --track-threads=yes

    • 作用: 跟踪线程创建与销毁,关联线程 ID 与内存分配(多线程场景必备);
    • 示例: 排查多线程泄漏时,加此参数:--track-threads=yes

优缺点

  • 优点: 精度极高、支持多线程、无需修改代码;
  • 缺点: 性能损耗大(程序运行速度降低 10~50 倍)、内存开销高(需额外占用 2~3 倍内存),不适合生产环境实时检测。

建议

  用 Valgrind 运行优化后的程序(-O2/-O3):优化会导致代码重排,调用栈信息不准确,建议用 -O0 编译。

2️⃣ AddressSanitizer

  ASanGoogle 开发的内存错误检测工具,集成在 GCC/Clang 中,相比 Valgrind 速度更快,适合开发调试阶段使用。

原理

  ASan 的核心是 “影子内存”Shadow Memory):

  • 将程序的每 8 字节内存映射为 1 字节影子内存,用于标记 “内存是否可访问”(如已分配、已释放、越界)。
  • ASan 在编译阶段对内存访问代码(load/store)进行 “插桩”,每次访问内存前先检查影子内存状态,若非法则触发错误报告。

ASan 会在程序退出时扫描所有堆内存,对比 “已分配未释放” 的块,生成 泄漏报告

使用流程

  • 编译阶段: 添加 -fsanitize=address -g 选项
  • 链接阶段: 若需静态链接 ASan(避免依赖系统 ASan 库),加 -static-libasan
  • 运行阶段: 设置环境变量 ASAN_OPTIONS 配置检测行为;

优缺点

  • 优点:
    • 速度快(仅比正常程序慢 2~5 倍)
    • 内存开销可控(额外占用 2~3 倍内存)
    • 支持多线程,适合开发调试;
  • 缺点:
    • 不支持区分泄漏类型
    • 部分场景存在误报

项目案例

面试问题:你了解过内存泄漏吗?

  • 我对内存泄漏的理解是:程序向堆区申请了资源,但因为 释放逻辑缺失异常中断所有权管理混乱 或者 智能指针循环引用,导致这块内存无法再被正常回收。
  • 对服务端程序来说,它最危险的点在于不会立刻炸,而是随着运行时间不断积累,最终表现为内存持续上涨、响应变慢,严重时触发 OOM
  • 我在自己实现 sylar 协程服务器的早期阶段,框架还不完整,当时先写了一个测试服务器去验证 IOManager + HttpServer + Servlet 这条链路。跑测试时我发现进程内存会持续上涨,后来用 ValgrindASan 去排查,最后定位到一段临时测试代码里的资源释放遗漏,并把它改成了 RAII
  • 这个过程让我比较系统地理解了:内存泄漏不只是 “忘了 delete”,更核心的是生命周期和所有权设计是否清晰。

我是怎么发现这个问题的?

第一步:先看到现象

  我的第一反应不是直接上工具,而是先观察现象:

  • 测试服务器功能上没有问题,接口能正常返回;
  • 但我连续多次访问测试接口后,进程内存占用不降反升;
  • 重复压测一段时间后,内存曲线持续上涨。

这时我初步怀疑:

  • 是不是某个请求上下文对象没有释放;
  • 是不是容器里留了裸指针;
  • 是不是异常或早返回路径把释放逻辑跳过去了。

第二步:构造稳定复现

  反复访问测试接口:如果此时观察到内存持续增长,这个问题就具备排查价值了。

使用工具排查

方式一:Valgrind

  • 生成 Debug 模式的可执行程序;

    1
    2
    3
    4
    cmake -S /root/workspace/sylar -B /root/workspace/sylar/build-debug \
    -DCMAKE_BUILD_TYPE=Debug

    cmake --build /root/workspace/sylar/build-debug -j
  • Valgrind 运行测试程序

    1
    2
    3
    4
    5
    6
    valgrind \
    --leak-check=full \
    --show-leak-kinds=all \
    --track-origins=yes \
    --log-file=leak.log \
    /root/workspace/sylar/bin/exe/my_http_server
  • 使用 Jmeter 进行压测

  • 生成的日志文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    ==104819== 131,408 (168 direct, 131,240 indirect) bytes in 3 blocks are definitely lost
    ==104819== by 0x2984E0: ... (test_memory_leak_server.cpp:104)

    ==104819== 84 bytes in 1 blocks are definitely lost
    ==104819== by 0x298544: ... (test_memory_leak_server.cpp:105)

    ==104819== 65,536 bytes in 1 blocks are definitely lost
    ==104819== by 0x298583: ... (test_memory_leak_server.cpp:106)

    ==104819== LEAK SUMMARY:
    ==104819== definitely lost: 65,788 bytes in 5 blocks
    ==104819== indirectly lost: 131,240 bytes in 4 blocks
    ==104819== possibly lost: 0 bytes in 0 blocks
    ==104819== still reachable: 0 bytes in 0 blocks

参考文章