我们最初的实现实际上存在一个严重问题,同时还有一些虽然能工作但不够优雅和值得讨论的地方。
这个严重问题在于:我们以危险的方式实施了“谎言”,且没有充分考虑内存对齐要求。看看operator new()的初始实现:
void *operator new(std::size_t n) {
// 分配 n 个字节,再加上足够的空间用于隐藏 n 的值
void *p = std::malloc(n + sizeof n); // 待修改
// 如有必要,表示未能满足后置条件(即内存分配失败)
if(!p) throw std::bad_alloc{};
// 在分配的内存块起始位置“隐藏”n 的值(用于后续释放时使用)
auto q = static_cast<std::size_t*>(p);
*q = n; // 待修改
// 通知内存统计器(Accountant)这次内存分配操作
Accountant::get().take(n);
// 返回用户请求的内存块起始位置
return q + 1; // 待修改
}
std::malloc()返回的内存必须按机器最严格(即最苛刻)的自然对齐要求进行对齐 —— 因为该函数不知道分配完成后要构造什么类型的对象,所以必须确保内存块在所有“自然”情况下都正确对齐。C++编译器提供的std::max_align_t类型别名,就代表了机器上最严格的自然对齐要求,实践中通常是(但不一定是)double类型。
现在,分配了比请求多sizeof(std::size_t)字节的内存。这在某种程度上可行,当然可以在std::malloc()返回的内存块开头存储std::size_t值,因为该内存块即使在最坏情况下也是对齐良好的。
然后,“跳过”这个std::size_t,返回比实际分配地址多sizeof(std::size_t)字节的地址。只有当std::size_t和std::max_align_t大小相同时,这样返回的地址才能保证在最坏情况下仍然正确对齐 —— 但根本无法保证(实际上它们的大小经常不同)。
如果这些类型大小不同,导致operator new()返回的地址不符合std::max_align_t的对齐要求,会发生什么?这要视情况而定:
侥幸运行:若返回地址恰好符合目标类型的对齐要求仍可工作。当alignof(int)==4而alignof(std::max_align_t)==8时,即使operator new返回4的倍数而非8的倍数地址,new int仍能运行。但new double很可能会引发问题。这种“幸运”实则是隐患,可能长期掩盖严重缺陷直至突发故障。
性能与安全隐患:部分硬件支持非对齐访问,但这会导致机器执行额外操作(如加载低位字节→高位字节→位运算合并)来完成本应简单的操作(如将double载入寄存器)。不仅显著降低性能,多线程环境下更可能导致撕裂读(读取半形成对象)或撕裂写(写入半形成对象) —— 这类问题极难调试。
直接崩溃:许多嵌入式平台(包括主流游戏主机)会直接崩溃,这反而是最合理的处理方式。
为解决此问题,必须确保重载的operator new()返回的地址始终符合std::max_align_t对齐要求,并相应调整operator delete()。解决方案之一是:确保存储n的“隐藏区域”大小经过特殊设计,使得跳过该区域后的地址仍满足std::max_align_t对齐要求。
void *operator new(std::size_t n) {
// 分配 n 个字节,并额外分配足够的空间用于“隐藏”n 的值,
// 同时考虑到最坏情况下的自然对齐要求
void *p = std::malloc(sizeof(std::max_align_t) + n);
// 如有必要,表示未能满足后置条件(即内存分配失败)
if(!p) throw std::bad_alloc{};
// 在分配的内存块起始位置“隐藏”n 的值(用于后续释放时使用)
*static_cast<std::size_t*>(p) = n; // 待修改
// 通知内存统计器(Accountant)这次内存分配操作
Accountant::get().take(n);
// 返回用户请求的内存块起始位置
return static_cast<std::max_align_t*>(p) + 1;
}
此实现除了分配请求的n字节外,还为std::max_align_t分配了空间,然后“跳过”这段存储区域,最终返回一个即使在最坏情况下也能正确对齐的地址。当sizeof(std::size_t)小于sizeof(std::max_align_t)时,可能会比最初(错误)的实现浪费更多空间 —— 至少能确保使用端代码可以在此地址安全构造对象。
对应的operator delete()将执行相同的指针运算,只是方向相反:在内存中回退sizeof(std::max_align_t)字节:
void operator delete(void *p) noexcept {
// 对空指针执行 delete 是一个空操作
if(!p) return;
// 找到最初分配的内存块的起始位置
p = static_cast<std::max_align_t*>(p) - 1;
// 通知内存统计器(Accountant)这次内存释放操作
Accountant::get().give_back(
*static_cast<std::size_t*>(p)
);
// 释放内存
std::free(p);
}
此实现中将std::max_align_t*赋值给void*(指针p)是完全合法的操作,无需强制类型转换。另一个需要讨论的问题虽在本实现中不算缺陷,但具有普遍意义。
观察operator new()中的这段代码:
void *operator new(std::size_t n) {
void *p = std::malloc(n + sizeof(std::max_align_t));
if(!p) throw std::bad_alloc{};
// 在分配的内存块起始位置“隐藏”n 的值(用于后续释放时使用) <-- 高亮行
*static_cast<std::size_t*>(p) = n; // 待修改 <-- 高亮行
Accountant::get().take(n);
return static_cast<std::max_align_t*>(p) + 1;
}
是否注意到异常之处?高亮行的赋值操作针对指针p指向的位置,但该操作仅对已存在对象有效。此时位置*p处是否存在对象?答案有些微妙……根据语言规则,创建对象必须调用其构造函数,但我们从未在此代码中对位置p调用过std::size_t的构造函数。这不禁让人疑惑:为何代码似乎能正常工作?实际上,存在以下特殊情况:
隐式生命周期类型:C++中某些类型被归类为隐式生命周期类型,包括标量类型(指针、成员指针、算术类型、枚举、std::nullptr_t及其cv限定版本),以及隐式生命周期类(无用户提供析构函数的聚合类型,至少具有一个合格简单构造函数和非删除的简单析构函数)。需注意std::size_t作为无符号整型的别名,正属于此类。若使用C++23编译器,可通过std::is_implicit_lifetime
隐式启动生命周期的标准库函数:包括部分C函数(如std::memcpy()、std::memmove()和std::malloc()),以及std::bit_cast、分配器中的某些函数(见第14章),还有C++23新增的std::start_lifetime_as()和std::start_lifetime_as_array()。
当前赋值操作能正常工作的关键原因在于:我们正在向一个已正确对齐且通过特殊函数分配的内存块中,写入隐式生命周期类型的对象。若尝试存储比隐式生命周期类型更复杂的对象,此操作要么在编译时失败(若编译器足够智能),要么导致运行时错误。
更安全可靠的做法是采用第7章介绍的placement new技术,将值n存入未初始化存储区。因此,以下operator new()的实现更为可取,避免了对非对象进行(通常错误的)赋值:
void *operator new(std::size_t n) {
void *p = std::malloc(n + sizeof(std::max_align_t));
if(!p) throw std::bad_alloc{};
// 在分配的内存块起始位置“隐藏”n 的值(用于后续释放时使用)
*static_cast<std::size_t*>(p) = n; // 待修改
Accountant::get().take(n);
return static_cast<std::max_align_t*>(p) + 1;
}
由于std::size_t具有简单析构函数,在operator delete()中无需显式调用其析构函数 —— 只需释放其底层存储空间即自动结束其生命周期。至此,我们已实现了一个正确可用的内存泄漏检测器!