8.4. 问题诊断与修复

我们最初的实现实际上存在一个严重问题,同时还有一些虽然能工作但不够优雅和值得讨论的地方。

这个严重问题在于:我们以危险的方式实施了“谎言”,且没有充分考虑内存对齐要求。看看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的对齐要求,会发生什么?这要视情况而定:

为解决此问题,必须确保重载的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的构造函数。这不禁让人疑惑:为何代码似乎能正常工作?实际上,存在以下特殊情况:

当前赋值操作能正常工作的关键原因在于:我们正在向一个已正确对齐且通过特殊函数分配的内存块中,写入隐式生命周期类型的对象。若尝试存储比隐式生命周期类型更复杂的对象,此操作要么在编译时失败(若编译器足够智能),要么导致运行时错误。

更安全可靠的做法是采用第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()中无需显式调用其析构函数 —— 只需释放其底层存储空间即自动结束其生命周期。至此,我们已实现了一个正确可用的内存泄漏检测器!