优化动态分配内存的变量

优化动态分配内存的变量

除了使用非最优算法外,乱用动态分配内存的变量就是C++程序中最大的性能杀手。C++中的一些特性使用标准库容器、智能指针和字符串等动态分配内存的变量。这些特性可以提高C++程序的编写效率。但是,也有副作用:当发生性能问题时,new就不再是你的好朋友了。

C++变量回顾

每个C++变量在内存中的布局都是固定的,它们的大小在编译时就已经确定了。C++允许程序获得变量的字节单位的大小和指向该变量的指针,但并不允许指定变量的每一位的布局。C++的规则允许开发人员讨论结构体成员变量的顺序和内存布局,C++也提供了多个变量可以共享统一内存块的联合类型,但是程序所看到的联合是依赖于实现的。

变量的存储期

每个变量都有它的存储期,也称为生命周期。只有在这段时间内,变量所占用的存储空间或内存字节中的值才是有意义的。为变量分配内存的开销取决于存储期。

静态存储期

具有静态存储期的变量被分配在编译器预留的内存空间中。在程序编译时,编译器会为每个静态变量分配一个固定位置和固定大小的内存空间。静态变量的内存空间在程序的整个生命周期内都会被一直保留。所有的全局静态变量都会在程序执行进入main()前被构建,在退出main()之后被销毁。在函数内声明的静态变量则会在程序第一次进入函数前被构建,这表示它可能会和全局静态变量同时被构建,也可能直到第一次调用该函数时才会被构建。

我们既可以通过名字访问静态变量,也可以通过指针或是引用来访问该变量。

为静态变量创建存储空间是没有运行时开销的。不过,我们无法再利用这段存储空间。因此,静态变量适用于那些在整个程序的生命周期内都会被使用的数据。

在命名空间作用域内定义的变量以及被生命为static或是extern的变量具有静态存储期。

线程局部存储期

自C++11开始,程序可以声明具有线程局部存储期的变量。在C++11之前,有些编译器和框架也以一种非标准的形式提供了类似的机制。

线程局部变量在进入线程时被构建,在退出线程时被析构。它们的生命周期与线程的生命周期一样。每个线程都包含一份这类变量的独立的副本。

访问线程局部变量可能会比访问静态变量开销更高,这取决于操作系统和编译器。在某些系统中,线程局部存储空间是由线程分配的,所以访问线程局部变量的开销比访问全局变量的开销多一次指令。而在其他系统中,则必须通过线程ID索引一张全局表来访问线程局部变量。尽管这个操作的时间开销是常量时间,但是会发生一次函数调用和一些计算,导致访问线程局部变量的开销变得更大。

自C++11开始,用thread_local存储类型指示符关键字声明的变量具有线程局部存储期。

自动存储期

具有自动存储期的变量被分配在编译器在函数调用栈上预留的内存空间中。在编译时,编译器会计算出距离栈指针的偏移量,自动变量会以该偏移量为起点,占用一段固定大小的内存,但是自动变量的绝对地址直到程序执行进入变量的作用域内才会确定下来。

在程序执行于大括号括起来的代码块内的这段时间,自动变量是一直存在的。当程序运行至声明自动变量的位置时,会构建自动变量;当程序离开大括号括起来的代码块时,自动变量将会被析构。

与静态变量一样, 我们可以通过名字访问自动变量。不同的是,该名字只在变量被构建后至被析构前课件。当变量析构后,指向该变量的指针和引用可能仍然存在,而解引它们会导致未定义的程序行为。

与静态变量一样,为自动变量分配存储空间不会发生运行时开销。但不同的是,自动变量每次可以占用的总的存储空间是有限的。当递归不收敛或是发生深度函数嵌套导致自动变量占用的存储空间大小超出这个最大值时,会发生栈溢出,导致程序会突然终止。自动变量适用于那些只在代码块附近被使用的对象。

函数的形参变量具有自动存储期。除非使用了特殊的关键字,那些声明在可执行代码块内部的变量也具有自动存储期。

动态存储期

具有动态存储期的变量被保存在程序请求的内存中。程序会调用内存管理器,即C++运行时系统函数和代表程序管理内存的数据结构的结合。程序会在new表达式中显式地为动态变量请求存储空间并构建动态变量,这可能会发生在程序中的任何一处地方。稍后,程序在delete表达式中显式地析构动态变量,并将变量所占用的内存返回给内存管理器。

与自动变量类似,但与静态变量不同的是,动态变量的地址是在运行时确定的。

不同于静态变量、线程局部变量和自动变量的是,数组的声明语法被扩展了,这样可以在运行时通过一个表达式来指定动态数组变量的最高维度。在C++中,这是唯一一种在编译时所占用的内存大小不固定的情况。

动态变量没有自己的名字。当它被构建后,C++内存管理器会返回一个指向动态变量的指针。程序必须将这个指针赋给一个变量,这样既可以在最后一个指向该变量的指针被析构之前,将动态变量返回给内存管理器,否则就会有因不断地创建动态变量而耗尽内存的危险。如果没有正确地返回动态变量,现代处理器可能会在数分钟内耗尽数G字节的内存。

不同于静态变量和线程局部变量的是,动态变量的数量和类型可以随着时间改变,而不受到它们所消耗的内存总量的限制。另外,与静态变量和自动变量不同的是,管理动态变量使用的内存会发生显著的运行时开销。

new表达式返回的变量具有动态存储期。

变量的所有权

C++变量的另一个重要概念是所有权。变量的所有者决定了变量什么时候会被创建,什么时候会被析构。

全局所有权

具有静态存储期的变量整体上被程序所有。

词法作用域所有权

具有自动存储期的变量被一段大括号括起来的代码块构成的词法作用域所拥有。词法作用域可能是函数体,控制语句块,try-catch语句,抑或是由大括号括起来的多条语句。

成员所有权

类和结构体的成员变量由定义它们的类实例所有。

动态变量所有权

动态变量没有预定义的所有者。取而代之,new表达式创建动态变量并返回一个必须由程序显式管理的指针。动态变量必须在最后一个指向它的指针被销毁之前,通过delete表达式返回给内存管理器销毁。因此,动态变量的生命周期是可以完全通过编程控制的,它是一个强大且危险的工具。如果在最后一个指向它的指针被销毁之前,动态变量没有通过delete表达式被返回给内存管理器,内存管理器将会在程序剩余的运行时间中丢失对变量的跟踪。

