优化C++软件(11)

9.6. 动态内存分配

使用new与delete或者malloc与free,可以动态分配对象与数组。在编译时刻要求的内存数量未知时,这是有用的。这里简述动态内存分配的4个典型用法:

  • 在一个大数组在编译时刻大小未知时,可以动态分配这个数组。
  • 在数目可变对象在编译时刻总数未知时,可以动态分配这些对象。
  • 字符串及类似大小可变的对象可以动态分配。
  • 对栈而言太大的数组可以动态分配。

动态内存分配的好处有:

  • 在某些情形里,给出更清晰的程序结构。
  • 不会分配超过需要的空间。在为了覆盖最大可能内存要求的最坏情形,使一个固定大小数组非常大时,这使得数据缓存更高效。
  • 在要求的内存空间数量没有合理的上限可以预先给出时,是有用的。

动态内存分配的坏处有:

  • 动态分配与释放内存的过程需要比其他类型储存多得多的时间。参考第17页。
  • 在随机分配与释放不同大小的对象时,堆空间变得碎片化。这使数据缓存效率下降。
  • 一个分配的数组在填满时需要扩大。这要求分配一个新的更大的内存块,并把整个内容拷贝到新的块。任何指向旧块的指针变为无效。
  • 在堆空间变得太碎片化时,堆管理器将开始垃圾收集。垃圾收集的开始时间不可预测,会在用户等待响应时,导致程序流的延迟。
  • 确保分配的每一个对象都被释放是程序员的责任。没有这样做将导致堆被用完。这称为内存泄露,是常见的编程错误。
  • 确保对象被释放后,不会再访问,是程序员的责任。没有这样做也是常见的编程错误。
  • 分配的内存可能不是最优对齐的。如何对齐动态分配内存参考第123页。
  • 编译器很难优化使用指针的代码,因为不能排除别名(参考第66页)。
  • 在行长度在编译时刻未知时,因为在每次访问时,需要计算行地址的额外工作,矩阵或多维数组的效率下降。编译器不能通过归纳变量优化它。

在确定是否使用动态内存分配时,权衡好坏是重要的。在数组大小或对象个数在编译时刻已知,或者可以定义一个合理上限时,没有理由使用动态内存分配。

在分配数量有限时,动态内存分配的代价可以忽略不计。因此,在程序有一个或少数几个可变大小数组时,动态内存分配是有优势的。制作覆盖最坏情形非常大的数组的替代方案浪费缓存空间。在程序有几个大数组且每个数组的大小是关键步长倍数(参考上面第76页)的情形里,很可能导致数据缓存的竞争。

如果数组中元素数在程序执行期间增长,最好从一开始就分配最终的数组大小,而不是逐步分配更多的内存。在大多数系统中,你不能增加已经分配的内存块的大小。如果不能预测最终大小或预测结果太小,那么分配一个新的更大的内存块,把旧内存块的内容拷贝到新内存块是必要的。当然,这是低效的,而且会导致堆空间碎片化。一个替代方案是保持多个内存块,以链表或者内存块索引的形式。使用多个内存块的方法使得访问数组元素更复杂、更耗时。

可变数量对象的集合通常实现为链表。链表中每个元素有自己的内存块以及指向下一个块的指针。因为下面的原因,链表比线性的数组效率低:

  • 每个对象单独分配。分配、释放以及垃圾收集需要可观的时间。
  • 对象在内存中不是连续储存。这使数据缓存效率下降。
  • 链接指针以及堆管理器对每个已分配块保存信息,需要使用额外的内存空间。
  • 遍历链表比线性数组需要更多时间。在载入前一个链接指针前,不能载入链接指针。这形成了阻止乱序执行的关键依赖链。

对所有的对象分配一大块内存(内存池)通常比对每个对象分配一小块内存更高效。

分配可变大小数组newdelete一个鲜为人知的替代方案是alloca。这是一个在栈上,而不是堆上,分配内存的函数。在调用alloca的函数返回时,空间自动释放。在使用alloca时,无需显式释放空间。对比newdelete或者mallocfreealloca的优势在于:

  • 分配过程开销很小,因为微处理器有支持栈的硬件。
  • 由于栈后进先出的特点,内存空间不会碎片化。
  • 因为在函数返回时自动进行,释放没有代价。无需垃圾收集。
  • 分配的内存与栈上其他对象是连续的,这使得数据缓存非常高效。

下面的例子展示了如何使用alloca制作可变大小数组:

// Example 9.3

