Effective C++条款50:定制new和delete——了解new和delete的合理替换时机

一、重载operator new和operator delete的理由

  • 我们可以重载编译器的operator new和operator delete,下面给出了三个理由

①用来检测运用上的错误

  • 下面是一些常见错误:
    • new分配内存,但是没有delete掉,导致内存泄漏
    • 对new所分配的内存,进行多次delete那么会导致不明确的行为
    • 上面这些错误都是可以检测出来的
  • 还有一种错误情况:
    • 一些程序操作数据可能会导致数据“overruns”(写入点在分配内存尾端之后)或“underruns”(写入点在分配区块起点之前)
    • 如果我们自己写一个operator new,便可以超额分配内存,以额外空间(位于客户所得区块之前或后)放置特定的bytepatterns(即签名)。operator delete便可以检查上述的签名是否原封不动,如果不是那么表示在分配区的某个声明时间点发生了overruns或underruns,这时候operator delete可以标记这个事实以及那个惹是生非的指针
    • 我们自己书写的delete可以进行检测,那么就不会担心超额书写的错误了

②为了强化效能

  • 编译器自带的operator new和operator delete主要用于一般目的:
    • 它们不但可被长时间执行的程序(例如网页服务器)接受,也可被执行时间少于一秒的程序接受
    • 它们必须处理一些列需求,包括大块内存、小块内存、大小混合型内存
    • 它们必须接纳各种分配形态,范围从程序存活期间的大量区块动态分配,到大数量短命对象的持续分配和归还
    • 它们必须考虑破碎问题,这最终会导致程序无法满足大区块内存要求,即使此时有总量足够但分散为许多小区块的自由内存
  • 针对于上面的这些情况,因此:
    • 有时编译器自带的operator new和operator delete可能对你的程序不太友好
    • 如果你对自己的程序有深刻了解,那么你可以自己重载operator new和operator delete,定制适合自己程序的版本

③为了收集使用上的统计数据

  • 在定制new和delete之前,应该先收集你的软件如何使用其动态内存:
    • 分配区块的大小分布如何?
    • 寿命分布如何?
    • 它们倾向于以FIFP次序或LIFO次序或随机次序来分配和归还?
    • 它们的运用形态是否随时间改变,也就是说你的软件中在不同阶段有不同的分配/归还形态?
    • 任何时刻所使用的最大动态分配量是多少?
  • 自定义版本的new和delete使我们得以收集上面这些信息

二、关于重载new和delete的一些其他考虑

  • 下面我们重载了operator new,其中有些小错误,稍后完善。代码如下:
static const int signature = 0XDEADBEEF;

typedef unsigned char Byte;


//这段代码有若干错误

void* operator new(std::size_t size) throw(std::bad_alloc)

{

    using namespace std;

    size_t realSize = size + 2 * sizeof(int); //我们实际申请两倍signature的大小


    void* pMem = malloc(realSize); 申请内存

    if (!pMem)

        throw bad_alloc();


    //将signature写入内存的最前部

    *(static_cast<int*>(pMem)) = signature;

    //将signature写入内存的最后

    *(reinterpret_cast<int*>

    (static_cast<Byte*>(pMem) + realSize - sizeof(int))

    ) = signature;


    //返回指针,指向恰位于第一个signature之后的内存位置

    return static_cast<Byte*>(pMem) + sizeof(int);

}
  • 这段代码的缺点:违反了C++申请内存的标准:例如条款51会说operator new内都应该有一个循环,用来反复调用某个new-handling函数,这类却没有(详情见条款51) 