值对象和实体对象

实体
- 实体是独一无二的
值是不可变的 实体是可变的
值是可以复制的 实体是不可复制的
值是可互换和可比较的 实体是不可比较的

一个类的成员变量是实体还是值决定了应该如何编写该类的构造函数。类实例可以共享实体的所有权,但是无法有效地复制实体。实体变量中往往包含许多动态分配内存的变量,即使复制这些变量是合理的,但其性能开销也是昂贵的。

C++动态变量API回顾

指针和引用

C++中的动态变量是没有名字的。我们可以通过C风格的指针变量或是引用变量来访问它们。而由于声明引用变量时必须初始化它,因此它们总是指向有效地址。

new和delete表达式

C++中的动态变量是通过new表达式创建的。用于创建数组的new表达式与用于创建单实例的new表达式不同,但都会返回相同类型的指针。

C++中的动态变量是通过delete表达式释放的。用于释放数组的new表达式与用于释放单实例的new表达式不同,但都可以作用于相同类型的指针。但从指针上看不出它到底是数组还是单实例,所以开发人员必须自己记住类型。

内存管理函数

new和delete表达式会调动C++标准库的内存管理函数,在C++标注那种称为自由存储区的内存池中分配和归还内存。这些函数是new、new[]、delete、delete[]运算符的重载。C++还提供了经典的C函数库中的内存管理函数,如用于分配和释放无类型的内存块的malloc()和free()。

类构造函数和析构函数

C++允许每个类定义一个构造成员函数,在创建该类的实例会调用这个函数来进行初始化;析构函数则会在每次销毁类实例时被调用。除了其他优点意外,这些特殊的成员函数提供了放置new和delete表达式的场地,这样所有的动态成员变量都可以在类的实例中被自动管理起来。

智能指针

C++标准库提供了智能指针模板类。智能指针可以记住分配的存储空间是数组还是一个单实例,它会根据智能指针的类型调用正确的delete表达式。

分配器模板

C++标准库提供了分配器模板,它是new和delete表达式的泛化形式,可以与标准容器一起使用。

使用智能指针实现动态变量所有权的自动化

使用动态变量的程序难以测试和调试,因为其所有权太分散了。开发人员必须追踪所有的执行路径,确保动态变量总是正确地被返回给了内存管理器。

使用编程惯用法可以降低这种复杂性。一种常用的方法是将指针变量声明为某个类的私有成员变量。我们可以在类的构造函数中将指针设置为nullptr,复制指针参数,抑或是编写一个创建动态变量的new表达式。类的析构函数㕜可以包含用于销毁动态变量的delete表达式。由于指针是私有成员,因此任何对指针的修改都必须经由成员函数。这样就限制了会影响该指针的代码的行数,使测试和调试都变得更容易。

我们可以设计一个仅仅用于拥有动态变量的简单的类。除了构造和销毁动态变量外,还让这个类实现operator->()和operator*()运算符。这样的类称为智能指针,因为它的行为几乎与C风格的指针一致,当它被销毁时还能够销毁它所指向的动态对象。C++提供了一个称为std::unique_ptr的智能指针模板来维护T类型的动态变量的所有权。

动态变量所有权的自动化

智能指针会通过耦合动态变量的生命周期与拥有该动态变量的智能指针的生命周期,来实现动态变量所有权的自动化。动态变量将会被正确地销毁,其所占用的内存页会被自动地释放,这些取决于指针的声明。

  • 当程序执行超出智能指针实例所属的作用域时,具有自动存储期的智能指针实例会删除它所拥有的动态变量,无论这发生在执行break\continue时,还是退出函数或在作用域内抛出异常时。
  • 声明为类的成员函数的智能指针实例在类被销毁时会删除它所拥有的动态变量。
  • 当线程正常终止时,具有线程局部存储期的智能指针实例会删除它所拥有的动态变量。
  • 当程序结束时,具有静态存储期的智能指针实例会删除它所拥有的动态变量。

共享动态变量的所有权开销更大

C++允许多个指针和引用指向同一个动态变量。这时,开发人员必须注意哪个指针是变量的所有者。开发人员不应当显式地通过非所有者指针来删除动态变量,在删除动态变量后应当再解引任何一个指针,也不应进行会导致两个指针拥有相同对象的操作,这样它会被删除两次。当程序发生错误或异常时,这种分析变得尤为重要。

有时,动态变量的所有权一定会在两个或多个指针间共享。当两个指针的生命周期重叠,但任何一个方的生命周期都不包含另一方的生命周期时,就一定会共享所有权。

C++标准库模板std::shared_ptr提供了一个智能指针,可以在所有权被共享时管理被共享的所有权的。shared_ptr的实例包含一个指向动态变量的指针和另一个指向含有引用计数的动态对象的指针。当一个动态变量被赋值给shared_ptr时,赋值运算符会创建引用计数对象并将引用计数设置为1。当一个shared_ptr被赋值给另一个shared_ptr时,引用计数会增加。当shared_ptr被销毁后,析构函数会减小引用计数;如果此时引用计数变为了0,还会删除动态变量。由于在引用计数上会发生性能开销昂贵的原子性的加减运算,因此shared_ptr可以工作与多线程程序中。shared_ptr也因此比C风格指针和std::unique_ptr的开销更大。

开发人员不能将C风格的指针赋值给多个智能指针,而智能将其从一个智能指针赋值给另外一个智能指针。如果将同一个C风格的指针赋值给多个智能指针,那么该指针会被多次删除,导致发生C++标准中所谓未定义的行为。不过由于智能指针可以通过C风格指针创建,因此传递参数时所进行的隐式类型转换会导致这种情况发生。

std::auto_ptr与容器类

在C++11之前,有一个称为std::auto_ptr的智能指针,它也能够管理动态变量未共享的所有权。auto_ptr与unique_ptr在许多方面十分相似。不过,auto_ptr并没有实现移动语义,也没有复制构造函数。