#include

void SomeFunction (int n) {

     if (n > 0) {

          // Make dynamic array of n floats:

          float * DynamicArray = (float *)alloca(n * sizeof(float));

          // (Some compilers use the name _alloca)

          for (int i = 0; i < n; i++) {

               DynamicArray[i] = WhateverFunction(i);

               // ...

          }

     }

}

显然,函数不应该返回指向使用alloca分配对象的指针或引用,因为在该函数返回时,它被释放了。Alloca可能与结构化异常处理不兼容。关于alloca使用的限制,参考你的编译器手册。

9.7. 容器类

一旦使用了动态内存分配,建议将分配的内存封装在一个容器类中。容器类必须有确保分配的每一个对象都被释放的析构函数。这是防止内存泄露及其他与动态内存分配相关常见编程错误的最好方法。

对数组增加边界检查,对更多具有先进先出、先进后出访问、排序以及查找、二叉树、哈希表等的先进数据结构,容器类也是便利的。

通常以模板形式制作容器类,其中包含的对象类型作为模板参数提供。使用模板没有性能损失。有现成的容器类可用于许多用途。最常用的容器集合是伴随大多数现代C++编译器的标准模板库(STL)。使用现成容器的好处是,你无需重新发明轮子。STL中的容量是通用、灵活、经过良好测试且对许多用途十分有用。

不过,STL针对通用与灵活性设计,而执行速度、内存经济性、缓存效率与代码大小优先程度不高。特别是在STL中内存分配有不必要的浪费。某些STL模板,比如listsetmap倾向于分配比容器内对象个数多的内存块。STl deque(双端队列)对每四个对象分配一个内存块。STL vector在同一个内存块中分配所有的对象,但每次填满,这个内存块就重新分配,这出现得相当频繁,因为每次块大小仅增长不超过50%。一个向STL vector插入10个元素,一次一个的实验最终导致大小分别为12346913对象的7次内存分配(MS Visual Studio 2008版)。可以预测或估计最终所需大小,在向vector加入第一个对象前,通过调用vector::reserve来防止这个浪费行为。其他STL容器没有这样一个预先保留内存的特性。

使用newdelete(或者mallocfree)频繁分配与释放内存,导致内存变得碎片化,缓存效率下降。对内存管理与垃圾收集,有巨大的开销代价,如上面提到的。

STL的通用性还有代码尺寸方面的代价。事实上,STL被批评造成代码膨胀与复杂性增加(en.wikipedia.org/wiki/Standard_Template_Library)。保存在STL容器里的对象允许有构造函数与析构函数。每次移动对象时,调用每个对象的拷贝构造函数与析构函数,这发生得相当频繁。如果保存的对象本身也是容器时,这是必须的。但在STL中把一个矩阵实现为vectorvector,这很常见,肯定是一个非常低效的解决方案。

许多容器使用链表。链表是制作可扩展容器一个便利的方法,但它效率很低。在许多情形里,线性数组比链表快。

STL中用于访问容器元素的所谓迭代器,对许多程序员而言是笨拙的,如果你可以使用一个具有简单索引的线性列表,它们是不必要的。在某些情形里,好的编译器可以优化掉迭代器额外的开销,但不是全部。

幸好,在执行速度、内存经济性及小代码尺寸比代码通用性优先级更高时,有更高效的替代方案。最重要的措施是内存池。将许多东西一起保存在一个大内存块中,比将每个对象保存自己分配的内存块里,效率更高。如果没有拷贝构造函数以及析构函数可调用,包含许多对象的大内存块可以通过调用memcpy一次拷贝或移动,而不是分别移动每个对象。

我实现了使用这些方法改进效率的一组容器类例子。它们作为本手册的附录,包含几个用途的类与模板,保存在www.agner.org/optimize/cppexamples.zip。所有这些例子都对速度与减少内存碎片优化。出于安全原因,包括了边界检查,但如果性能原因要求,可以在调试后移除。在STL的性能不能满足的情形下,使用这些例子容器。

