Effective C++手册Item 50: 领会何时替换 new 和 delete 才有意义

Item 50: 领会何时替换 new 和 delete 才有意义

作者:Scott Meyers

译者:fatalerror99 (iTePub's Nirvana)

发布:http://blog.csdn.net/fatalerror99/

让我们先回顾一下基础。为什么有些人想要替换编译器提供的 operator new 或 operator delete 版本呢?有三个最主要的原因:

  • 为了监测使用错误。对由 new 产生的内存没有实行 delete 会导致内存泄漏。在 new 出的内存上实行多于一次的 delete 会引发未定义行为。如果 operator new 保存一个已分配地址的列表,而 operator delete 从这个列表中移除地址,这样就很容易监测到上述使用错误。同样,某种编程错误会导致 data overruns(数据上溢)(在一个已分配块的末端之后写入)和 underruns(下溢)(在一个已分配块的始端之前写入)。在对于客户可用的内存的之前和之后,自定义 operator news 可以跨越分配块,在这些空间放置已知的字节模式 ("signatures")。operator deletes 会去检查这些 signatures 是否依旧保持原样。如果不是,在这个分配块的生存期间的某个时刻发生了一个上溢或者下溢,而且 operator deletes 可以记录这件事以及那个讨厌的指针的值。

  • 为了提升性能。由编译器加载的 operator new 和 operator delete 版本是为了多种用途而设计的。它们必须被长时间运行的程序(例如,web servers),接受,但是,它们也必须被运行时间少于一秒的程序接受。它们必须处理大内存块,小内存块,以及两者混合的请求序列。它们必须适应广泛的分配模式,从存在于整个程序的持续期间的少数几个区块的动态分配到大量短寿命 objects 的持续不断的分配和释放。它们必须为堆碎片化负责,对这个过程,如果不进行控制,最终会导致不能满足对大内存块的请求,即使有足够的自由内存分布在大量小块中。

由于内存管理器的特定需求,由编译器加载的 operator news 和 operator deletes 采取了 middle-of-the-road strategy(中间路线策略)不值得大惊小怪。它们的工作对每一个人来说都说得过去,但是对谁都不是最合适的。如果你对你的程序的动态内存的应用模式有充分的理解,你可能经常发现 operator new 和 operator delete 的自定义版本胜于缺省版本。对于“胜于”,我的意思是它们运行更快——有时会有数量级的提升——而且它们需要更少的内存——最高会少于 50%。对于某些(尽管不意味着全部)应用程序,用自定义版本取代普通的 new 和 delete 是获得重大性能提升的一个简单方法。

  • 为了收集使用方法的统计数据。在一头扎入编写自定义 news 和 deletes 的道路之前,收集一下你的软件如何使用动态内存的信息还是比较明智的。被分配区块大小的分布如何?生存期的分布如何?它们的分配和释放的顺序是趋向于 FIFO ("first in, first out")(“先进先出”),或者 LIFO ("last in, first out")(“后进先出”)的顺序,还是某种接近于随机的顺序?使用模式会随着时间而变化吗?例如,你的软件是不是在不同的运行阶段有不同的分配/释放模式?在任一时间内使用中的动态分配内存的最大值(也就是说,它的“最高水位”)是多少?operator new 和 operator delete 的自定义版本使得收集这类信息变得容易。

在概念上,编写一个自定义 operator new 相当简单。例如,这是一个便于 under- 和 overruns 的检测的 global operator new 的主要部分。这里有很多小麻烦,但是我们马上就来关注一下它们。

static const int signature = 0xDEADBEEF;
typedef unsigned char Byte;
// this code has several flaws—see below
void* operator new(std::size_t size) throw(std::bad_alloc)
{
  using namespace std;
  size_t realSize = size + 2 * sizeof(int);    // increase size of request so2
                                               // signatures will also fit inside
  void *pMem = malloc(realSize);               // call malloc to get theactual
  if (!pMem) throw bad_alloc();                // memory
  // write signature into first and last parts of the memory
  *(static_cast(pMem)) = signature;
  *(reinterpret_cast(static_cast(pMem)+realSize-sizeof(int))) =
  signature;
  // return a pointer to the memory just past the first signature
  return static_cast(pMem) + sizeof(int);
}

这个 operator new 的大多数缺陷都与它没有遵循叫这个名字的函数的 C++ 惯例有关。例如,Item 51 阐明:所有的 operator new 都应该包含一个调用 new-handling function 的循环,但是这里没有。但是,Item 51 正是专用于这样的惯例,所以我在这里忽略它们。我现在要关注一个更微妙的问题:alignment(排列对齐)。

很多计算机架构要求特定类型的数据要放置在内存中具有特定性质的地址中。例如,一种架构可能要求 pointers(指针)要出现在四的倍数的地址上(也就是说,按照四字节对齐)或者 doubles(双精度浮点型)必须出现在八的倍数的地址上(也就是说,按照八字节对齐)。不遵守这样的约束会导致 hardware exceptions at runtime(运行时硬件异常)。其它的架构可能会宽容一些,但是如果满足了排列对齐的次序会得到更好的性能。例如,在 Intel x86 架构上 doubles(双精度浮点型)可以按照任意字节分界排列,但是如果他们按照八字节对齐,访问速度会快得多。

alignment(排列对齐)在这里有重大意义,因为 C++ 要求所有的 operator news 返回适合任何数据类型的排列的指针。malloc 也工作于同样的要求下,所以,让 operator new 返回它从 malloc 得到的指针是安全的。然而,在上面的 operator news 中,我们没有返回我们从 malloc 得到的指针,我们返回的指针比我们从 malloc 得到的指针偏移了一个 int 大小。无法保证这是安全的!如果客户调用 operator new 为一个 double(或者,如果我们正在编写 operator new[],一个 doubles 的数组)申请足够的内存,而且我们正在运行一台 ints 是四个字节大小而 doubles 需要八字节对齐的机器,我们就可能返回对齐不恰当的指针。这可以导致程序崩溃。或者,它只是导致运行速度变慢。无论哪种情况,这或许都不是我们想要的。

像 alignment(排列对齐)这样的细节可以用于区分专业品质的内存管理器和那些由需要解决其它任务而心烦意乱的程序员匆匆拼凑出来的东西。编写一个几乎能工作的自定义内存管理器相当容易。编写一个工作得很好的要困难得多。作为一个一般规则,我建议你不要致力于此,除非你不得不做。

很多情况下,你并非不得不做。有些编译器提供选项开关用为它们的 memory management functions(内存管理函数)打开调试和记录的功能。快速浏览一下你的编译器的文档也许可以打消你编写 new 和 delete 的念头。在很多平台上,商用产品可以替代随编译器提供的 memory management functions(内存管理函数)。为了利用它们的增强的功能以及(或许会有的)更好的性能,你需要做的全部就是重新链接。(当然,你还必须把它们买回来。)

另一个选择是开源的内存管理器。它们可用于多种平台,所以你可以下载并试用。出自于 Boost(参见 Item 55)的 Pool library 就是一个这样的开源分配器。Pool library 提供了针对自定义内存管理能提供帮助的最通常的情况之一(大数量 small objects(小对象)的分配)进行了调谐的分配器。很多 C++ 书籍,包括本书的早期版本,展示了一个 high-performance small-object allocator(高性能小对象分配器)的代码,但是它们通常忽略了可移植性和排列对齐的考虑以及线程安全等等诸如此类的麻烦的细节。真正的库会注意用健壮得多的代码。即使你决定编写你自己的 news 和 deletes,看一下开源版本很可能会为你提供对“区分几乎起作用和真正起作用”的容易忽略的细节的洞察力。(已知 alignment(排列对齐)就是一个这样的细节,值得一提的是,TR1(参见 Item 54)包含了对已发现的类型特定的排列对齐要求的支持。)

这个 Item 的主题是了解何时替换 new 和 delete 的缺省版本(无论是基于全局的还是 per-class 的)才有意义。我们现在应该比前面更详细地总结一下时机问题。

  • 为了监测使用错误(如前)。

  • 为了收集有关动态分配内存的使用的统计数据(如前)。

  • 为了提升分配和回收的速度。general-purpose allocators(通用目的的分配器)通常(虽然不总是)比自定义版本慢很多,特别是如果自定义版本是为某种特定类型的 objects 专门设计的。class-specific allocators(类专用分配器)是 fixed-size allocators(固定大小分配器)(就像 Boost 的 Pool library 所提供的那些)的一种典范应用。如果你的程序是 single-threaded(单线程)的,而你的编译器缺省的内存管理例程是 thread-safe(线程安全)的,通过编写 thread-unsafe allocators(非线程安全分配器)你可以获得相当的速度提升。当然,在得出 operator new 和 operator delete 对速度提升有价值的结论之前,确实测定你的程序以保证这些函数是真正的瓶颈。

  • 为了减少缺省内存管理的空间成本。general-purpose memory managers(通用目的的内存管理器)通常(虽然不总是)不仅比自定义版本慢,而且还经常使用更多的内存。这是因为它们经常为每个已分配区块招致某些成本。针对 small objects(小对象)调谐的分配器(诸如 Boost 的 Pool library 中的那些)从根本上消除了这样的成本。

  • 为了调整缺省分配器不适当的排列对齐。就像我前面提到的,在 x86 架构上,当 doubles 按照八字节对齐时访问速度是最快的。哎呀,有些随编译器提供的 operator news 不能保证 doubles 的动态分配按照八字节对齐。在这种情况下,用保证按照八字节对齐的 operator new 替换掉缺省版本,可以使程序性能得到较大提升。

  • 为了聚集相关的 objects,使它们彼此靠近。如果你知道特定的 data structures(数据结构)通常会在一起使用,而且你想将在这些数据上工作时的页错误频率降到最低,那么为这些 data structures(数据结构)创建一个独立的 heap(堆)以便让它们尽可能地聚集在不多的几个页上就是有意义的。new 和 delete 的 placement versions(参见 Item 52)使得完成这样的聚集成为可能。

  • 为了获得不同寻常的行为。有时你想让 operators new 和 delete 做一些编译器装备版本没有提供的事情。例如,你可能想在共享内存中分配和回收区块,但是只能通过一个 C API 来管理那片内存。编写 new 的 delete 的自定义版本(或许是 placement versions——再次参见 Item 52)允许你用 C++ 衣服来遮住那个 C API。作为另一个例子,你可以编写一个自定义的 operator delete 用 zeros 复写被回收的内存以提高应用程序数据的安全性。

Things to Remember

  • 有很多正当的编写 new 和 delete 的自定义版本的理由,包括改进性能,调试 heap(堆)用法错误,以及收集 heap(堆)用法信息。