C++11之前的绝大多数标准库容器都会通过复制构造函数将它们的值类型复制到容器内部的存储空间中,因此auto_ptr无法被用作值类型。在引入unique_ptr之前,不得不使用C风格指针、对象深复制或share_ptr实现标准库容器。这些解决方法都有问题。使用原生C风格指针会带来错误和内存泄漏的风险;对象深复制对于大型对象非常低效;share_ptr的开销非常大。容器类的复制构造函数会执行一个类似移动的操作,例如使用std::swap()将无主指针传递交换给构造函数,有些项目为这些容器类实现了特殊的非安全的智能指针。这能够让许多容器类成员函数正常工作,这虽然高效,却不安全,而且难以调试。

动态变量有运行时开销

从概念上来说,分配内存的函数会从内存块集合中寻找一块可以使用的内存来满足请求。如果函数找了一块正好符合大小的内存,它会将这块内存从集合中移除并返回这块内存。如果函数找到了一块比需求更大的内存,它可以选择拆分内存块然后只返回其中一部分。显然,这种描述为许多实现留下了可选择的空间。

如果没有可用的内存块来满足请求,那么分配函数会调用操作系统内核,才能够系统的可用内存池中请求额外的大块内存,这次调用的开销非常大。内核返回的内存可能会(也可能不会)被缓存在物理RAM中,可能会导致初次访问时发生更大的延迟。遍历可使用的内存块列表的开销也是昂贵的。这些内存块分散在内存中,而且与那些运行中的程序正在使用的内存块相比,它们也不太会被缓存起来。

未使用内存块的集合是由程序中的所有线程所共享的资源。对未使用内存块的集合所进行的改变都必须是线程安全的。如果若干个线程频繁地调用内存管理器分配内存或是释放内存,那么它们会将内存管理器视为一种资源进行竞争,导致除了一个线程外,所有线程都必须等待。

当不再需要使用动态变量时,C++程序必须释放那些已经分配的内存。从概念上来说,释放内存的函数会将内存块返回到可用内存块集合中。在实际的实现中,内存释放函数的行为会更复杂。绝大多数实现方式都会尝试将刚释放的内存块与邻近的未使用的内存块合并。这样可以防止向未使用的内存集合中放入过多太小的内存块。调用内存释放函数与调用内存分配函数有着同样的问题:降低缓存效率和争夺对未使用的内存块的多线程访问。

减少动态变量的使用

静态地创建类实例

有时候看起来必须动态地创建类实例,因为它是其他类的成员变量,而且用于创建该成员变量的资源在创建类实例时还未就绪。一种解决模式是将这个“问题类”声明为其他类的成员,并在创建其他类时部分初始化这个问题类。然后,在“问题类”中定义一个用于在资源准备就绪时完全地初始化变量的成员函数。最后,在原来使用new表达式动态创建实例的地方,插入一段调用这个初始化成员函数的代码就可以了。这种常用的解决模式被称为两段初始化。

两端初始化灭有额外开销,因为成员变量在完全创建之前是无法使用的。任何判断成员变量是否初始化完成的开销,都与判断指向它的指针是否为nullptr的开销是相同的。这种方法还有一个额外的好处,就是初始化成员函数可以返回错误代码或是其他信息,而构造函数不行。

当一个类必须在初始化的过程中做一些非常耗时的事情,两段初始化非常有效。提供一个单独的初始化函数使得与其他程序活动一起并发地进行这类初始化工作成为可能,而且如果失败了,也很容易进行第二次初始化。

使用静态数据结构

std::string、std::vector、std::map、std::list是C++ 程序员几乎每天比用的容器。只要使用得当,它们的效率还是比较高的。但它们并非是唯一选择。std::string、std::vector偶尔会重新分配它们的存储空间。std::map、std::list会为每个新添加的元素分配一个新节点。有时,这种开销非常昂贵。

用std::array替代std::vector

std::vector允许程序定义任意类型的动态大小的数组。如果在编译时能够知道数组的大小,或是最大的大小,那么可以使用与std::vector具有类似接口,但数组大小固定且不会调用内存管理器的std::array。

std::array支持复制构造,且提供了标准库风格的随机访问迭代器和下标运算符[]。size()函数会返回数组的固定大小。

从性能优化的角度看,std::array几乎与C风格的数组不分伯仲;从编程的角度看,std::array与标准库容器具有相似性。

在栈上创建大块缓冲区

随着字符串的增长,可能需要重新分配内存空间,因此向字符串中插入一个一个字符或是子字符串的开销非常昂贵。如果开发人员能够知道字符串可能的最大长度,那么就可以使用一个具有自动存储期且长度超过可能的最大长度的C风格的字符数组作为临时字符串,然后利用这个临时字符串进行字符串连接操作,最后再将结果从临时字符串中复制出来。尽管在栈上可以声明的总存储空间是有限的,但这种限制往往非常大。担心可能会发生局部数组溢出的谨慎的开发人员,可以先检查参数字符串或是数组的长度,如果发现参数长度大于局部数组变量的长度了,那么就使用动态构建的数组。

静态地创建链式数据结构

可以使用静态初始化的方式构建具有链式数据结构的数据。例如,可以静态地创建树形结构:

struct treenode {
    char const* name;
    treenode* left;
    treenode* right;
} tree[] = {
    {"D", &tree[1], &tree[2]},
    {"B", &tree[3], &tree[4]},
    {"F", &tree[5], nullptr},
    {"A", nullptr, nullptr},
    {"C", nullptr, nullptr},
    {"E", nullptr, nullptr},
}

这段代码之所以能够正常工作,是因为数组元素的地址是常量表达式。我们可以使用这种标记法定义任何链式结构,但是这种初始化方法难以记住,所以在构建这种结构时很容易出现编码错误。

另外一种静态地创建链式结构的方法是为结构中的每个元素都初始化一个变量。这种方式非常容易记忆,但是它的缺点是必须特别声明前向引用。声明这种结构最自然的方法需要将将这四个变量都声明为extern。

struct cyclenode {
    char const* name;
    cyclenode* next;
}
extern cyclenode first;
cyclenode fourth = {"4", &first};
cyclenode third  = {"3", &fourth};
cyclenode second = {"2", &third};
cyclenode first  = {"1", &second};

在数组中创建二叉树

