为什么要替换默认的new和delete操作符

Why would one replace default new and delete operators?

本文关键字:delete 操作符 new 替换 默认 为什么      更新时间:2023-10-16

为什么应该用自定义的newdelete运算符替换默认运算符newdelete

这是C++常见问题解答中Overloading new和delete的延续:
运算符重载。

此常见问题解答的后续条目是:
我应该如何编写符合ISO C++标准的自定义newdelete运算符?

注意:答案基于Scott Meyers的《更有效的C++》。_(注意:这是[Stack Overflow的C++常见问题解答]的一个条目(https://stackoverflow.com/questions/tagged/c++-faq)。如果你想批评以这种形式提供常见问题解答的想法,那么[开始这一切的meta上的帖子](https://meta.stackexchange.com/questions/68647/setting-up-a-faq-for-the-c-tag)会是这样做的地方。该问题的答案在[C++聊天室]中监控(https://chat.stackoverflow.com/rooms/10/c-lounge),FAQ的想法最初是从这里开始的,所以你的答案很可能会被那些提出这个想法的人阅读。)_

可能会尝试替换newdelete运算符,原因有很多,即:

要检测使用错误:

CCD_ 9和CCD_内存泄漏。各自的例子分别是:
在CCD_ 12 ed存储器上使用多个CCD_;不在使用CCD_ 14分配的存储器上调用CCD_
过载的运算符new可以保留已分配地址的列表,过载的运算符delete可以从列表中删除地址,这样就很容易检测到此类使用错误。

类似地,各种编程错误可能导致数据溢出(在分配的块结束之后写入)和欠载
重载运算符new可以过度分配块,并在客户端可用的内存之前和之后放置已知的字节模式("签名")。重载运算符delete可以检查签名是否仍然完好无损。因此,通过检查这些签名是否完好无损,可以确定在分配的块的生命周期中的某个时候发生了溢出或运行不足,操作员删除可以记录这一事实以及违规指针的值,从而有助于提供良好的诊断信息。


要提高效率(速度和内存):

newdelete运算符对每个人都工作得相当好,但对任何人来说都是最佳的。这种行为源于这样一个事实,即它们只是为通用目的而设计的。它们必须适应分配模式,从程序期间存在的几个块的动态分配到大量短期对象的恒定分配和释放。最终,编译器附带的运算符new和运算符delete采取了中间策略。

如果您对程序的动态内存使用模式有很好的了解,您经常会发现自定义版本的运算符new和运算符delete的性能优于默认版本(性能更快,或需要的内存更少,最多可达50%)。当然,除非你确定自己在做什么,否则这样做不是一个好主意(如果你不理解其中的复杂性,甚至不要尝试)。


要收集使用情况统计信息:

在考虑如#2所述替换newdelete以提高效率之前,您应该收集有关应用程序/程序如何使用动态分配的信息。您可能需要收集以下信息:
分配块的分布,
寿命的分布,
分配顺序(FIFO或LIFO或随机),
了解一段时间内使用模式的变化,使用的动态内存的最大数量等。

此外,有时您可能需要收集使用信息,例如:
计算类的动态对象数量,
限制使用动态分配等创建的对象数量。

所有这些信息都可以通过替换自定义newdelete并在过载的newdelete中添加诊断收集机制来收集。


要补偿new中的次优内存对齐:

许多计算机体系结构要求将特定类型的数据放在存储器中特定类型的地址处。例如,一个体系结构可能要求指针出现在四的倍数(即,四字节对齐)的地址,或者双指针必须出现在八的倍数(例如,八字节对齐)。不遵守这些约束可能导致运行时出现硬件异常。其他体系结构更宽容,可能允许它在降低性能的情况下工作。一些编译器附带的运算符new不能保证动态的八字节对齐双打的分配。在这种情况下,用保证八字节对齐的运算符替换默认运算符new可以大大提高程序性能;可以成为取代CCD_ 31和CCD_。


要将相关对象聚集在一起,请执行以下操作:

如果您知道特定的数据结构通常一起使用,并且希望在处理数据时尽量减少页面错误的频率,那么为数据结构创建一个单独的堆是有意义的,这样它们就可以聚集在尽可能少的页面上。CCD_ 33和CCD_。


要获得非常规行为:

有时,您希望new和delete运算符执行编译器提供的版本没有提供的操作
例如:您可以编写一个自定义运算符delete,用零覆盖已释放的内存,以提高应用程序数据的安全性。

首先,确实存在许多不同的newdelete运算符(实际上是任意数)。

首先,有::operator new::operator new[]::operator delete::operator delete[]。其次,对于任何类别X,都有X::operator newX::operator new[]X::operator deleteX::operator delete[]

在这两者之间,重载特定于类的运算符比重载全局运算符要常见得多——对于特定类的内存使用来说,遵循足够特定的模式是很常见的,这样您就可以编写比默认值有实质性改进的运算符。一般来说,在全局范围内准确或具体地预测内存使用情况要困难得多。

可能还值得一提的是,尽管operator newoperator new[]彼此分离(对于任何X::operator newX::operator new[]也是如此),但两者的要求没有区别。一个将被调用来分配单个对象,另一个将分配对象数组,但每个对象仍然只接收所需的内存量,并且需要返回(至少)那么大的内存块的地址。

说到需求,可能值得回顾一下其他需求1:全局运算符必须是真正的全局运算符——你可能不会把一个放在命名空间中,或者在特定的翻译单元中使一个静态。换句话说,只有两个级别可以发生重载:类特定的重载或全局重载。诸如"命名空间X中的所有类"或"转换单元Y中的所有分配"之类的中间点是不允许的。类特定的运算符需要是static,但实际上并不需要将它们声明为静态的——无论是否显式声明为static,它们都将是静态的。正式地说,全局运算符返回的内存是对齐的,这样它就可以用于任何类型的对象。非正式地说,在一个方面有一点回旋余地:如果你收到一个小块(例如,2个字节)的请求,你只需要为这个大小的对象提供对齐的内存,因为试图在那里存储更大的东西无论如何都会导致未定义的行为。

在介绍了这些预备内容之后,让我们回到最初的问题,即为什么要重载这些运算符。首先,我应该指出,重载全局运算符的原因往往与重载类特定运算符的原因有很大不同。

由于它更常见,我将首先讨论类特定的运算符。类特定内存管理的主要原因是性能。这通常有两种形式:要么提高速度,要么减少碎片。内存管理器将只处理特定大小的块,因此它可以返回任何空闲块的地址,而不是花时间检查块是否足够大,如果块太大,则将其一分为二,等等。碎片以(大部分)相同的方式减少,例如,预先分配一个足够大的块给N个对象,正好给出了N个对象所需的空间;分配一个对象的内存将为一个对象精确地分配空间,而不是多分配一个字节。

导致全局内存管理操作符过载的原因有很多种。其中许多都面向调试或检测,例如跟踪应用程序所需的总内存(例如,为移植到嵌入式系统做准备),或者通过显示分配和释放内存之间的不匹配来调试内存问题。另一种常见的策略是在每个请求块的边界前后分配额外的内存,并将唯一的模式写入这些区域。在执行结束时(可能还有其他时间),检查这些区域,看看代码是否在分配的边界之外编写。另一个是尝试通过自动化内存分配或删除的至少某些方面来提高易用性,例如使用自动垃圾收集器。

非默认全局分配器也可以用于改进性能。一个典型的情况是替换一个通常很慢的默认分配器(例如,大约4.x的MS VC++的至少一些版本会为的每个分配/删除操作调用系统HeapAllocHeapFree函数)。我在实践中看到的另一种可能性是在英特尔处理器上使用SSE操作时发生的。它们对128位数据进行操作。尽管操作将在不考虑对齐的情况下工作,但当数据对齐到128位边界时,速度会提高。一些编译器(例如,MS VC++再次2)不一定强制与更大的边界对齐,因此即使使用默认分配器的代码可以工作,替换分配也可以为这些操作提供显著的速度提高。


  1. C++标准的§3.7.3和§18.4(或C++0x中的§3.7.4和§18.6,至少从N3291开始)涵盖了大多数要求
  2. 我不得不指出,我不打算挑剔微软的编译器——我怀疑它有很多这样的问题,但我碰巧经常使用它,所以我往往很清楚它的问题

似乎值得在这里重复我的答案中的列表,从"是否有理由重载全局新建和删除?"——请参阅该答案(或该问题的其他答案)以了解更详细的讨论、参考资料和其他原因。这些原因通常适用于本地运算符重载以及默认/全局重载,也适用于Cmalloc/calloc/realloc/free重载或挂钩。

我们重载了我工作的许多全局new和delete运算符原因:

  • 池化所有小的分配--减少开销,减少碎片,可以提高小的分配量大的应用程序的性能
  • 使用已知生存期构建分配--忽略所有释放,直到这段时间结束,然后释放所有释放一起(无可否认,我们在本地运营商过载的情况下做得更多比全球)
  • 对齐调整--缓存线边界等
  • alloc fill--有助于公开未初始化变量的使用情况
  • 免费填充--有助于公开以前删除的内存的使用情况
  • 延迟免费--提高免费填充的有效性,偶尔提高性能
  • 哨兵fenceposts--有助于暴露缓冲区溢出、欠载和偶尔的野生指针
  • 重定向分配--考虑NUMA、特殊内存区域,甚至在内存中保持单独的系统(例如。嵌入式脚本语言或DSL)
  • 垃圾收集或清理--同样适用于那些嵌入式脚本语言
  • 堆验证--您可以每N次分配/释放遍历一次堆数据结构,以确保一切正常
  • 会计,包括泄漏跟踪使用快照/统计(堆栈、分配期限等)

许多计算机体系结构要求将特定类型的数据放在内存中特定类型的地址。例如,一个体系结构可能要求指针出现在四的倍数(即,四字节对齐)的地址,或者双指针必须出现在八的倍数(例如,八字节对齐)。不遵守这些约束可能导致运行时出现硬件异常。其他体系结构更宽容,可能允许它在降低性能的情况下工作。

为了澄清:如果架构要求,例如double数据是八字节对齐的,那么就没有什么可优化的了。保证适当大小的任何类型的动态分配(例如malloc(size)operator new(size)operator new[](size)new char[size],其中size >= sizeof(double))被适当地对准。如果一个实施没有做出这一保证,那么它就是不符合要求的。在这种情况下,更改operator new以做"正确的事情"将是试图"修复"实现,而不是优化。

另一方面,一些体系结构允许对一个或多个数据类型进行不同(或所有)类型的对齐,但根据对相同类型的对齐提供不同的性能保证。然后,一个实现可以返回次最优对齐的并且仍然一致的存储器(再次,假设适当大小的请求)。这就是这个例子的意义所在。

与使用统计相关:按子系统进行预算。例如,在基于主机的游戏中,您可能需要为3D模型几何体保留一部分内存,一些用于纹理,一些用于声音,一些用于游戏脚本等。自定义分配器可以按子系统标记每个分配,并在超出个别预算时发出警告。

一些编译器附带的运算符new不能保证双精度动态分配的八字节对齐。

请引用。通常,默认的新操作符只比malloc包装器稍微复杂一点,按照标准,malloc封装器返回的内存与目标体系结构支持的ANY数据类型适当对齐。

我并不是说没有充分的理由为自己的类重载新的和删除的。。。你在这里已经谈到了几个合法的,但以上并不是其中之一。

我用它来分配特定共享内存领域中的对象。(这与@Russell Borogove提到的内容类似。)

几年前,我为CAVE开发了软件。这是一个多墙VR系统。它用一台电脑驱动每台投影仪;6是最大值(4面墙、地板和天花板),而3更常见(2面墙和地板)。这些机器通过特殊的共享内存硬件进行通信。

为了支持它,我从我的普通(非CAVE)场景类派生出一个新的"new",它将场景信息直接放在共享内存领域中。然后,我将该指针传递给不同机器上的从属渲染器。