资讯动态

C++ std::bad_alloc 排查指南:从内存泄漏到碎片化的实战解析

发布时间:2026/9/21 22:16:42 来源:尧图企业网站定制
1. 从一次深夜崩溃说起std::bad_alloc 到底在喊什么凌晨两点服务端进程突然挂掉日志最后一行只有一句terminate called after throwing an instance of std::bad_alloc然后what(): std::bad_alloc。如果你写过稍微复杂一点的 C 程序这个场景大概率不陌生。它不像段错误那样直接给你一个 core dump 让你去 gdb 里翻栈它更像是一个慢性病急性发作——程序跑了几小时甚至几天内存一点点被吃掉最后在某次new或std::vector::push_back时系统实在拿不出连续内存了于是抛出这个异常。先把概念说清楚。std::bad_alloc是 C 标准库new头文件里定义的异常类型继承自std::exception。当operator new无法分配请求的字节数时标准行为就是抛出它。注意这里的关键词是无法分配原因可能有两类一是真的没内存了物理内存加交换空间耗尽或者进程地址空间被限制二是内存碎片化严重明明总空闲内存够但找不到一块足够大的连续区域。很多人一看到 bad_alloc 就条件反射认为是内存泄漏其实这两者只是经常同时出现不是必然因果。那为什么内存泄漏会导致 bad_alloc打个比方你的程序像一个仓库管理员每次需要放货就向系统申请一块货架new用完就该还回去delete。内存泄漏就是管理员把货架用完后忘了登记归还货架越堆越多仓库进程虚拟地址空间被占满等到再来一批大货要申请连续货架时系统只能说没有了。在 64 位系统上进程虚拟地址空间理论上很大但实际受限于物理内存、cgroup 限制、ulimit -v设置等泄漏累积到一定程度照样会触发。这篇文章面向的是已经能写 C、但一遇到内存问题就抓瞎的开发者。我会把从看到 bad_alloc到定位到具体泄漏点的完整链路拆开讲包括工具选型、编译配置、排查手法、以及那些文档里不会写的坑。你不需要是内存管理专家但看完之后应该能独立处理大部分泄漏场景。提示bad_alloc 不等于泄漏。先确认是内存耗尽还是单次申请过大再决定排查方向否则容易南辕北辙。2. 先分清敌人bad_alloc 的几种真实成因与快速判别2.1 一次性申请超大内存最容易被误判为泄漏的情况我见过不少新手代码里写了std::vectorint v; v.resize(1000000000);或者char* buf new char[SIZE];而 SIZE 来自某个未校验的输入结果直接 bad_alloc。这种情况根本不是泄漏而是单次申请量超过了可用上限。判别方法很简单在抛出异常的地方打印请求的字节数。你可以重载全局operator new来记录#include new #include cstdio #include cstdlib void* operator new(std::size_t size) { void* p std::malloc(size); if (!p) { std::fprintf(stderr, [alloc fail] request size %zu bytes (%.2f MB)\n, size, size / 1024.0 / 1024.0); throw std::bad_alloc(); } return p; }这段代码把每次失败的申请大小打出来。如果看到的是几百 MB 甚至 GB 级别的数字那基本可以确定是单次申请过大去检查数据来源和边界校验即可不用大动干戈上 Valgrind。2.2 真正的内存泄漏增长曲线才是证据泄漏的特征是内存占用随时间单调上升且不随业务量回落。判别它最直接的办法是观察进程的 RSSResident Set Size。在 Linux 上可以这样盯# 每 2 秒打印一次目标进程的 RSS单位 KB while true; do ps -o rss -p $(pgrep your_program) | awk {printf %.2f MB\n, $1/1024} sleep 2 done如果这个数字在业务空闲期也持续爬升那泄漏基本坐实。注意要区分缓存增长和泄漏——有些程序故意缓存数据RSS 上升是正常的但通常会有上限或淘汰策略。真正的泄漏是没有上限的。2.3 碎片化总内存够但就是分配不出来碎片化导致的 bad_alloc 比较隐蔽。典型场景是程序频繁申请释放不同大小的块运行很久之后空闲内存被切成无数小碎片此时来一个较大的申请就失败了。判别方法是看/proc/pid/status里的VmRSS和/proc/pid/smaps的碎片情况或者用malloc_stats()打印堆状态。碎片化问题往往需要换分配器如 jemalloc、tcmalloc来缓解而不是简单找泄漏点。2.4 快速判别流程现象可能原因首选排查手段启动即崩日志有申请大小单次申请过大打印申请 size检查输入校验运行数小时后崩RSS 持续上升内存泄漏Valgrind / ASan / 堆分析长时间运行后偶发RSS 稳定碎片化malloc_stats、换分配器特定操作后必崩该路径有泄漏或大申请复现路径 ASan这张表是我自己排查时的第一反应清单能帮你快速缩小范围避免一上来就盲目跑工具。3. 工具选型Valgrind、AddressSanitizer 与堆分析怎么选3.1 Valgrind memcheck精度高但慢得让人抓狂Valgrind 的 memcheck 工具是内存泄漏排查的经典选择。它的原理是在虚拟机和真实 CPU 之间插一层模拟执行每条指令所以能精确追踪每一次内存读写和分配释放。用法很直接valgrind --leak-checkfull --show-leak-kindsall \ --track-originsyes --log-filevalgrind.log \ ./your_program arg1 arg2跑完之后valgrind.log里会列出 definitely lost、indirectly lost、possibly lost、still reachable 四类。重点看definitely lost那是确定泄漏的。--track-originsyes能告诉你未初始化内存的来源排查未定义行为时很有用。但 Valgrind 的代价是运行速度下降 10 到 50 倍内存占用也大幅增加。对于计算密集或长时间运行的服务直接跑 Valgrind 可能几小时都出不来结果。我的经验是把泄漏场景做成一个最小复现的单元测试或短时用例再挂 Valgrind这样几分钟就能出报告。3.2 AddressSanitizer编译期插桩速度快得多ASan 是 GCC 和 Clang 内置的通过编译选项开启不需要额外运行工具g -fsanitizeaddress -fno-omit-frame-pointer -g -O1 your_program.cpp -o your_program ./your_program程序退出时ASan 会自动打印泄漏报告包括泄漏的调用栈。它的速度损失通常只有 2 倍左右内存开销约 2 到 3 倍比 Valgrind 友好太多。ASan 还能检测越界、use-after-free 等问题是日常开发的首选。不过 ASan 有个坑它默认只在程序正常退出时报告泄漏。如果你的程序是被信号杀死的或者调用了_exit()报告可能不完整。可以用ASAN_OPTIONSdetect_leaks1:abort_on_error0调整行为。另外ASan 和某些第三方库尤其是预编译的二进制库可能冲突需要重新编译依赖。3.3 堆分析massif 与自定义统计如果泄漏是缓慢增长型短时间跑不出来可以用 Valgrind 的 massif 工具做堆快照分析valgrind --toolmassif --massif-out-filemassif.out ./your_program ms_print massif.out massif.txtmassif 会定期采样堆使用量生成时间轴上的堆增长图能看出是哪段时间、哪个调用栈在持续分配。配合--detailed-freq可以调采样频率。另一个轻量办法是在代码里埋点重载operator new/delete做计数统计按调用栈聚合。这个方案侵入性小适合线上环境长期观测但需要自己写统计逻辑。3.4 选型建议场景推荐工具理由开发阶段日常排查AddressSanitizer快、集成简单、报告清晰精确定位复杂泄漏Valgrind memcheck精度最高能追踪来源长时间缓慢增长massif 埋点统计适合长周期观测线上环境自定义统计 定期快照无侵入、开销可控我个人的习惯是开发时全程开 ASanCI 里跑一遍 Valgrind 做兜底线上用埋点统计监控趋势。三管齐下基本不会漏。4. 实战排查链路从 bad_alloc 到具体泄漏点的完整过程4.1 第一步拿到崩溃现场的信息程序抛 bad_alloc 时默认会调用std::terminate栈信息往往丢失。第一件事是捕获异常并打印上下文int main() { try { run(); } catch (const std::bad_alloc e) { std::fprintf(stderr, bad_alloc caught: %s\n, e.what()); // 打印当前内存状态 std::system(cat /proc/self/status | grep Vm); throw; } }如果能在崩溃前拿到 RSS、VmSize 等数据就能判断是耗尽还是碎片。更进一步可以在operator new失败时打印调用栈用backtrace()直接看到是谁在申请。4.2 第二步构造最小复现拿到崩溃路径后别急着在完整程序上跑工具。把触发泄漏的操作抽出来写成一个几十行的小程序。比如每次处理请求泄漏 1KB那就写个循环调用处理函数一千次观察 RSS。最小复现的好处是Valgrind 跑得快ASan 报告干净你能反复试验不同假设。我踩过的一个坑有次泄漏只在多线程下出现单线程复现不了。后来发现是某个线程局部缓存没释放。所以构造复现时要尽量贴近真实并发模型否则可能白忙。4.3 第三步用 ASan 定位调用栈在最小复现上开 ASan 编译运行报告会直接给出泄漏点的调用栈。典型输出长这样Direct leak of 1024 byte(s) in 1 object(s) allocated from: #0 operator new(unsigned long) #1 MyClass::MyClass() my_class.cpp:42 #2 processRequest() handler.cpp:88 ...顺着栈往下看找到你自己的代码帧那就是泄漏发生的位置。注意 ASan 报告的是分配点不是忘记释放的点所以你要去检查这个分配对应的delete为什么没执行——可能是异常路径跳过了、可能是容器没清空、可能是智能指针用错。4.4 第四步Valgrind 交叉验证ASan 有时会因为优化或内联导致栈不完整这时候用 Valgrind 交叉验证。Valgrind 的报告更啰嗦但更完整尤其是--track-originsyes能追到最初分配的地方。两者结论一致时基本可以确定泄漏点。4.5 第五步修复与回归修复之后别只看这次不崩了。要重新跑一遍 ASan 和 Valgrind确认 definitely lost 归零。然后在 CI 里加一个内存回归测试跑固定负载断言 RSS 增长不超过阈值。这样才能防止同类问题再次引入。注意修复泄漏时优先用 RAII 和智能指针而不是到处补 delete。补 delete 容易漏掉异常路径治标不治本。5. 那些文档不会告诉你的踩坑经验5.1 智能指针不是万能药循环引用照样泄漏std::shared_ptr用起来很爽但两个对象互相持有 shared_ptr 就形成循环引用引用计数永远不归零。这种泄漏 ASan 和 Valgrind 都能查出来但报告会指向 shared_ptr 的构造容易让人懵。解决办法是把其中一方改成std::weak_ptr。我见过一个项目里观察者和被观察者互相 shared_ptr泄漏了几百 MB 才被发现。5.2 容器 clear 不等于释放内存std::vector::clear()只销毁元素不释放底层缓冲区。如果 vector 曾经涨到很大clear 之后内存还占着。要真正释放得用shrink_to_fit()或者和空 vector 交换std::vectorint().swap(big_vec); // 真正释放这个坑在处理完一批大任务后内存不降的场景里特别常见很多人误以为是泄漏其实是容器没缩容。5.3 第三方库的静态缓存有些第三方库内部有全局缓存第一次调用时分配之后一直持有。这种still reachable在 Valgrind 里不算泄漏但如果缓存无上限增长就是问题。排查时要区分库的合理缓存和库的泄漏前者可以通过配置限制后者只能升级或打补丁。5.4 多线程下的分配器竞争多线程频繁 new/delete 时glibc 的默认分配器可能因为锁竞争导致性能下降也可能因为 per-thread arena 导致内存占用偏高。换 jemalloc 或 tcmalloc 往往能同时改善性能和内存表现。链接方式很简单g your_program.cpp -o your_program -ljemalloc或者用LD_PRELOAD在运行时替换。实测在高并发服务里换分配器后 RSS 能降 20% 到 30%。5.5 别忽略 ulimit 和 cgroup 限制有时候程序泄漏其实是外部限制太紧。检查ulimit -v虚拟内存、ulimit -m物理内存以及容器里的 cgroup memory limit。如果限制设得比实际需求小程序会在正常内存用量下就 bad_alloc。这种情况调大限制即可不用改代码。6. 把内存排查变成日常习惯排查内存问题最怕的不是难而是平时不管出事抓瞎。我的做法是把内存检查嵌进日常流程本地开发默认开 ASan提交前跑一遍短时 ValgrindCI 里加内存回归断言线上用埋点统计画趋势图。这样大部分泄漏在引入的当天就能被发现而不是等到半夜服务崩了才去救火。另外写 C 时养成几个习惯能省掉大量麻烦优先用std::vector、std::string和智能指针少用裸new资源获取即初始化别把 delete 散落在各个分支对可能抛异常的路径确保资源有 RAII 兜底。这些不是教条是我踩了无数次坑之后总结出来的保命做法。最后分享一个我常用的小技巧在operator new里加一个可开关的计数器按大小分桶统计分配次数和总量程序退出时打印。这个开销极小但能让你一眼看出哪类大小的分配在异常增长往往比完整跑一遍 Valgrind 还快定位到问题方向。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价