二叉树是链式数据结构,其中每个节点都是包含指向左侧和右侧子节点的指针的单独的类实例。以这种方式定义二叉树的结果是,即使对于存储小至一个字符的类也需要足够的存储空间来存储两个指针,另外还需要加上内存管理器的开销。

一种解决方法是在数组中构建二叉树,然后不在节点中保存子节点的链接,而是利用节点的数组索引来计算子节点的数组索引。如果节点的索引是i,那么它的两个子节点的索引分别是2i和2i+1。这种方法带来的另一个好处是,能够很快地知道父节点的索引是i/2.由于这些乘法和除法运算在代码中可以实现为左移和右移,因此即使在处理能力非常差的处理器上这些计算也不会太慢。

以数组方式实现的树中的节点需要一种机制来判断它们的子节点是否有效,或是它们是否等于空指针。如果树是左平衡的,那么用一个整数值保存第一个无效节点的数组索引就够了。这些特性使得对于堆数据结构而言,在数组中构建树是一种高效的实现方法。

对于平衡二叉树而言,数组形式的树可能会比链式树低效。有些平衡算法保存一棵有n个节点的树可能需要2n长度的数组。而且,一次平衡啊哦做需要复制节点到不同的数组位置中,而不仅仅是更新指针。在更加小型的处理器上,对有很多节点的树进行处理时,这种复制操作的开销可能非常大。但是,如果节点的大小小于三个指针时,数组形式的树可能会在性能上领先。

用环形缓冲区替代双端队列

std::deque和std::list经常被用于FIFO(first-in-first-out,先进先出)缓冲区,以至于在标准库中有一个称为std::queue的容器适配器。其实,还可以在环形缓冲区上实现双端队列。环形缓冲区是一个数组型的数据结构,其中,队列的首尾两端由两个数组索引对数组的长度取模给定。

环形缓冲区与双端队列有着相似的特性,包括都有时间开销为常量时间的push_back()和pop_front()以及随机访问迭代器。不过,只要缓冲区消费者跟得上缓冲区生产者,环形缓冲区就无需重新分配内存。环形缓冲区的大小并不决定它能处理多少输入数据,而是决定了缓冲区生产者能领先多少。

不同在于,环形缓冲区使得缓冲区中元素的数据限制变得课件。通过暴露这项限制条件给使用者来特化通用队列数据结构,使得显著地性能提升成为可能。

使用std::make_shared替代new表达式

像std::shared_ptr这样的共享指针实际上包含了两个指针:一个指针指向std::share_ptr所指向的对象,另一个指针指向了一个动态变量,该变量保存了被所有指向该对象的srd::shared_ptr所共享的引用计数。因此,下面这条语句:

std::shared_ptr<myClass> p(new MyClass("hello", 123));

会调用两次内存管理器:第一次用于创建MyClass实例,第二次用于创建被隐藏起来的引用计数对象。

在C++11之前,分配引用计数器的开销是添加侵入式引用计数作为MyClass的基类,然后创建一个自定义的智能指针使用该侵入式引用计数。

custom_shared_ptr<RCClass> p(new RCClass("Goodbye",999));

C++标准库的编写者在了解到了开发人员的这种痛苦后,编写了一个称为std::make_shared()的模板函数,这个函数可以分配一块独立的内存来同时保存引用计数和MyClass的一个实例。std::shared_ptr还有一个删除器函数,它知道被共享的指针是以这两种方式中的哪一种被创建的。make_shared()的使用方法很简单:

std::shared_ptr<MyClass> p = std::make_shared<MyClass>("hello", 123);

也可以使用更简单的C++11风格的声明:

auto p = std::make_shared<MyClass>("hello", 123)

不要无谓地共享所有权

多个std::shared_ptr实例可以共享一个动态变量的所有权。当各个指针的生命周期会不可预测地发生重叠时,shared_ptr非常有用。但它的开销也很昂贵。增加和减少shared_ptr中的引用计数并不是执行一个简单的增量指令,而是使用完整的内存屏障进行一次非常昂贵的原子性增加操作,这样shared_ptr才能工作于多线程程序中。

如果一个shared_ptr的生命周期完全地包含了另一个shared_ptr的生命周期,那么第二个shared_ptr的开销是无谓的。比如,shared_ptr作为函数形参,函数调用的最小开销是一次无谓的原子性增加和减小操作,而且这两次操作都带有完整的内存屏障。在一次函数调用过程中,这种开销微不足道。但是作为一项编程实践,如果在程序中传递指针的每个函数参数都使用shared_ptr,那么整个开销就是非常巨大的。改为普通指针可以避免这种开销。

在C++编程世界中有一个常识,那就是永远不要在程序中使用C风格的指针,除非是要实现智能指针。但是也有另外一种观点认为,只要理解了普通指针,那么使用它们作为无主指针是没有问题的。将函数参数改为引用传递了两个信息:1. 调用方负责确保在调用过程中引用是有效的;2. 指针是非空指针。

使用“主指针”拥有动态变量

std::shared_ptr很简单。它会自动管理动态变量。但是共享指针的开销是昂贵的。在许多情况下,这都是没必要的。

经常出现的一种情况是,一个单独的数据结构在它的整个生命周期内拥有动态变量。指向动态变量的引用或是是指针可能会被传递给函数和被函数返回,或是被赋值给变量,等等。但是在这些引用中,没有哪个寿命比“主引用”长。

如果存在主引用,可以使用std::unique_ptr高效地实现它。然后,我们可以在函数调用过程中,用普通的C风格的指针或是C++引用来引用该对象。如果在程序中贯彻了这种方针,那么普通指针和引用就会被记录为无主指针。

当不使用std::shared_ptr时,一些开发人员会变得不安。不过,他们没有意识到每天使用的迭代器的行为与无主指针一样,可能会失效。使用主指针实际上不会发生导致内存泄漏或是双重释放的问题。当指针的拥有者显而易见时,使用主指针性能会更好;而当不确定指针的拥有者时,可以使用std::shared_ptr。

减少动态变量的重新分配

动态变量带来的便利太多了。我首先想到了std::string。但这并不意味着开发人员可以大意。当使用标准库容器时,总是有技术可以帮助我们减少内存分配的次数。我们可以泛化这些技术,让它们也适用于开发人员自己的数据结构。

预分配动态变量以防止重新分配

