本节摘要:堆账本的四类坏账——泄漏(登记不销账)、野指针(未开户先使用)、悬垂指针(销账后继续用旧凭证)、双重释放(同一笔账销两回)——全部是未定义行为,症状从静默污染到随机崩溃不等。本节逐类复现、给出识别特征,并用一次完整排错案例把"发现到修复验证"走成闭环。
前三节把账本体系与记账工序搭完了,本节抵达本章主线的终点站:事故现场。1.3 的图 1-3 里留了一句预告——每类坏账都能在四道工序上指出断点。现在兑现这句话:四类坏账逐一进场,每类都配一段能复现的代码与识别特征,最后用 ProfileService 的完整排错过程收束本章。
第一类:内存泄漏——登记了,反向流程永远没跑。 最经典的形态是异常或早退跳过 delete,以及把指针覆盖导致"账户还在、凭证丢了":
#include <cstdio> void leak_by_overwrite() { int* p = new int(1); p = new int(2); // 第一笔账的凭证被覆盖,永远无法销账 delete p; // 只销了第二笔 std::printf("函数结束,第一笔 4 字节已泄漏\n"); } int main() { leak_by_overwrite(); return 0; }
输出正常打印,程序"看起来没事"——泄漏的阴险正在于此:它不崩溃、不报错,只在长时间运行中慢慢吃干物理内存。审计特征是存量单调增长:给类挂上 1.3 的存量钩子,泄漏类的数字只涨不跌。
第二类:野指针——从未开户,凭证在手。 未初始化的指针变量里是随机位模式,拿它访问内存等于随机闯进别人的账户:
#include <cstdio> int main() { int* p; // 未初始化:野指针 // std::printf("%d\n", *p); // 解引用即未定义行为:随机值或崩溃 p = nullptr; // 制度化底线:声明即给明确状态 int* q = nullptr; if (q) { std::printf("不会走到\n"); } std::printf("空指针可以安全判断,野指针连判断都不可靠\n"); return 0; }
审计特征是崩溃地址随机且复现不稳定——随机位模式每次不同,闯的"账户"每次不同。治理手段没有技巧可言:声明即初始化(哪怕初始化为 nullptr),让"野"失去存在空间。
第三类:悬垂指针——销账后继续用旧凭证。 1.3 讲过 delete 会跑完反向两道工序,但delete 不会替你把指针变量改成空,旧地址依然躺在指针里:
#include <cstdio> int main() { int* p = new int(42); int* alias = p; // 两个名字记同一笔账 delete p; // 反向工序跑完,账已销 // 此时 p 和 alias 都是悬垂指针,下面这行是未定义行为: std::printf("读旧凭证:%d(结果完全不可信)\n", *alias); p = nullptr; // 只能救一个名字,alias 依旧悬垂——手工记账的死角 return 0; }
审计特征是**"先正常后爆炸"**:销账后若那块内存没被复用,旧凭证还能读到旧数据(最危险的情形——看起来是对的);一旦分配器把账页分给新客户,读到的是别人的数据,写进去就是跨账户污染。测试环境复现不了的崩溃,常常就是这类"暂时没被复用"的悬垂。
第四类:双重释放——同一笔账销两回。 1.4 开头的 alias 例子再往前走一步,把被注释的 delete alias 放开,就是双重释放:分配器的账目结构会被写坏,典型症状是崩在 delete 内部深处或崩在下一次毫不相干的分配里。

本章主线的收束时刻,把追账线走完:
背景:ProfileService 压测两小时 RSS 上涨约 600 MB 且不回落,功能测试全部通过,没有崩溃。三个特征——不崩、只涨、不回落——初步指向泄漏而非悬垂。
操作:第一步挂钩子,给最可疑的 RequestContext 类加上 1.3 的存量统计,压测十分钟抓拍存量曲线:请求数涨了约四十万次,存量从 0 涨到约四十万,每次开户都有对应构造,析构次数停在几千。第二步二分定位:存量钩子加在构造与析构里,再用日志把每条处理路径打标,找出"开户多、销户少"的分支——是超时分支提前 return,绕过了尾部销账。
结果:超时分支的 return 之前补上销账,压测两小时存量归零、RSS 平稳。修复本身只有一行。
解读:这单案子印证了本章的判断——坏账不是道德问题而是流程问题。写代码的同事并非"不记得 delete",他只是不可能在脑子里遍历所有早退路径。泄漏的根因是销账责任散落在控制流里,控制流每多一个分叉,责任就多一处脱落的可能。
变式:同样的排错框架适用于另外三类坏账,只是观测手段不同——悬垂指针的观测点是"销账后谁还持有旧地址"(6.4 的 sanitizer 能直接抓现行),双重释放看崩溃栈是否落在销账附近,野指针看崩溃地址是否随机漂移。把"现象特征 → 观测手段 → 定位手法"整理成对照表贴在团队 wiki 上,下次再遇到内存曲线异常,照表取药。
| 坏账类型 | 工序断点 | 典型症状 | 首选观测 |
|---|---|---|---|
| 内存泄漏 | 反向流程缺失 | 只涨不跌、终致 OOM | 存量钩子曲线 |
| 野指针 | 正向流程未走 | 崩溃地址随机 | 初始化纪律检查 |
| 悬垂指针 | 销账后用旧凭证 | 先正常后错乱 | sanitizer 抓现行 |
| 双重释放 | 反向流程跑两遍 | 崩在销账或下次分配附近 | 崩溃栈定位 |
⚠️ 常见坑:用"置空指针"当万能修复。
p = nullptr只保护 p 这一个名字,同一笔账的其他别名依旧悬垂;把名字置空还会掩盖"谁真正拥有这笔账"的问题。置空是止血,不是治病——治病要把所有权写清楚,那是第 2 章的正题。
本章把地基与坏账都摆完了。下一章正面回答悬垂案例里暴露的死结——别名问题与早退问题,用 RAII 把"反向流程必须跑"写进语言机制,让销账从人工承诺变成制度保证。