Fork me on GitHub

读书笔记 effective c++ Item 50 了解何时替换new和delete 是有意义的

1. 自定义new和delete的三个常见原因

我们先回顾一下基本原理。为什么人们一开始就想去替换编译器提供的operator new和operator delete版本?有三个最常见的原因:

  • 为了检测内存使用错误。不能成功delete new出来的内存会造成内存泄漏。在new出来的内存上使用多于一次的delete会产生未定义行为。如果operator new持有一份内存分配的列表,并且operator delete从列表中移除地址,那么就很容易侦测出这种使用错误。类似的,不同种类的编程错误能够导致数据右越界(overrun)(越过分配内存块的结尾写数据)或者左越界(underrun)(在分配内存块的开始之前写数据)。自定义的operator new能够分配额外的内存块,所以在客户申请内存前后就有空间存放已知的字节模式(“签名signatures”)。Operato delete能够检查签名是否发生了变化,如果变了,那么在分配内存块的生命周期中,越界(overrun orunderrun)就有可能会发生,operator delete会记录这个事实,并且将违规指针的值记录下来。
  • 为了提高效率。编译器提供的operator new和operator delete的版本是供大众使用的。它们必须能被长时间运行的程序所用(例如 web server),也能被执行时间小于1秒的程序所使用。它们必须要处理对大内存块,小内存块以及大小混合内存块的请求。它们必须要适应不同的内存分配模式,从为持续运行的程序提供内存块的动态分配到为大量短暂存在对象提供的常量大小的内存块分配和释放。它们必须考虑内存碎片问题,如果不做内存碎片的检查,最后有可能发生内存充足却因为分布在不同的小内存块中而导致内存请求失败的问题。

考虑以上在内存管理上的不同要求,编译器版本的operator new和operator delete为你提供一个大众化内存分配策略就不足为奇了。它们能够为每个人都工作  的很好,但是对于这些人来说都不是最优的。如果你对程序的动态内存运用模式有一个很好的理解,你就会发现使用自定义版本的operator new和operator delete会胜过默认版本。“胜过”的意思就是它们运行的更快——有时速度提升是数量级的,它们使用的内存会更少——最高能减少50%的内存。对于一些应用来说,能够很容易的替换默认operator new和operator delete版本,却能够收获很大的性能提升。

  • 为了收集内存使用的统计信息。在沿着自定义new和delete这条小路前进之前,对你的软件是如何使用动态分配内存的相关信息进行收集是很精明的。内存分配块的大小是如何分布的?内存块的生命周期是如何分布的?内存的分配和释放是使用FIFO(先进先出)的顺序,还是使用LIFO(后进先出)的顺序?或者有时候更加趋近于随机的顺序?内存使用的模式是不时地发生变化的么?例如,你的软件在不同的执行阶段是不是有不同的内存分配和释放模式?一次能够使用的动态分配内存的最大容量是多少?自定义版本的operator new和operator delete使得收集这些信息变得容易。

2. 自定义operator new中的对齐问题

从概念上来说,实现一个自定义operator new是非常容易的。例如,我们快速的实现一个全局operator new,它能够很容易的检测内存越界。它也有很多小的错误,但是我们一会再去为它们担心。

 1 static const int signature = 0xDEADBEEF;
 2 
 3 typedef unsigned char Byte;
 4 // this code has several flaws — see below
 5 void* operator new(std::size_t size) throw(std::bad_alloc)
 6 {
 7 
 8 using namespace std;
 9 size_t realSize = size + 2 * sizeof(int);             // increase size of request so 2
10 // signatures will also fit inside
11 
12 void *pMem = malloc(realSize);   // call malloc to get the actual
13 
14  
15 
16 if (!pMem) throw bad_alloc();                                                          // memory
17 
18 // write signature into first and last parts of the memory              
19 
20 *(static_cast<int*>(pMem)) = signature;                                        
21 
22 *(reinterpret_cast<int*>(static_cast<Byte*>(pMem)+realSize-sizeof(int))) =          
23 
24 signature;                                                                                       
25 
26 // return a pointer to the memory just past the first signature       
27 
28 return static_cast<Byte*>(pMem) + sizeof(int);                             
29 
30 }      

                                                                                         