随着在std::string或是std::vector上数据的增加, 它内部的动态分配的存储空间终究会枯竭。下一个添加操作会导致需要分配更大的存储空间,以及将旧的数据复制到新存储空间中。对内存管理器的调用以及复制操作将会多次访问内存并消耗很多指令。诚然,添加操作的时间开销是O(1),但是比例常量可能会非常大。

string和vector都有成员函数reserve(size_t n),调用该函数会预先分配n个元素的空间。如果可以事先计算或是预估出这个大小,可以避免出现达到增长极限后需要重新分配存储空间的情况。与分配最差情况下的静态缓存不同的是,即使过小估计了预留的容量,代价也不过是额外的重新分配。而即使过大估计了,只要会在短暂地使用后被销毁,就都没问题。在使用reserve()预分配后,还可以使用shrink_to_fit()将未使用的空间返回给内存管理器。

标准库散列表类型std::unordered_map有一个链接到其他数据结构的骨干数组(桶的链表)。它也有一个reserve()成员函数。不幸的是,std::deque虽然也有一个骨干数组,却没有reserve()成员函数。

如果开发人员在设计包含了骨干数组的数组结构时,能够实现用于预分配骨干数组的内存的reserve()函数,那就帮了执行诶数据结构的用户一个大忙了。

在循环外创建动态变量

延长动态分配内存的变量的生命周期可以显著提升性能。比如,将临时的动态大小骨架的数据结构的声明,移到循环体外,甚至声明为类的成员变量。虽然对于某些开发人员来说,这种做法像是滥用全局变量。

移除无谓的复制

在C中,所有可以被直接赋值的实体都是char、int、float和指针等基本类型,它们都会被保存在一个单独的寄存器。在C++中,基本类型的赋值同样高效。但是类的赋值语句会调用赋值运算符成员函数,将所有字段复制过去。如果类中有动态变量,复制它们可能会引发对内存管理器的调用。另外,在C++中,如果初始化声明一个类,可能会调用复制构造函数。复制构造函数和赋值运算符是两个紧密相关的成员函数,它们做的事情几乎相同。

开发人员在寻找一段热点代码中的优化机会时,必须特别注意赋值和声明,因为这些地方可能会发生昂贵的复制:

  • 初始化(调用构造函数)
  • 赋值(调用赋值运算符)
  • 函数参数(每个参数表达式都会被移动构造函数或复制构造函数复制到形参中)
  • 函数返回(调用移动构造函数或复制构造函数,甚至可能会调用两次)
  • 插入一个元素到标准库容器中(会调用移动构造函数或复制构造函数复制元素)
  • 插入一个元素到vector中(如果需要重新分配内存,那么所有的元素都会通过移动构造函数或复制构造函数复制到新的vector中)

在类定义中禁止不希望发生的复制

并非程序中所有的对象都应当被复制。例如,具有实体行为的对象不应当被赋值,否则会失去它们的意义。

禁止复制:将复制构造函数和赋值运算符的可见性声明为private可以防止它们被外部调用。既然它们无法被调用,那么也就不需要任何定义,只需要声明就足够了。在C++11中,我们可以在复制构造函数和赋值运算符后面加上delete关键字来达到这个目的。,这个时候可见性设为public更好,因为这时调用复制构造函数的话编译器会报告出明确的错误消息。

// 在C++11中禁止复制的方法
class BigClass {
public:
    BigClass(BigClass const&) = delete;
    BigClass& operator=(BigClass const&) = delete;
    ...
};

但是还可以用指向该类的指针和引用来赋值或初始化变量,或是在函数中传递和返回指向该类实例的引用或指针。从性能优化的角度看,使用指针或引用进行赋值和参数传递,或是返回指针或引用更加高效,因为指针或引用是存储在寄存器中的。

移除函数调用上的复制

当程序低啊用函数时,会计算每个参数表达式,并以相对应的参数表达式的值作为初始化器创建每个形参。创建意味着会调用形参的构造函数。当形参是基本类型时,由于基本类型的构造函数是概念上的而非实际的函数,因此程序只会简单地将值复制到形参的存储空间中。但当形参是某个类的实例时,程序将调用这个类的复制构造函数之一来初始化实例。为了避免这猴子那个开销,我们可以将形参定义为带有平凡(trivial)构造函数的类型,比如指针和引用。

通过引用访问实例也会产生开销:每次访问实例时,那么连续地解引引用的开销会超过所节省下来的复制开销,导致性能改善收益递减。但是对于小型函数,除了特别小的类以外,通过引用传递参数总是能获得更好的性能。

引用参数在函数内部发生改变会导致它所引用的实例也发生改变,但是值参数却不会。将引用参数声明为常量引用可以防止不小心修改所引用的实例。

引用参数还会引入别名,这会导致不曾预料到的影响:

void func(int& a, int& b);
func(x, x);

移除函数返回上的复制

如果函数返回一个值,那么这个值会被复制构造到一个未命名的与函数返回值类型相同的临时变量中。

在有些情况下,通过返回引用而不是返回已经创建的返回值,可以避免发生复制的开销。不幸的是,如果在函数内计算出返回值后,将其复制给了一个具有自动存储期的变量,那么这个技巧将无法适用。当函数返回后,这个变量将超出它的作用域,导致悬挂引用将会指向一块堆内存尾部的未知字节,而且该区域通常都会很快被其他数据覆盖。更糟糕的是,函数计算返回结果是很普遍的情况,所以多数函数都会返回值,而非引用。

就像返回值的复制构造的开销并不算太糟糕,调用方也会调用复制构造函数或赋值运算符。在早期的C++程序中,这两次复制构造函数的开销简直是性能杀手。幸运的是,C++标准的制定人员和许多优秀的C++编译器找到了一种移除额外的复制构造函数调用的方法。这种优化方法被称为复制省略(copy elision)或是返回值优化(return value optimization,RVO)。开发人员可能听说过RVO,他们会误认为他们可以通过值返回对象,而不必担心复制构造的性能开销。但实际情况并非这样。只有在某些特殊情况下编译器才能够进行RVO:1. 函数必须返回一个局部对象;2. 编译器必须能够确定在所有的控制路径上返回的都是相同的对象;3. 返回对象的类型必须与所声明的函数返回值的类型相同。最简单的情况是,如果一个函数非常短小,并且只有一条控制路径,那么编译器进行RVO的可能性非常大。如果函数比较庞大,或是控制路径有很多分支,那么编译器将难以确定是否可以进行RVO。当然,各种编译器的分析能力也是不同的。