没有遵循“齐位(alignment)”操作

  • 齐位操作概述:
    • 许多计算机要求特定的类型必须放在特定的内存地址上。例如它可能会要求指针的地址必须是4倍数,double的地址必须是8倍数
    • 如果没有这个约束条件,可能导致运行期硬件异常。有些体系可能不会这样。有些编译器还会在齐位条件下提供较佳效率。例如Intel x86体系结构上的double可被对齐于任何byte边界,但如果它是8byte齐位,其访问速度会变快
    • operator new也不例外,C++要求所有的operator new返回的指针都有适当的对齐(取决于数据类型)
  • 上面我们重载的new就没有遵循齐位操作:
    • 在上面的函数中,我们返回了自malloc申请且偏移一个int大小的指针
    • 如果我们将new返回的地址分配给double使用,那么我们将在“int为4bytes且double必须是8bytes齐位”的机器上运行,程序可能会崩溃或效率降低
  • 用来检测管理器的优劣:
    • 有了齐位操作之后,我们就可以在众多内存管理器中区分出专业质量的管理器了,因为好的管理器会根据齐位来操作
    • 不过有时候不是必要的:
      • ①某些编译器已经在它们的内存管理器函数中切换至调试状态和标记状态。许多平台上已有商业产品可以替代编译器自带的内存管理器。如果需要它们来为你的程序提高机能和改善效能,你唯一要做的就是重新链接。当然,你需要付出费用
      • ②我们可以选择开发源码中的内存管理器。例如Boost中的Pool就是这样一个分配器,它对于最常见的“分配大量小型对象”很有帮助。普通的内存管理器通常缺少可移植性、齐位考虑、线程安全性等。但是Boost却拥有齐位这些条件。因此如果你想自己书写内存管理器,可以参阅这些开发源码

三、关于重载new和delete的总结

  • 下面是对重载new和delete理由的一些总结:
    • 为了检测运用错误(如前所示)
    • 为了收集动态分配内存之使用统计信息(如前所示)
    • 为了增加分配和归还速度:
      • 系统提供的new往往(虽然并不总是)比自己定义的new慢,特别是当自定义的new针对于某特定类型的对象设计时
      • class专属分配器是“区块尺寸固定”的分配器实例,例如Boost提供的Pool程序库便是
      • 如果你的程序是个单线程程序,但你的编译器所带的内存管理器具备线程安全,你或许可以写个布局线程安全的分配器而大幅改善速度
      • 当然,在获得new和delete有加快速度的价值之前,首先分析的程序,确认程序瓶颈是否发生在这些函数身上
    • 为了降低缺省内存管理器带来的空间额外开销:
      • 系统提供的new往往(虽然并不总是)比自己定义的new慢,它们往往还是用更多内存,因为它们常常在每一个分配区块身上带来一些额外的开销
      • 针对小型对象而开发的分配器(例如Boost提供的Pool程序库),本质上消除了这样的额外开销
    • 为了弥补缺省分配器中的非最佳齐位:
      • 如前面所说,在x86体系上,double的访问速度最快的原则是它们以8bytes对齐。但是编译器自带的operator new并不保证动态分配而得的double采取8bytes对齐。在这种情况下,将缺省的operator new替换为一个8bytes齐位版本,可以使程序大幅度提升
    • 为了将相关对象成簇集中:
      • ​​​​​​​如果你知道将某个数据结构常常放在一起被使用,而你又希望这样数据将“内存页错误”的频率降至最低,那么可以为此数据结构创建另一个heap,这么一来它们就可以被成簇集中在尽可能少的内存页上
      • new和delete的“placement版本(见条款52)”有可能完成这样的集簇行为
    • 为了获得非传统的行为:
      • ​​​​​​​有时候你希望operator new和delete做编译器附带没有的事情:例如希望分配和归还共享内存内的区块,但唯一能够管理该内存的只有C API函数,那么可以写一个定制版的new和delete(很可能是placement版本,见条款52),你可以为此C API套上一件C++外套,你也可以写一个自定义的delete,在其中将所有归还内存内容覆盖为0,增加安全性

四、总结

  • 有许多理由需要写个自己的new和delete,包括改善效能、对heap运用错误进行调试、收集heap使用信息

猜你喜欢

转载自blog.csdn.net/www_dong/article/details/113849448