这个operator new的大多数毛病是因为它不符合C++惯例。例如,Item 51中解释了所有的operator new都应该包含一个反复调用new-handling函数的循环,但是这个函数里没有。然而,因为在Item51中会有解释,在这里我们将其忽略。我现在想关注一个更加微妙的问题:对齐(alignment

对于许多计算机架构来说,在内存中替换特定类型的数据时,需要在特定种类的地址上进行。例如,一种架构可能需要指针定义的开始地址为4的整数倍(也就是4字节对齐的)或者定义double的开始地址必须为8的整数倍(也就是8字节对齐的)。不遵守这个约束条件在运行时就会导致硬件异常。其他架构可能更加宽松,也即是如果满足对齐会有更好的性能。例如,在英特尔X86架构中double可以被对齐在任何字节边界上,但是如果它们是8字节对齐的,访问它们的速度会大大加快。

Operator new和对齐(alignment)是相关的,因为C++需要所有operator new返回的指针都能够被恰当的对齐,malloc工作在同样的需求下,所以让operator new返回从malloc得到的指针是安全的。然而,在上面的operator new中,我们没有返回从malloc得到的指针,我们返回的是从malloc得到的指针加上int大小的偏移量。这就在安全上没有保证了!如果客户通过调用operator new来为double获取足够的内存(或者如果我们实现了operator new[],为double数组申请内存),并且我们工作在int为4字节大小但是double需要8字节对齐的机器上,我们可能返回一个没有恰当的对齐的指针。这可能会导致程序崩溃。或者它只会导致程序运行更加缓慢。不管哪种结果,都不是我们想要的。

3. 通常情况你无需自定义new和delete

因为像对齐(alignment)这样的细节问题的存在,程序员在专心完成其他任务的时候将这些细节问题忽略会导致各种问题的抛出,这就能够将专业级别的内存管理器区分出来。实现一个能够工作的内存管理器是非常容易的。实现一个工作良好的就非常难了。作为通用规则,我建议你不要尝试,除非有必要。

3.1 使用默认版本和商业产品

在许多情况下,你不必这么做。在一些编译器的内存管理函数中有控制调试和记录日志功能的开关。快速瞥一眼你的编译器文档可能就能消除你自己来实现New和delete的想法。在许多平台中,商业产品能够替换编译器自带的内存管理函数。它们的增强的功能和改善的性能能够使你受益,你所需要做的就是重新链接(前提是你必须买下这个产品。)

3.2 使用开源内存管理器

另外一个选择是开源的内存管理器。在许多平台上都能找到这样的管理器,所以你可以下载和尝试。其中一个开源的内存分配器是来自Boost的Pool库(Item 55)。这个Pool库提供的内存分配器对自定义内存管理很有帮助:也就是在有大量的小对象需要分配的时候。许多C++书籍中,包含本书的早期版本,展示出了高性能小对象内存分配器的源码,但他们通常都会忽略一些细节,像可移植性,对于对齐的考虑,线程安全等等。真正的库提供的源码都是更加健壮的。即使你自己决定去实现你自己的new和delete,看一下这些开源的版本能够让你对容易忽略的细节有了深刻洞察力,而这些细节就将“基本工作”和“真正工作”区分开来。(鉴于对齐是这样一个细节,因此注意一下TR1是很有价值的,其中包含了对特定类型对齐的支持。)

4. 使用自定义版本new和delete的意义总结

这个条款的论题是让你知道在什么情况下对默认版本的new和delete进行替换是有意义的,无论是在全局范围内替换还是在类的范围内替换。我们现在做一个总结。

  • 检测内存使用错误。
  • 收集使用动态分配内存的统计信息。
  • 提高内存分配和释放的速度。为大众提供的分配器通常情况下比自定义版本要慢的多,特别是在自定义版本是专门为特定类型对象所设计的情况下。类特定的分配器是固定大小分配器的一个实例应用,例如在Boost的Pool库中提供的分配器。如果你的应用是单线程的,但是你的编译器默认版本是线程安全的,你可以通过实现线程不安全的分配器来获得可观的速度提升。当然,在下决定要提升operator new和operator delete的速度之前,研究一下你的程序来确定这些函数真的是瓶颈所在。
  • 减少默认内存管理的空间开销。大众内存管理器通常情况下不仅慢,而且使用更多的内存。因为它们会为每个内存分配块引入一些额外的开销。为小对象创建的分配器从根本上消除了这些开销。
  • 能够补偿在默认分配器中的次优对齐。正如我先前提到的,在X86架构的机器上访问double,在8字节对齐的情况下速度是最快的。但是一些编译器中的operator new不能够保证对于动态分配的double是8字节对齐的。在这种情况中,用能够保证8字节对齐的版本替换默认版本可以很大程度的提高性能。
  • 将相关对象集中起来。如果你知道一些特定的数据结构通常情况下会被放在一起被使用,当在这些数据上进行工作时你想让页错误出现的频率最小化,为这些数据结构创建一个单独的堆就有意义了,这样它们就能够聚集在尽可能少的页中。替换new和delete的默认版本可以达到这种聚集。
  • 可以获得非常规的行为。有时候你想让operator new和delete能够做一些编译器版本不能做的事。例如,你可能想在共享内存中进行内存分配和释放,但是你只有一个C API来进行内存管理。实现自定义版本的new 和delete(可能是placement 版本——见Item 52)允许你为C API穿上C++的外衣。另外一个例子,你可以自己实现一个operator delete来为释放的内存填充数据0以达到增强应用数据安全性的目的。

5. 本条款总结

有许多正当的理由来自定义new 和delete,包括提高性能,调试堆应用错误和收集堆使用信息。

posted @ 2017-04-19 08:00  HarlanC  阅读(621)  评论(0编辑  收藏  举报