有一种方法可以移除函数内部的类实例的构造以及从函数返回时发生的两次复制构造。这需要开发人员手动编码实现,所以其结果肯定比寄希望于编译器要好。这种方法就是不用return语句返回值,而是在函数内更新引用参数,然后用过输出参数返回该引用参数。这种机制有以下几个优点:

  • 当函数被调用时,该对象已经被构建。有时,该对象必须别清除或是重新初始化,但是这些操作不太可能比构造操作的开销更大。
  • 在函数内被更新的对象无需在return语句中被赋值到未命名的临时变量中。
  • 由于实际数据通过残水返回了,因此函数的返回类型可以是void,也可以用来返回状态或错误信息。
  • 由于在函数中被更新的对象已经与调用方中的某个名字绑定在了一起,因此当函数返回时不再需要复制或是赋值。

这种机制会产生额外的运行时开销,例如额外的残水开销吗?其实并不会。编译器在处理返回实例的函数时,会将其转换为一种带有额外参数的形式。这个额外的参数是一个引用,它指向为用于保存函数所返回的未命名的临时变量的未初始化的存储空间。

在C++中有一种情况只能通过值返回对象:运算符函数。在实现运算符函数时必须格外小心,确保它们会使用RVO和移动语义,这样才能实现最高效率。

免复制库

当需要填充的缓冲区、结构体或其他数据结构是函数参数时,传递引用穿越多层库调用的开销很小。有些焦作“免复制”的库实现了这样的行为。这种模式出现在了许多性能需求严格的函数库中。这种方法值得学习。

RVO和移动语义只能降低部分开销,而且需要开发人员仔细地实现它们。从性能的角度来看,免复制的设计更加高效。

实现写时复制惯用法

写时复制(copy on write,COW)是一项编程惯用法,用于高效地复制那些含有复制开销昂贵的动态变量的类实例。COW是一项具有悠久历史、被广泛使用的优化技巧。不过,C++11标准禁止在std::string中使用COW,COW并非总是能够带来优秀的性能,我们必须小心使用它。

通常来说,当一个带有动态变量的对象被复制时,也必须复制该动态变量。这种复制被称为深拷贝。通过复制指针,而不是复制指针指向的变量得到包含无主指针的对象的副本,这种复制被称为浅拷贝。

写时复制的核心思想是,在其中一个副本被修改之前,一个对象的两个副本一直都是相同的。因此,直到其中一个实例被修改,两个实例能够共享那些指向复制开销昂贵的字段的指针。写时复制首先会进行一次浅拷贝,然后将深拷贝推迟到对象某个元素发生改变时。

在现代C++的COW的实现方式中,任何引用动态变量的类成员都是用如tsd::shared_ptr这样的具有共享所有权的智能指针实现的的。类的构造函数复制具有共享所有权的指针,将动态变量的一份新的复制的创建延迟到任何一份复制想要修改该动态变量时。

作用于类上的任何变值操作都会在真正改变类之前先检查共享指针的引用计数。引用计数值大于1表明所有权被共享了,那么这个操作会为对象创建一份新的副本,用指向新副本的共享指针交换之前的共享指针成员,并释放旧的副本(?)和减小引用计数。由于已经确保了动态变量没有被共享,现在可以进行变值操作了。

在COW类中使用std::make_shared()构建动态变量是非常重要的。否则,使用共享指针会发生额外的调用内存管理器来获取引用计数对象的开销。如果在类中有许多动态变量,那么这个开销与简单地将动态变量复制到新的存储空间中并赋值给一个(非共享的)智能指针的开销无异。因此,除非要复制很多份副本,或者变值运算符通常不会被调用,否则COW惯用法可能不会发挥什么作用。

切割数据结构

切割(slice)是一种编程惯用法,它指的是一个变量指向另一个变量的一部分。例如,C++17中推荐的string_view类型就指向一个字符串的子字符串,它包含了一个指向子字符串开始位置的char*指针以及到子字符串的长度。

被切割的对象通常都是小的、容易复制的对象,将其内容复制至子数组或子字符串中而分配存储空间的开销不大。如果被分割的数据结构为被共享的指针所有,那么切割是完全安全的。但是经验是,被切割的对象的生命是短暂的。它们在短暂地实现了存在意义后,就会在被切割的数据结构能够被销毁前超出它们的作用域。

实现移动语义

就性能优化而言,C++11加入的移动语义对C++具有非常重要的意义。移动语义解决了之前版本的C++中反复出现的问题,例子如下:

  • 将一个对象赋值给一个变量时,会导致其内部的内容被复制。这个运行时开销非常大。而在这之后,原来的对象立即被销毁了。
  • 开发人员希望将一个实体赋值给一个变量。在这个对象中,赋值语句中的“复制”操作是未定义的,因为这个对象具有唯一的识别符。

以上这两种情况对std::vector等动态容器有很大影响,因为伴随着容器中元素数量的增加,容器内部的存储空间必须被重新分配。第一种情况会导致重新分配容器的开销比实际所需更大。第二种情况则会导致实体无法被存储在容器中。

在C++11之前,C++没有提供任何标准方式来高效地将一个变量的内容移动到另一个变量中,无法避免那些不应当发生的复制开销。

非标准复制语义:痛苦的实现

当一个变量表现为实体时,创建它的一个副本通常都是一张通往未定义行为大陆的单程票。较好的做法是对这类变量禁用复制构造函数和赋值运算符。但是当容器重新分配时,又需要复制其中所容纳的对象,因此禁止复制意味着无法在容器中使用这类对象。

对于在移动语义出现之前,想把实体放进标准库容器的绝望的设计人员来说,一种解决方法是以非标准的形式实现赋值。例如,

代码清单 6-1 非复制赋值的hacky智能指针
hacky_ptr& hacky_ptr::operator=(hacky_ptr& rhs) {
    if (*thsi != rhs) {
        this->ptr_ = rhs.ptr_;
        rhs.ptr_ = nullptr;
    }
    return *this;
}

