大家都在用的c++ STL就一定是完美无缺的吗?
本文一针见血的指出常见STL顺序容器vector的致命bug。
在Scott Meyers的《Effective C++》中,第一个条款明确指出,C++是一个语言联邦。
这体现在:
● C:C++继承了C语言的基础特性,包括区块、语句、预处理器、内置数据类型、数组、指针等。
● Object-Oriented C++:这是C++的面向对象编程部分,包括类(构造/析构函数)、封装、继承、多态、虚函数等。
● Template C++:C++的泛型编程部分,它引入了模板元编程(TMP)等新的编程范式。
● STL(Standard Template Library):C++的标准模板库,为容器、迭代器、算法以及函数对象等提供了高效的实现和规约。
我们可以看出STL容器在现代C++语言中的地位了,可以说是支撑柱一样的角色。
很多软件处理的数据单位以GB计算,这就意味着开辟内存时,数据结构往往有上千万个元素,而每个元素(结构体或基本数据类型)往往有数百个字节。那么用最基本vector或list容器就可以完美处理这么多的元素吗?
显然是不能的。首先list容器可以先排除,因为底层数据结构是双链表的list取值非常慢,这显然是程序无法忍受得了的。那么为了处理大批量的连续数据,很多人就会自然而然地选择vector容器,当然,很多C++开源库也是这样做的。
但是当vector容器要容纳并处理“数据结构往往有上千万个元素,而每个元素(结构体或基本数据类型)往往有数百个字节”这样的数据时,内存泄漏的恐怖bug就来了。明明逻辑没有错误,为什么会出现内存泄漏呢?这就不得不从vector的底层中说起。
vector的底层机制:可变大小数组
- 内存管理:
○ std::vector 使用连续的内存空间来存储元素。这意味着当你访问一个元素时,可以通过简单的指针运算快速定位到该元素(与数组类似)。
○ 当 vector 的大小超过其当前分配的内存空间时,它需要重新分配一块更大的内存空间,并将原有的元素复制到新的内存位置。这个过程被称为“扩容”或“重新分配”。 - 扩容策略:
○ 当 vector 需要扩容时,它通常会分配比当前所需大小更大的内存空间,以便在将来插入更多元素时减少重新分配的次数。这种策略被称为“预留容量”(reserve)或“过度分配”。
○ 具体的扩容策略(即每次扩容时增加多少容量)可能因不同的编译器和库实现而异。但通常,每次扩容时,vector 的大小会翻倍或按某个固定比例增长。 - 迭代器:
○ std::vector 提供了迭代器(iterator)来访问和修改其元素。迭代器是类似于指针的对象,可以用于遍历 vector 中的元素。
○ 由于 vector 使用连续的内存空间,其迭代器通常是简单的指针或指针的封装。这使得迭代器的操作(如递增、解引用等)非常高效。 - 插入和删除操作:
○ 在 vector 的尾部插入或删除元素是常数时间复杂度的操作,因为只需要调整 vector 的大小计数器并可能重新分配内存(对于插入操作)。
○ 在 vector 的中间或开头插入或删除元素则可能涉及元素的移动,因此具有线性时间复杂度。这是因为需要重新排列元素以保持连续的内存空间。 - 容量和大小:
○ std::vector 有两个重要的属性:size() 和 capacity()。size() 返回 vector 中实际元素的数量,而 capacity() 返回 vector 当前分配的内存空间可以容纳的元素数量。
○ 你可以使用 reserve() 成员函数来预留容量,以减少因扩容导致的重新分配次数。
○ 你也可以使用resize() 成员函数来更改容量大小。多就删除,少就开辟。 - 异常安全性:
○ std::vector 的操作通常是异常安全的。这意味着如果某个操作(如插入或删除元素)在执行过程中抛出异常,vector 的状态将保持不变(即回滚到操作开始前的状态)。
vector 是使用 3 个迭代器来表示的:
其中statrt指向vector 容器对象的起始字节位置;
finish指向当前最后一个元素的末尾字节
end_of指向整个 vector 容器所占用内存空间的末尾字节。
下图演示了迭代器分别指向的位置。
在此基础上,将 3 个迭代器两两结合,还可以表达不同的含义,例如:
start 和 finish 可以用来表示 vector 容器中目前已被使用的内存空间;
finish 和 end_of可以用来表示 vector 容器目前空闲的内存空间;
start和 end_of可以用表示 vector 容器的容量。
vector的对象是如何增长的
vector对象为了支持快速随机访问,其物理存储方式是连续存储的,又因为vector是动态大小的,所以这就涉及到了一个问题。如果当前的vector容器分配的存储空间空间已经满了,不能再添加新的元素,那么就需要重新分配一块内存空间,将原来的值复制过去并添加新的元素。但是如果每次添加都重新分配内存空间的话,vector的效率会非常的低。所以为了避免这种低效的方式,vector有自己的内存增长方式,需要注意的是,这种增长方式不同的标准库实现者策略是不同的。总的来说,vector和string通常会分配比新空间需求(新空间需求即:容器当前需要存进去多少元素)更大的内存空间。以 VS 为例,新增加的容量一般是原来的0.5倍。
和容量相关的成员函数如下:
容器实际能够容纳的元素个数通常大于或者等于当前的需求,也就是容器会有多余的空间,当你声明一个新的vector对象时,一般的预分配空间是32个元素的空间。使用shrink_to_fit()可以收回多余的空间,但是这依赖于标准库的实现者,实现者有权不收回多余的空间。
capacity(),表示容器在不重新分配内存空间的情况下,最多可以容纳多少元素。
reserve(),改变容器的capacity。需要注意的是,reserve(n)分配的内存空间是小于等于capacity的。且当n小于容器当前实际存储的元素个数时,reserve是不会起作用的。也就是说reserve的n只是一个参考,当n<size()时,reserve不会起作用;当n>size()时,capacity()>=n。
那么:
capacity()表示在不重新分配内存空间的情况下,最多可以容纳多少元素。
size()表示当前容纳了多少元素。
并且:
resize()改变的是size()的大小,但是如果resize(n),n大于capacity(),就会改变capacity()。也就是,多就删除,少就开辟。
reserve()改变的是capacity()的大小,reserve(n),n<=capacity()。reserve()需要在声明vector对象后使用。如果中途使用,会带来意想不到的内存bug。
这就是,中途更改vector空间,一般用resize()。
当 vector 的大小和容量相等(size()==capacity())也就是满载时,如果再向其添加元素,那么 vector 就需要扩容。vector 容器扩容的过程需要经历以下 3 步:
完全弃用现有的内存空间,重新申请更大的内存空间;
将旧内存空间中的数据,按原有顺序移动到新的内存空间中;
最后将旧的内存空间释放。
但是,vector 容器在进行扩容后,与其相关的指针、引用以及迭代器有可能会失效。
由此可见,vector 扩容是非常耗时的。为了降低再次分配内存空间时的成本,每次扩容时 vector 都会申请比用户需求量更多的内存空间(这也就是 vector 容量的由来,即 capacity()>=size()),以便后期使用。
vector在处理大数据量时的缺陷
vector 在处理大数据量时的缺陷或限制,通常与内存管理、性能开销和数据局部性有关。即:
内存碎片:当 vector 需要扩容时,它通常会分配一块新的、更大的内存区域,并将旧数据复制过去。这可能会导致内存碎片,因为被释放的旧内存区域可能无法被有效地重新利用,特别是在内存分配和释放操作非常频繁的情况下。
性能开销:扩容操作(即分配新内存和复制数据)是昂贵的,特别是当 vector 包含大量数据时。这可能导致添加新元素的开销显著增加,特别是在实时或性能敏感的应用程序中。
数据局部性:vector 将元素连续存储在内存中,这有助于缓存局部性(cache locality)。然而,当 vector 扩容时,元素可能会被移动到新的内存地址,这可能会破坏缓存局部性,导致缓存未命中(cache misses)增加,进而降低性能。
内存占用:vector 在扩容时通常会分配比当前所需更多的内存作为备用。这可能导致不必要的内存占用,特别是在处理大数据量时。虽然这有助于减少扩容操作的频率,但在某些内存很宝贵的嵌入式系统下,这会很严重,直接导致内存崩了。
迭代器失效:vector 扩容时,指向其元素的迭代器可能会失效。这可能会使在遍历过程中修改 vector 的操作变得复杂和容易出错。
如何解决这一问题
为了克服这些缺陷,使用自定义内存管理。
简单的说就是,对所有代码中的vector容器,用C语言中的malloc、calloc、free或者C++中的new、new[]、delete、delete[],进行了重写。你也可以看到很多优秀稳定的开源库也都使用C/C++的这种基本内存管理模型,比如FFmpeg。简单、方便、内存可控,这就是解决方案。