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::vector<int> 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 真正的内存泄漏:增长曲线才是证据
泄漏的特征是内存占用随时间单调上升,且不随业务量回落。判别它最直接的办法是观察进程的 RSS(Resident 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-check=full --show-leak-kinds=all \ --track-origins=yes --log-file=valgrind.log \ ./your_program arg1 arg2跑完之后valgrind.log里会列出 definitely lost、indirectly lost、possibly lost、still reachable 四类。重点看definitely lost,那是确定泄漏的。--track-origins=yes能告诉你未初始化内存的来源,排查未定义行为时很有用。
但 Valgrind 的代价是运行速度下降 10 到 50 倍,内存占用也大幅增加。对于计算密集或长时间运行的服务,直接跑 Valgrind 可能几小时都出不来结果。我的经验是:把泄漏场景做成一个最小复现的单元测试或短时用例,再挂 Valgrind,这样几分钟就能出报告。
3.2 AddressSanitizer:编译期插桩,速度快得多
ASan 是 GCC 和 Clang 内置的,通过编译选项开启,不需要额外运行工具:
g++ -fsanitize=address -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_OPTIONS=detect_leaks=1:abort_on_error=0调整行为。另外,ASan 和某些第三方库(尤其是预编译的二进制库)可能冲突,需要重新编译依赖。
3.3 堆分析:massif 与自定义统计
如果泄漏是"缓慢增长"型,短时间跑不出来,可以用 Valgrind 的 massif 工具做堆快照分析:
valgrind --tool=massif --massif-out-file=massif.out ./your_program ms_print massif.out > massif.txtmassif 会定期采样堆使用量,生成时间轴上的堆增长图,能看出是哪段时间、哪个调用栈在持续分配。配合--detailed-freq可以调采样频率。
另一个轻量办法是在代码里埋点,重载operator new/delete做计数统计,按调用栈聚合。这个方案侵入性小,适合线上环境长期观测,但需要自己写统计逻辑。
3.4 选型建议
| 场景 | 推荐工具 | 理由 |
|---|---|---|
| 开发阶段日常排查 | AddressSanitizer | 快、集成简单、报告清晰 |
| 精确定位复杂泄漏 | Valgrind memcheck | 精度最高,能追踪来源 |
| 长时间缓慢增长 | massif + 埋点统计 | 适合长周期观测 |
| 线上环境 | 自定义统计 + 定期快照 | 无侵入、开销可控 |
我个人的习惯是:开发时全程开 ASan,CI 里跑一遍 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-origins=yes能追到最初分配的地方。两者结论一致时,基本可以确定泄漏点。
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::vector<int>().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,提交前跑一遍短时 Valgrind,CI 里加内存回归断言,线上用埋点统计画趋势图。这样大部分泄漏在引入的当天就能被发现,而不是等到半夜服务崩了才去救火。
另外,写 C++ 时养成几个习惯能省掉大量麻烦:优先用std::vector、std::string和智能指针,少用裸new;资源获取即初始化,别把 delete 散落在各个分支;对可能抛异常的路径,确保资源有 RAII 兜底。这些不是教条,是我踩了无数次坑之后总结出来的"保命"做法。
最后分享一个我常用的小技巧:在operator new里加一个可开关的计数器,按大小分桶统计分配次数和总量,程序退出时打印。这个开销极小,但能让你一眼看出"哪类大小的分配在异常增长",往往比完整跑一遍 Valgrind 还快定位到问题方向。