我们计划编写一个内存泄漏检测器,这个任务初看可能有些抽象。如何着手?最佳方式是通过编写测试程序来明确需求 —— 既能展示工具的使用方法,又能从用户代码角度凸显工具的核心功能:
#include <iostream>
// 这段代码尚未完成
int main() {
auto pre = // 获取当前已分配的内存总量
{ // BEGIN
int *p = new int{ 3 };
int *q = new int[10]{ }; // 初始化为 0
delete p;
// oops! Forgot to delete[] q
} // END
auto post = // current amount of allocated memory
// 假设 sizeof(int) == 4,则 new int[10] 分配了 40 字节
// 所以我们期望输出 "Leaked 40 bytes"
if(post != pre)
std::cout << "Leaked " << (post - pre) << " bytes\n";
}
这个“故意泄漏”的程序执行两次分配但仅释放一次,“恰好”忘记释放包含10个int的数组(假设sizeof(int)==4),我们的检测器应能报告40字节的泄漏。
虽然,测试程序未展示如何实时获取动态内存量(这正是本章要实现的功能),但它通过BEGIN/END注释标记清晰地界定了检测范围。在C++中,匹配的花括号构成作用域,而作用域边界会确保其中自动变量的销毁。这里的关键在于:需要在存在RAII对象的情况下(见第4章)仍能检测泄漏,它们也可能存在缺陷,必须确保这些对象在诊断前已销毁,我们将通过重载全局内存分配操作符来实现泄漏检测。聪明如你,可能已经想到,这些操作符需要共享某些状态 —— 至少需要实时记录当前分配的内存量:new和new[]会增加该值,delete和delete[]会减少它。
对于检测器,数组和非数组版本的操作符处理逻辑完全相同,但这并非绝对:可以为标量和数组设计不同的分配策略,或分别追踪两者的使用情况。为简化表述,本章中用new统指new和new[],对delete也采用相同表述方式。
由于这些是全局函数而非某个对象的成员函数,必须使用全局变量来维护状态。虽然全局变量通常不推荐使用(多数情况下理由充分),但正是为此类场景而存在的。
我们对全局变量的反感理由充足:使局部推理变得困难(谁知道在何处何时访问?),往往会成为缓存访问的瓶颈并拖慢程序速度,现代(可能多线程的)程序中通常需要同步机制等等。我们在此采用这种机制实属必要:C++之所以提供丰富工具,正是因为它要解决广泛多样的问题。当这些工具恰是当前任务的最优解时,使用它们并无不妥 —— 关键是要做出能自圆其说的明智选择!
为稍稍缓解全局变量带给多数人的抵触感,将状态封装在对象中(尽管这个对象本身仍是全局的)。为此将应用单例设计模式(这同样备受开发者诟病),单例即程序中唯一存在的类实例。这种做法的好处在于能严格控制全局状态的访问方式,同时也有助于厘清逻辑。我们将这个单例类命名为Accountant(会计员),其职责就是协助内存分配操作符记录程序运行期间分配和释放的字节数。
所有设计模式中,单例模式大概最不受待见 —— 原因与全局变量类似:难以测试或模拟、需要同步机制、易成为性能瓶颈等。坦白说,真正的罪魁祸首其实是共享可变状态,更何况这种状态还能被程序全局访问。既然现在需要共享可变状态来跟踪实时内存分配量…那就只能用它了!
具体实现时,需要制定策略来追踪分配/释放的字节数。核心思路是:operator new()向Accountant对象报告分配的字节数,operator delete()则报告释放的字节数。我们将采用C++11及之前传统形式的操作符实现,其函数签名如下(如第7章所述):
void *operator new(std::size_t n);
void *operator new[](std::size_t n);
void operator delete(void*p) noexcept;
void operator delete[](void*p) noexcept;
作为本书的读者,各位必然已察觉一个问题:分配函数通过参数知晓需分配的字节数,但释放函数仅获知待释放内存块的起始地址,并无此特权。所以需要在operator new()返回的地址与对应内存块大小之间建立关联。
这看似容易解决 —— 只需构建类似std::vector<std::pair<void*, std::size_t>> 或 std::map<void*, std::size_t>>的结构来存储地址-大小映射。但这类容器本身需要动态分配内存,相当于为了实现内存分配机制而先分配内存,这至少会引发严重问题,必须另寻他法。
我们将采取任何理智开发者在此情境下的选择:说谎。没错!各位读者们,你们没看错。还记得前几章为何要研究那些棘手的危险代码吗?具体如何用“谎言”解决问题呢?试想当编写X* p = new X;时,实际会调用以sizeof(X)为参数的operator new()...
X *p = new X{ /* ... */ };
我们将这个参数命名为n,如果分配和随后的构造都成功,从使用端代码的角度来看,情况将如下所示:
为了让 operator delete() 能够根据指针 p 找到对应的内存块大小 n,我们将采用一种策略(也是本示例所采用的方案):将 n 的值隐藏在 p 之前的内存位置。从我们代码的角度来看,实际的内存布局如下:
p是使用端代码看到的地址,而p'才是实际分配内存块的起始位置。显然,这是个“谎言”:分配函数返回的地址虽然是可用于构造对象的有效地址,但并非实际分配内存块的真正起点。只要operator new()和operator delete()都知晓p与p'之间的这段空间,这个方案就能成立。
出于显而易见的原因,重载operator new()实现这个技巧意味着必须同时重载operator delete()来完成逆向操作:给定某个指针p,需要回溯到内存中p'的位置,找到隐藏的n值,并向会计员对象报告释放了n字节的内存。
现在,来看看具体如何实现这个机制。