在对一个特殊用途选择容器时,应该考虑以下因素:

  • 包含一个还是多个元素?如果容器仅保存一个元素,那么使用智能指针(参考第27页)。
  • 大小在编译时刻已知吗?如果元素数在编译时刻已知,或者可以设置一个不是太大的上限,那么最优的方案是固定大小数组或不使用动态内存分配的容器。不过,动态内存分配可能是需要的,如果数组或容器对栈而言太大。
  • 在保存第一个元素前,大小已知吗?如果在保存第一个元素前,要保存的元素总数已知(或者可以做出一个合理的估计),那么最好使用允许你预先保留所需内存的容器,而不是分段分配或者在内存块变得太小时重新分配。
  • 对象连续编号吗?如果对象由连续索引或者一个有限范围的键值标识,那么简单的数组就是最高效的解决方案。
  • 需要多维结构吗?矩阵或多维数组应该保存在一个连续的内存块中。不要每行或每列使用一个容器。如果每行元素数在编译时刻已知,访问会更快。
  • FIFO方式访问对象吗?如果对象的访问基于先进先出(FIFO),那么使用队列。将队列实现为循环缓冲比链表更高效。
  • FILO方式访问对象吗?如果对象的访问基于先进后出(FILO),那么使用带有栈顶索引的线性数组。
  • 对象由键值识别吗?如果键值局限在一个狭窄的区域,那么可以使用简单数组。如果对象数量很多,那么最高效的解决方案可能是二叉树或者哈希表。
  • 对象本质上是有序的吗?如果你需要进行这种类型的查找:“最接近x的元素是什么?”或者“xy之间有多少个元素?”,那么你可以使用排序链表或二叉树。
  • 在加入所有对象后需要查找吗?如果需要查找,但仅在保存了所有的对象之后,那么线性数组将是一个有效的解决方案。在加入所有元素后排序数组,然后使用二分搜索查找元素。哈希表也可能是一个有效的方案。
  • 在加入使用对象前需要查找吗?如果需要查找,且新对象可在任何时间加入,那么解决方案更复杂。如果元素总数少,因为简单,排序链表是最高效的方案。但如果链表很大,排序链表会非常低效,因为在链表中插入新元素需要移动所有后续的元素。在这个情形里,需要二叉树或者哈希表。如果元素本质是有序的,且对一个特定间隔中的元素有查找要求,可以使用二叉树。如果元素没有特定的序,但可由唯一的键识别,可以使用哈希表。
  • 对象有固定类型或大小吗?在同一个内存池里保存不同类型的对象或者不同长度的字符串是可能的。参考www.agner.org/optimize/cppexamples.zip。如果元素的数量与类型在编译时刻已知,无需使用容器或内存池。
  • 对齐?某些应用要求数据对齐在取整的地址。特别是使用固有向量要求对齐到被16整除的地址。在某些情形里,把数据结构对齐到被缓存行大小整除的地址会提高性能。
  • 多线程?容器类通常不是线程安全的,如果多线程会同时添加、删除或修改对象。在多线程应用中,对每个线程使用独立的容器,比每个线程临时锁定容器独占访问,更高效。
  • 有包含对象的指针?制作指向被包含对象的指针是不安全的,因为在需要重新分配内存的情形里,容器可能移动了这个对象。容器内对象应该由它们的索引或容器中的键值识别,而不是通过指针或引用。不过,把这样一个对象的指针或引用传递给不添加或删除任何对象的函数是可以的,如果没有其他线程访问这个容器。
  • 容器会被循环使用吗?创建与删除容器有很大的代价。如果程序逻辑允许,重用容器比删除它并创建新的一个更高效。

我在www.agner.org/optimize/cppexamples.zip提供了合适容器类的几个例子。如果不需要STL容器的完整通用性与灵活性,这些可用作标准模板库(STL)的替代。你可以编写自己的容器类或修改现有的类,以适应特定的需要。

9.8. 字符串

字符串通常有在编译时刻未知的可变长度。在像stringwstringCstring类中字符串的储存,在每次创建或修改字符串时,使用newdelete分配一个新内存块。如果程序创建或修改许多字符串,这会相当低效。

在大多数情形里,处理字符串最快的方式是使用字符数组的旧式C方式。可以通过C函数,比如strcpystrcatstrlensprintf等操作字符串。但小心这些函数不检查数组溢出。数组溢出会导致在程序别处不可预期的错误,诊断它们非常困难。确保数组对字符串处理足够大,包括结尾的0,在必要时进行溢出检查,是程序员的责任。常见字符串函数的快速版本,以及用于字符串查找与解析的高效函数在www.agner.org/optimize/asmlib.zipasmlib库里提供。

如果你希望在不危害安全的情况下提升速度,你可以在内存池里保存所有的字符串,如上面所示。例子在www.agner.org/optimize/cppexamples.zip的本手册附录里提供。

你可能感兴趣的:(Agner,Fog编写的优化手册,c++,性能优化)