这个定义确保了所有权。以这种方式定义的指针可以在容器中工作。尽管赋值运算符的签名(注释)中可能会给出了一个微妙的提示,但从赋值运算自身上看不出它的行为异常。

std::swap():“穷人”的移动语义

在两个变量之间可能会进行的另外一种操作是交换——互换两个变量所保存的内容。即使当两个变量都是实体时,交换操作也很容易定义。这是因为在操作结束后,每个变量都各含有一个实体。C++提供了模板函数std::swap()来交换两个变量的内容。

在移动语义出现之前,std::swap()的默认实例化类似于利用一个中间的临时变量来完成交换。这仅工作于那些已经定义了复制操作的对象上。它还有潜在的性能问题:它一共进行了3次复制。

交换操作的强大之处在于它可以递归地应用于类的成员变量上。交换并不会复制指针所指向的对象,而是交换指针本身。对于那些指向大的、动态分配内存的数据结构,交换比复制更加高效。在实践中,std::swap()可以为某些类特化。标准容器提供了swap()成员函数来交换指向它们的动态成员变量的指针。容器类还提供了特化的std::swap(),可以在不调用内存管理器的情况下高效地进行一次交换。用户定义类型也同样可以提供特化版的std::swap()。

std::vector的定义使得它的骨干数组增长时并不会使用交换来复制它的内容,但是我们可以定义另外一种相似的数据结构来实现这个功能。

交换的问题在于,尽管对于带有需要深复制的动态变量的类而言,交换比复制更加高效,但对于其他类则比较低效。无论如何,交换至少对于有主指针和简单类型还是有意义的。

共享所有权的实体

实体无法复制。不过,一个指向实体的共享指针却可以复制。因此,虽然在移动语义出现之前无法创建std::vector<std::mutex>等,但是我们可以定义一个std::vector<std::shared_ptr<std::mutex>>。复制一个shared_ptr意义重大:创建一个额外的引用指向一个唯一的对象。

当然,让一个shared_ptr指向实体也是一种办法。虽然这种方法的优点是使用了C++标准库工具,但是它充满了不必要的复杂性和运行时开销。

移动语义的移动部分

标准库的实现人员认识到,他们需要将“移动”操作作为C++d基础概念。移动应当负责处理所有权的转移,它需要比复制更加高效,而且无论对于值还是实体都应当有良好的定义。其结果就是移动语义的诞生。

为了实现移动语义,C++编译器需要能够识别一个变量在什么时候只是临时值。这样的实例是没有名字的。例如,函数返回的对象或new表达式的结果就没有名字。不可能会有其他引用指向该对象。该对象可以被初始化、赋值给一个变量或是作为表达式或函数的参数。但是接下来会立即被销毁。这样的无名值被称为右值,因为它与赋值语句右侧的表达式的结果类似。相反,左值是指通过变量命名的值。当一个对象是右值时,它的内容可以被转换为左值。所需做的就是保持右值为有效状态,这样它的析构函数就可以正常工作了。

C++的类型系统被扩展了,它能够从函数调用上的左值中识别出右值。如果T是一个类型,那么声明T&&就是指向T的右值引用。函数重载的解析规则也被扩展了,这样当右值是一个实参时,优先右值引用重载;而当左值是实参时,则需要左值引用重载。

特殊成员函数的列表被扩展了,现在它包含了移动构造函数和一个移动赋值运算符。这些函数是赋值构造函数和赋值运算符的重载,它们接收右值引用作为参数。如果一个类实现了移动构造函数和移动赋值运算符,那么在进行初始化或是赋值实例时就可以使用高效的移动语义。

代码清单6-3是一个包含唯一实体的简单的类。编译器会为这个类自动地生成移动构造函数和移动赋值运算符。如果类的成员定义了移动操作,这些移动运算符就会对这些成员进行一次移动操作;如果没有,则进行一次复制操作。这等同于对每个类成员都执行this->member = std::move(rhs.member)

代码清单6-3 带有移动语义的类
class Foo {
    	std::unique_ptr<int> value_;
    public:
    ...
        Foo(Foo&& rhs) {
            value_ = rhs.release();
        }
        Foo(Foo csont& rhs) : value_(nullptr) {
            if (rhs.value_)
            	value_ = std::make_unique<int*>(*rhs.value_);
        }
}

实际上,编译器只会在当程序没有指定复制构造函数、赋值运算符或是析构函数,而且父类或是任何类成员都没有禁用移动运算符的简单情况下,CIA会自动生成移动构造函数和移动赋值运算符。这条规则是有意义的,因为这些特殊的函数定义的出现暗示可能需要一些特殊的东西。

如果开发人员没有提供或是编译器没有自动生成移动构造函数和移动赋值运算符,程序仍然可以编译通过。这时候,编译器会使用比较低效的复制构造函数和复制赋值运算符。由于自动生成的规则太过复杂,因此最好显式地声明、默认声明或是禁用所有特殊函数(默认构造函数、复制构造函数、复制赋值运算符、移动构造函数、移动赋值运算符和析构函数),这样可以让开发人员的意图更清晰。

更新代码以使用移动语义

我们可以修改各个类来让现有的代码也可以使用移动语义。下面这份检查项目清单有助于你进行这项工作:

  • 找出一个从移动语义中受益的问题。例如,在复制构造函数和内存管理函数上花费了太多时间可能表明,增加移动构造函数㶡移动赋值运算符可能会使那些频繁被使用的类受益。
  • 升级C++编译器(如果编译器中不带有标准库,还需要升级标准库)到一个更高级的支持移动语义的版本。在升级后要重新运行性能测试,因为改变编译器可能会显著地改变那些使用了字符串和适量等标准库组件的代码的性能,导致热点函数排行榜也随之发生变化。
  • 检查第三方库,查看是否有新的支持移动语义的版本。
  • 当遇到性能问题时,为类定义移动构造函数和移动赋值运算符。

移动语义的微妙之处

  1. 移动实例至std::vector

    如果你希望你的对象在std::vector中能够高效地移动,那么仅仅编写移动构造函数和移动赋值运算符是不够的。开发人员必须将移动构造函数和移动赋值运算符声明为noexcept。这很有必要,因为std::vector提供了强异常安全保证(strong exception safety guarantee):当一个vector执行某个操作时,如果发生了异常,那么该vector的状态会与执行操作之前一样。复制构造函数并不会改变源对象。移动构造函数则会销毁它。任何在移动构造函数中发生的异常都会与强异常安全保证相冲突。

    如果没有这么做,std::vector会使用比较低效的复制构造函数,并且编译器可能不会给出警告,代码仍然可以正常运行,不过会变慢。

    noexcept是一种强承诺。使用noexcept意味着不会调用内存管理器、IO或是其他任何可能会抛出异常的函数。同时,它也意味着必须忍受所有的异常,因为没有任何办法报告在程序中发生了异常。在Windows上,这意味着将结构化异常转换为C++异常充满了危险,因为打破了noexcept的承诺意味着程序会突然且不可撤销地终止。但是,这是高效要付出的代价。

  2. 右值引用参数是左值

    当一个函数接收一个右值引用作为参数时,它会使用右值引用来构建形参。因为形参是由名字的,所以尽管它构建与一个右值引用,它仍然是一个左值。

    幸运的是,开发人员可以显式地将左值转换为右值引用。标准库提供了漂亮的中的模板函数std::move()来完成这项任务。

    代码清单6-4 显式地移动
    std::string MoveExample(std::string&& s) {
        std::string tmp(std::move(s));
        // 注意! s现在是空的。
        return tmp;
    }
    
    ...
    std::string s1 = "hello";
    std::string s2 = "everyone";
    std::string s3 = MoveExample(s1 + s2);
    

    在代码清单6-4中,调用MoveExample(s1 + s2)会导致通过右值引用构建s,这意味着实参被移动到了s中。调用std::move(s)会创建一个指向s的内容的右值引用。由于右值引用是std::move()的返回值,因此它没有名字。右值引用会初始化tmp,调用std::string的移动构造函数。此时,s已经不再指向MoveExample()的实参字符串。它可能是一个空字符串。当返回tmp的时候,从概念上讲,tmp的值会被赋值到匿名返回值中,接着tmp会被删除。MoveExample()的匿名返回值会被复制构造到s3中。不过,实际上,在这种情况下编译器能够进行RVO,这样参数s会被直接移动到s3的存储空间中。通常,RVO比移动更高效。

  3. 不要返回右值引用

    直觉上,返回右值引用是合理的。但是实际上,返回右值引用会妨碍返回值优化,即允许编译器向函数传递一个指向目标的引用作为隐藏参数,来移除从未命名的临时变量到目标的复制。返回右值引用会执行两次移动操作,而一旦使用了返回值优化,返回一个值则只会执行一次移动操作。

    因此,只要可以使用RVO,无论是返回语句中的实参还是函数的返回类型,都不应当使用右值引用。

  4. 移动父类和类成员

    要想为一个类实现移动语义,你必须为所有的父类和类成员也实现移动语义。否则,父类和类成员将会被复制,而不会被移动。

    代码清单6-5 移动父类和成员
    Class Base {...};
    Class Derived : Base {
        ...
        std::unique_ptr<Foo> member_;
        Bar* barmember_;
    };
    
    Derived::Derived(Derived&& rhs)
      : Base(std::move(rhs)),
    	member_(std::move(rhs.member_)),
    	barmember_(nullptr) {
            std::swap(this->barmember_, rhs.barmember_);
    	}
    

    假设Base有移动构造函数,那么它只有在通过调用std::move()将左值rhs转换为右值引用后才会被调用。同样,只有当rhs.member_被转换为右值引用后才会调用std::unique_ptr的移动构造函数。而对于普通指针barmember_或其他任何没有定义移动构造函数的对象,std::swap()实现了一个类似移动的操作。

    在实现移动赋值运算符时,std::swap()可能会引起麻烦。麻烦在于this可能会指向一个已经分配了内存的对象。std::swap()不会销毁那些不再需要的内存。它会将它们保存在rhs中,直至rhs被销毁前者块内存都无法被重新利用。如果在一个类成员中有一个含有100w个字符的字符串或是包含一张100w个元素的表,这可能会是一个潜在的大问题。在这种情况下,最好先显式地复制barmember指针,然后在rhs中删除它,以防止rhs的析构函数删除释放它:

    void Derived::operator=(Derived&& rhs) {
        Base::operator=(std::move(rhs));
        delete(this->barmember_);
        this->barmember_ = rhs.barmember_;
        rhs.barmember_ = nullptr;
    }
    
    

扁平数据结构

当一个数据结构中的元素被存储在连续的存储空间中时,我们称这个数据结构为扁平的。想比于通过指针链接在一起的数据结构,扁平数据结构具有显著的性能优势。

  • 相比于通过指针链接在一起的数据结构,创建扁平数据结构实例是调用内存管理器的开销更小。有些数据结构(如list、deque、map、unordered_map)会创建许多动态变量而其他数据结构(vector)则较少。
  • std::array和std::vector等扁平数据结构所需的内存比基于节点的数据结构少,因为在基于节点的数据结构中存在着链接指针的开销。即使所消耗的总字节数没有问题,紧凑的数据结构仍然有助于改善缓存局部性。
  • 以前常常需要用到的技巧,比如用智能指针组成vector或是map来存储不可复制的对象,在C++11中的移动语义出现后已经不再需要了。移动语义移除了在分配智能指针和它所指向的对象的存储空间时产生的显著的运行时开销。

小结

  • 在C++程序中,乱用动态分配内存的变量是最大的性能杀手。当发生性能问题时,new不再是你的朋友。
  • 只要知道了如何减少对内存管理器的调用,开发人员就能够成为一个高效的性能优化专家。
  • 通过提供::operator new()和::operator delete()运算符,可以整体地改变程序分配内存的方式。
  • 通过替换malloc()和free()可以整体地改变程序管理内存的方式。
  • 智能指针实现了动态变量所有权的自动化。
  • 共享了所有权的动态变量更加昂贵。
  • 静态地创建类实例。
  • 静态地创建类成员并且在有必要时采用两段初始化。
  • 让主指针来拥有动态变量,使用无主指针替代共享所有权。
  • 编写通过输出参数返回值的免复制函数。
  • 实现移动语义。
  • 扁平数据结构更好。
posted @ 2020-05-21 18:12  睿阳  阅读(735)  评论(0编辑  收藏  举报