学习25 分钟阅读编辑本文 ↗

Standard C++ 基础回炉|第 1 课(1-4):std::vector 扩容、reserve 与迭代器失效

"通过实际观察 std::vector 的 size、capacity、data 地址变化与 ASan 的 heap-use-after-free 报告,理解动态扩容、reserve、连续存储以及指针、引用和迭代器失效。"

Standard C++ 基础回炉|第 1 课(1-4):std::vector 扩容、reserve 与迭代器失效

1. 实验目标

本实验围绕 std::vector 的底层存储行为展开,重点验证以下问题:

  1. std::vector 的 size 和 capacity 有什么区别?
  2. 连续 push_back() 时,vector 什么时候需要重新分配内存?
  3. vector 的容量是否一定按照 2 倍增长?
  4. data() 为什么可以用来观察底层存储地址变化?
  5. reserve() 有什么作用?
  6. reserve() 和 resize() 有什么区别?
  7. vector 扩容后,之前取得的指针、引用和迭代器为什么会失效?
  8. 为什么普通运行时失效指针可能还能读出一个数字?
  9. ASan 为什么将这种错误报告为 heap-use-after-free?
  10. 如何从 ASan 报告反推出 vector 的内存申请、扩容和释放过程?

本实验也和前面的"返回局部变量地址"实验形成对照:

返回局部变量地址
    ↓
对象位于栈上
    ↓
函数返回后对象生命周期结束
    ↓
stack-use-after-return

vector 扩容
    ↓
旧元素位于 vector 的堆存储区
    ↓
重新分配后旧存储区释放
    ↓
heap-use-after-free

二者共同说明一个非常重要的 C++ 原则:

指针变量仍然保存一个地址,不代表那个地址上的对象仍然有效。


2. 完整测试代码

#include <cstddef>
#include <iostream>
#include <vector>

void compare_reallocations() {
    std::cout << "=== no reserve ===\n";

    std::vector<int> without_reserve;
    const int* previous_address = without_reserve.data();
    std::size_t reallocations = 0;

    for (int value = 0; value < 100; ++value) {
        without_reserve.push_back(value);

        if (without_reserve.data() != previous_address) {
            ++reallocations;
            previous_address = without_reserve.data();

            std::cout << "size=" << without_reserve.size()
                      << ", capacity=" << without_reserve.capacity()
                      << ", address="
                      << static_cast<const void*>(without_reserve.data())
                      << '\n';
        }
    }

    std::cout << "reallocations: " << reallocations << "\n\n";

    std::cout << "=== reserve(100) ===\n";

    std::vector<int> with_reserve;
    with_reserve.reserve(100);

    previous_address = with_reserve.data();
    reallocations = 0;

    for (int value = 0; value < 100; ++value) {
        with_reserve.push_back(value);

        if (with_reserve.data() != previous_address) {
            ++reallocations;
            previous_address = with_reserve.data();
        }
    }

    std::cout << "size=" << with_reserve.size()
              << ", capacity=" << with_reserve.capacity()
              << ", reallocations=" << reallocations
              << "\n\n";
}

void trigger_invalid_access() {
    std::cout << "=== invalidated pointer ===\n";

    std::vector<int> values;
    values.reserve(2);
    values.push_back(10);
    values.push_back(20);

    int* old_pointer = values.data();
    int& old_reference = values.front();
    auto old_iterator = values.begin();

    std::cout << "before push_back:\n";
    std::cout << "  size:     " << values.size() << '\n';
    std::cout << "  capacity: " << values.capacity() << '\n';
    std::cout << "  address:  "
              << static_cast<void*>(values.data()) << '\n';
    std::cout << "  pointer:  "
              << static_cast<void*>(old_pointer) << '\n';

    // size 已等于 capacity,本次插入必然需要更大的存储空间。
    values.push_back(30);

    std::cout << "\nafter push_back:\n";
    std::cout << "  size:     " << values.size() << '\n';
    std::cout << "  capacity: " << values.capacity() << '\n';
    std::cout << "  address:  "
              << static_cast<void*>(values.data()) << '\n';

    std::cout << std::flush;

    // 三者均已失效。每次实验只解引用一个,便于阅读报告。
    std::cout << "old pointer value: " << *old_pointer << '\n';

    // 后续可以分别换成下面两行再次实验:
    // std::cout << "old reference value: " << old_reference << '\n';
    // std::cout << "old iterator value: " << *old_iterator << '\n';

    static_cast<void>(old_reference);
    static_cast<void>(old_iterator);
}

int main() {
    compare_reallocations();
    trigger_invalid_access();
}

3. 头文件说明

#include <cstddef>
#include <iostream>
#include <vector>

<vector>

提供:

std::vector

std::vector 是标准库中的动态数组容器,最重要的特征之一是:

元素在内存中连续存储。

对于:

std::vector<int> values;

如果里面有:

10 20 30

可以概念化为:

低地址                              高地址
  ↓                                  ↓
┌────────┬────────┬────────┐
│   10   │   20   │   30   │
└────────┴────────┴────────┘
   int      int      int

因此:

values.data()

可以取得第一个元素所在连续存储区域的首地址。


<cstddef>

本实验使用:

std::size_t

来统计重新分配次数:

std::size_t reallocations = 0;

std::size_t 是标准库中常用于表示对象大小、元素数量和下标相关数值的无符号整数类型。


<iostream>

用于打印:

std::cout

实验中需要观察:

  • size
  • capacity
  • data() 地址
  • 重新分配次数
  • 失效指针访问结果

4. 第一部分:观察 vector 自动扩容

函数:

void compare_reallocations()

首先比较两种情况:

不 reserve
vs
提前 reserve(100)

第一组:

std::vector<int> without_reserve;

创建一个空 vector。

此时:

size = 0
capacity 通常 = 0

5. size() 是什么

size() 表示:

vector 当前真正拥有多少个元素。

例如:

std::vector<int> values;

values.push_back(10);
values.push_back(20);
values.push_back(30);

此时:

values.size()

为:

3

内存概念上:

┌────┬────┬────┐
│ 10 │ 20 │ 30 │
└────┴────┴────┘

size = 3

6. capacity() 是什么

capacity() 表示:

当前已经获得的存储空间,在不重新分配内存的情况下最多可以容纳多少个元素。

例如:

size = 3
capacity = 4

可以理解成:

┌────┬────┬────┬──────────┐
│ 10 │ 20 │ 30 │ 预留空间 │
└────┴────┴────┴──────────┘
  ↑ 已存在3个      ↑还能放1个

size     = 3
capacity = 4

始终满足:

size <= capacity

7. data() 的作用

代码:

const int* previous_address = without_reserve.data();

data() 返回 vector 底层连续存储区域的首地址。

本实验不关心里面的具体元素,而是利用:

without_reserve.data()

观察底层存储空间有没有换位置。

每次 push_back() 后:

if (without_reserve.data() != previous_address)

如果地址变化:

旧 data 地址 != 新 data 地址

说明 vector 已经换了一块底层存储区域。

于是:

++reallocations;

记录一次重新分配。


8. 为什么要保存 previous_address

const int* previous_address = without_reserve.data();

它相当于给当前 vector 的底层存储地址拍一张快照。

每次:

without_reserve.push_back(value);

之后比较:

without_reserve.data() != previous_address

如果不同:

旧地址
0xAAAA

      ↓ push_back

新地址
0xBBBB

说明原来的连续空间已经不能继续满足需求,vector 获得了新的存储空间。

随后:

previous_address = without_reserve.data();

更新快照,为下一次比较做准备。


9. 第一组循环逐行分析

for (int value = 0; value < 100; ++value) {
    without_reserve.push_back(value);

向 vector 中依次加入:

0
1
2
...
99

共 100 个整数。

每插入一个元素,就检查:

if (without_reserve.data() != previous_address)

如果底层地址改变,就打印:

std::cout << "size=" << without_reserve.size()
          << ", capacity=" << without_reserve.capacity()
          << ", address="
          << static_cast<const void*>(without_reserve.data())
          << '\n';

这里同时观察三个量:

size
capacity
address

三者结合起来,就能看出 vector 的扩容过程。


10. 为什么地址打印时转成 const void*

代码:

static_cast<const void*>(without_reserve.data())

data() 返回:

const int*

转换为:

const void*

是为了明确告诉输出流:

我要观察的是这个指针保存的地址,而不是它指向的某个具体值。

void* 常用于表示不关心具体对象类型的通用内存地址。


11. 第一组实际输出

=== no reserve ===
size=1, capacity=1, address=0x11b2460
size=2, capacity=2, address=0x11b2470
size=3, capacity=4, address=0x11b2480
size=5, capacity=8, address=0x11b2498
size=9, capacity=16, address=0x11b24c0
size=17, capacity=32, address=0x11b2508
size=33, capacity=64, address=0x11b2590
size=65, capacity=128, address=0x11b2698
reallocations: 8

当前 GCC/libstdc++ 环境表现为:

capacity

0
↓
1
↓
2
↓
4
↓
8
↓
16
↓
32
↓
64
↓
128

所以插入 100 个元素时,本实验观察到 8 次底层地址变化。


12. vector 是否一定按照 2 倍扩容

不是。

实验中观察到:

1 → 2 → 4 → 8 → 16 → ...

但:

C++ 标准没有规定 std::vector 必须按照 2 倍扩容。

具体增长策略属于标准库实现细节。

不同:

  • 标准库实现
  • 编译器工具链
  • 版本
  • 平台

都可能采用不同增长策略。

因此面试不能回答:

vector 每次一定扩容为原来的两倍。

更准确的回答:

vector 通常采用几何增长策略,以保证尾部插入具有摊销常数复杂度。具体扩容倍率不是 C++ 标准规定的,我当前 libstdc++ 实验中观察到的是近似 2 倍增长。


13. vector 为什么需要扩大一整块连续空间

假设当前:

size = 2
capacity = 2

底层:

┌────┬────┐
│ 10 │ 20 │
└────┴────┘

现在执行:

values.push_back(30);

原来的空间已经没有空位。

但 vector 又要求:

所有元素连续存储。

因此它不能简单地在任意位置再申请一个 int:

旧区域
10 20

另一块随机地址
30

否则元素就不连续了。

典型过程是:

旧空间

┌────┬────┐
│ 10 │ 20 │
└────┴────┘

      ↓

申请更大的连续空间

┌────┬────┬────┬────┐
│    │    │    │    │
└────┴────┴────┴────┘

      ↓

把原来的元素移动/拷贝到新空间

┌────┬────┬────┬────┐
│ 10 │ 20 │    │    │
└────┴────┴────┴────┘

      ↓

构造新元素 30

┌────┬────┬────┬────┐
│ 10 │ 20 │ 30 │    │
└────┴────┴────┴────┘

      ↓

旧存储空间释放

这就是 vector 扩容的核心成本来源。

对于 int 这种简单类型,搬迁成本很小。

如果 vector 中保存的是复杂对象:

std::vector<BigObject>

重新分配时可能涉及大量元素的移动构造或拷贝构造,因此频繁扩容可能产生明显开销。


14. 第二组:reserve(100)

代码:

std::vector<int> with_reserve;
with_reserve.reserve(100);

reserve(100) 的含义是:

提前保证 vector 的 capacity 至少达到 100。

注意:

reserve(100);

并不是创建 100 个元素。

执行后通常:

size = 0
capacity >= 100

随后:

for (int value = 0; value < 100; ++value) {
    with_reserve.push_back(value);
}

由于已经准备好了足够的容量,100 次 push_back() 不需要因为容量不足而重新分配。

实际输出:

=== reserve(100) ===
size=100, capacity=100, reallocations=0

15. reserve() 的两个主要价值

价值一:减少扩容次数

没有 reserve:

申请
↓
扩容
↓
扩容
↓
扩容
↓
...

提前 reserve:

提前准备足够容量
↓
push_back
push_back
push_back
...

因此可以减少:

  • 内存重新分配
  • 元素移动/拷贝
  • allocator 调用

价值二:提高一段操作期间的地址稳定性

例如:

std::vector<int> values;
values.reserve(100);

values.push_back(10);

int* p = &values[0];

如果后续只进行不会超过当前 capacity 的尾部插入,那么不会因为容量增长发生重新分配。

因此已有元素的地址不会因为这类扩容而变化。

但要注意:

reserve() 不是永久地址稳定保证。

如果最终:

size > capacity

仍然需要重新分配。


16. reserve() 和 resize() 的区别

这是高频面试题。

reserve

values.reserve(100);

主要改变:

capacity

通常不改变:

size

例如:

size = 0
capacity = 100

此时还没有 100 个有效元素。


resize

values.resize(100);

会改变:

size

使 vector 真正拥有 100 个元素。

如果原容量不足,也可能导致 capacity 增长。

可以简单记:

操作 size capacity


reserve(n) 通常不变 必要时增加到至少 n resize(n) 改变为 n 必要时增加


17. 第二部分实验:制造指针失效

函数:

void trigger_invalid_access()

专门制造一次确定的 vector 重新分配,并保留扩容前的:

  • 指针
  • 引用
  • 迭代器

然后访问失效对象,让 ASan 捕获。


18. 为什么先 reserve(2)

std::vector<int> values;
values.reserve(2);

目的不是性能优化,而是:

精确控制实验条件。

随后:

values.push_back(10);
values.push_back(20);

得到:

size     = 2
capacity = 2

内存:

┌────────┬────────┐
│   10   │   20   │
└────────┴────────┘

size     = 2
capacity = 2

此时已经:

size == capacity

所以再插入一个元素时,现有存储空间肯定不够。

这就保证下一次:

values.push_back(30);

必须获得更大的存储空间。


19. 保存旧指针

int* old_pointer = values.data();

old_pointer 保存 vector 当前底层存储区域首地址。

实际:

address: 0x73e00750
pointer: 0x73e00750

说明:

values.data()
      │
      └──────────────┐
                     ↓
              ┌────┬────┐
              │ 10 │ 20 │
              └────┴────┘
                     ↑
              old_pointer

准确地说,old_pointer 指向第一个 int 元素。


20. 保存引用

int& old_reference = values.front();

front() 返回第一个元素的引用。

因此:

old_reference

引用的是:

values[0]

也就是值:

10

21. 保存迭代器

auto old_iterator = values.begin();

begin() 返回指向第一个元素的迭代器。

因此扩容前可以概念化为:

旧 vector storage

0x73e00750
     ↓
┌────┬────┐
│ 10 │ 20 │
└────┴────┘
  ↑
  ├── old_pointer
  ├── old_reference
  └── old_iterator

虽然三者的 C++ 类型和语义不同,但都关联到了旧存储区中的第一个元素。


22. 扩容前输出

std::cout << "before push_back:\n";
std::cout << "  size:     " << values.size() << '\n';
std::cout << "  capacity: " << values.capacity() << '\n';
std::cout << "  address:  "
          << static_cast<void*>(values.data()) << '\n';
std::cout << "  pointer:  "
          << static_cast<void*>(old_pointer) << '\n';

实际:

before push_back:
  size:     2
  capacity: 2
  address:  0x73e00750
  pointer:  0x73e00750

这证明 old_pointer 此时确实指向 vector 当前有效的底层元素。


23. 关键操作:第三次 push_back

values.push_back(30);

此时:

size = 2
capacity = 2

容量已经满。

因此 vector 必须扩大存储空间。

实际 ASan 版本输出:

after push_back:
  size:     3
  capacity: 4
  address:  0x73e00730

地址从:

0x73e00750

变成:

0x73e00730

说明底层存储区域已经改变。


24. 扩容过程的完整内存模型

扩容前:

旧存储区:0x73e00750

┌────────┬────────┐
│   10   │   20   │
└────────┴────────┘
  ↑
  ├ old_pointer
  ├ old_reference
  └ old_iterator

执行:

values.push_back(30);

vector 获得新空间:

新存储区:0x73e00730

┌────────┬────────┬────────┬────────┐
│   10   │   20   │   30   │        │
└────────┴────────┴────────┴────────┘

随后旧存储区释放:

旧存储区:0x73e00750

┌─────────────────┐
│   已经释放       │
└─────────────────┘

old_pointer   ─────► 旧区域
old_reference ─────► 旧区域
old_iterator  ─────► 旧区域

这些旧观察者不会自动改成新地址。

因此:

old_pointer
old_reference
old_iterator

全部失效。


25. vector 重新分配时的失效规则

当 vector 的操作导致 reallocation(重新分配) 时:

所有指向 vector 元素的指针、引用和迭代器都会失效。

因此本实验:

int* old_pointer = values.data();
int& old_reference = values.front();
auto old_iterator = values.begin();

values.push_back(30);

因为第三次 push_back() 导致 capacity 从 2 变成 4,所以三者全部失效。


26. push_back() 是否一定让迭代器失效

不是。

必须判断有没有发生重新分配。

例如:

size = 2
capacity = 10

执行:

values.push_back(30);

还有剩余空间,不需要重新分配。

原有元素仍然位于原地址。

对于 push_back():

  • 如果发生重新分配,所有元素的指针、引用、迭代器都失效。
  • 如果没有重新分配,已有元素的指针、引用、迭代器保持有效,但原来的 end() 会失效。

所以面试不能简单回答:

push_back() 一定导致迭代器失效。


27. 普通运行为什么读出了 18556000

普通版本:

old pointer value: 18556000

代码:

std::cout << "old pointer value: " << *old_pointer << '\n';

此时 old_pointer 仍保存旧地址,但旧存储区已经释放。

因此:

*old_pointer

属于:

Undefined Behavior。

18556000 没有可靠语义。

不能解释为:

原来的 10 被 vector 改成了 18556000。

真正情况是:

old_pointer
     ↓
仍保存旧地址
     ↓
旧地址所属存储已经释放
     ↓
继续读取
     ↓
Undefined Behavior

程序可能:

  • 读到垃圾值
  • 偶然读到旧值
  • 崩溃
  • 在不同环境出现不同结果

28. 为什么每次只测试一个失效对象

代码只真正访问:

*old_pointer

而另外两个测试被注释:

// std::cout << "old reference value: " << old_reference << '\n';
// std::cout << "old iterator value: " << *old_iterator << '\n';

这是一个很好的测试习惯。

因为:

ASAN_OPTIONS=halt_on_error=1

要求 ASan 发现第一个错误后立即停止。

如果一次同时测试三个失效对象,程序通常在第一个非法访问处就终止,后面的测试无法执行。

所以应当:

第一次运行:测试 old_pointer
第二次运行:测试 old_reference
第三次运行:测试 old_iterator

这样每份 ASan 报告都更清楚。


29. static_cast<void>(old_reference) 的作用

代码末尾:

static_cast<void>(old_reference);
static_cast<void>(old_iterator);

这里不是在测试它们。

主要用于明确表示:

这两个变量是有意保留但当前不真正使用的实验变量。

需要注意:

static_cast<void>(old_reference);

不会像:

std::cout << old_reference;

那样读取引用所指向的 int 值。

真正的非法读取测试仍然是:

*old_pointer

30. ASan 编译

g++ -std=c++17 -O0 -g \
  -Wall -Wextra -Wpedantic \
  -fsanitize=address,undefined \
  -fno-omit-frame-pointer \
  experiment_c.cpp -o with_asan

其中:

-fsanitize=address

开启 AddressSanitizer。

-fsanitize=undefined

开启 UndefinedBehaviorSanitizer。

-g

保留调试信息,使报告可以定位:

experiment_c.cpp:84
-fno-omit-frame-pointer

有利于获得更可靠的调用栈。


31. 树莓派上的运行方式

先取得 GCC 对应的 ASan 动态库:

LIBASAN="$(g++ -print-file-name=libasan.so)"

然后:

LD_PRELOAD="$LIBASAN" \
ASAN_OPTIONS=halt_on_error=1 \
./with_asan

其中:

LD_PRELOAD

是 Linux 动态链接器机制,用于让程序启动时优先加载指定共享库。

本实验环境需要显式加载 libasan.so。

注意不要写成:

RELOAD

正确变量名是:

LD_PRELOAD

32. ASan 报告第一部分:错误类型

核心:

ERROR: AddressSanitizer: heap-use-after-free

拆开理解:

heap
  ↓
堆内存

use
  ↓
正在使用

after-free
  ↓
但是这块内存已经释放

即:

程序正在访问一块已经释放的堆内存。

这与 vector 扩容过程完全对应。


33. 第二部分:访问方式

READ of size 4 at 0x73e00750 thread T0

说明:

READ

发生的是读取。

size 4

读取 4 字节。

对应:

*old_pointer

因为:

old_pointer

类型为:

int*

当前平台 int 占 4 字节。


34. 第三部分:错误发生在哪里

#0 ... in trigger_invalid_access()
experiment_c.cpp:84

对应:

std::cout << "old pointer value: " << *old_pointer << '\n';

所以 ASan 已经准确定位:

非法访问发生在解引用 old_pointer 的位置。


35. 第四部分:失效区域有多大

报告:

0x73e00750 is located 0 bytes inside of
8-byte region [0x73e00750,0x73e00758)

原 vector:

capacity = 2

保存两个 int:

2 × sizeof(int)
= 2 × 4
= 8 bytes

所以旧存储区域正好是:

[0x73e00750, 0x73e00758)

old_pointer 指向:

0x73e00750

刚好是这 8 字节区域的起始位置,因此报告:

0 bytes inside

这与:

old_pointer = values.data();

完全吻合。


36. 第五部分:谁释放了这块内存

ASan:

freed by thread T0 here:

调用链:

operator delete
↓
new_allocator<int>::deallocate
↓
allocator_traits::deallocate
↓
_Vector_base::_M_deallocate
↓
vector::_M_realloc_insert
↓
vector::emplace_back
↓
vector::push_back
↓
trigger_invalid_access()
experiment_c.cpp:73

最后落到自己的代码:

values.push_back(30);

这非常关键。

它证明:

旧内存不是我们手动 delete 的,而是 vector 在扩容过程中自动释放的。

完整过程:

push_back(30)
    ↓
容量不足
    ↓
vector 重新分配
    ↓
旧元素搬到新区域
    ↓
旧区域释放
    ↓
old_pointer 失效

37. 第六部分:旧内存最初在哪里申请

ASan:

previously allocated by thread T0 here:

调用链最终落到:

std::vector<int>::reserve
↓
trigger_invalid_access()
experiment_c.cpp:56

也就是:

values.reserve(2);

因此 ASan 给出了完整生命周期:

values.reserve(2)
    ↓
申请 8 字节堆空间
    ↓
push_back(10)
push_back(20)
    ↓
old_pointer 保存旧地址
    ↓
push_back(30)
    ↓
vector 扩容
    ↓
旧 8 字节区域释放
    ↓
*old_pointer
    ↓
heap-use-after-free

38. Shadow Memory

本次报告:

[fd]

图例:

Freed heap region: fd

表示:

这个地址对应的堆区域已经释放。

这和前一个悬空栈指针实验形成很好的对比:

实验 ASan 报告 Shadow Byte


返回局部变量地址 stack-use-after-return f5 vector 扩容后访问旧指针 heap-use-after-free fd

可以理解为:

f5
↓
对象死在“函数返回”

fd
↓
对象所在堆存储死在“内存释放”

虽然原因不同,但最终问题相同:

程序仍然持有旧地址,并错误地认为旧对象还存在。


39. 本实验与对象生命周期的联系

这个实验表面是在研究:

std::vector

实际上核心仍然是:

对象生命周期和引用有效性。

扩容前:

old_pointer
    ↓
有效 int 对象 10

扩容后:

旧 int 对象生命周期结束
新存储区中产生对应的新元素
old_pointer 仍保存旧地址

因此:

地址值存在
≠
对象存在

非空指针
≠
有效指针

这与之前返回局部变量地址的实验属于同一条知识主线。


40. vector 扩容时是移动还是拷贝

对于:

std::vector<int>

不太容易从日志中观察区别。

但如果元素是自定义类型:

std::vector<Tracker>

vector 扩容时需要把旧元素搬到新存储区域。

此时可能涉及:

移动构造
或
拷贝构造

现代 C++ 中,如果类型具有合适的 noexcept 移动构造函数,vector 通常更有机会安全地使用移动操作。

这可以和前面的:

  • 移动构造
  • std::move
  • noexcept
  • RVO/NRVO

知识继续串起来。


41. vector 为什么能做到摊销 O(1) 的 push_back

如果 vector 每次容量不足时只增加一个位置:

1
2
3
4
5
6
...

那么大量 push_back() 会频繁重新分配和搬迁已有元素。

典型实现采用几何增长:

1
2
4
8
16
32
...

虽然某一次扩容可能需要搬迁很多元素,但扩容不会每次插入都发生。

因此长期大量尾部插入的平均成本可以控制在:

摊销常数时间,amortized O(1)。

注意:

单次触发扩容的 push_back() 并不是严格 O(1),因为它可能需要搬迁已有元素。


42. 面试高频问题

vector 的 size 和 capacity 有什么区别?

size 是当前已经存在的元素数量,capacity 是当前底层存储空间在不重新分配的情况下最多能容纳的元素数量,并且始终有 size <= capacity。

reserve() 有什么作用?

reserve(n) 提前保证 capacity 至少达到 n,但不会创建 n 个元素,因此通常不改变 size。已知大致元素数量时可以减少后续扩容、内存重新分配以及元素搬迁。

reserve() 和 resize() 的区别?

reserve() 主要改变容量,不改变元素数量;resize() 会真正改变 size,增加时会构造新元素,缩小时会销毁多余元素。

vector 扩容一定是 2 倍吗?

不一定。C++ 标准没有规定具体增长倍率。标准库通常采用几何增长策略,我当前 libstdc++ 实验中观察到近似 2 倍增长。

vector 为什么扩容会导致指针失效?

vector 要保证连续存储。容量不足时通常需要申请一块更大的连续区域,将已有元素移动或拷贝过去,再释放旧存储区域。因此原来指向旧元素的指针、引用和迭代器都会失效。

push_back() 一定导致迭代器失效吗?

不一定。如果 push_back() 导致重新分配,则所有元素相关的指针、引用和迭代器都会失效;如果没有重新分配,已有元素的指针、引用和迭代器仍然有效,但原来的 end() 会失效。

什么是 heap-use-after-free?

程序访问了一块已经释放的堆内存。本实验中 vector 扩容释放了旧存储区域,但 old_pointer 仍保存旧地址,继续解引用后被 ASan 检测为 heap-use-after-free。


43. 本实验核心结论

  1. std::vector 使用连续存储。
  2. size() 表示当前元素数量。
  3. capacity() 表示不重新分配时能够容纳的最大元素数量。
  4. 当 size == capacity 且继续插入时,vector 通常需要重新分配更大的连续存储空间。
  5. 扩容倍率不是 C++ 标准规定的。
  6. data() 可以取得 vector 底层连续存储区域的首地址。
  7. 比较扩容前后的 data() 可以观察存储区域是否改变。
  8. reserve(n) 可以提前准备容量,减少重新分配。
  9. reserve() 不等于 resize()。
  10. 发生重新分配后,所有指向原元素的指针、引用和迭代器都会失效。
  11. 指针仍保存旧地址,不代表旧对象仍然存在。
  12. 解引用失效指针属于未定义行为。
  13. 普通运行读出的垃圾值没有可靠意义。
  14. ASan 可以将本实验准确报告为 heap-use-after-free。
  15. ASan 的 freed by 可以找到旧存储被释放的位置。
  16. ASan 的 previously allocated by 可以找到旧存储最初被申请的位置。
  17. Shadow Byte fd 表示已经释放的堆区域。
  18. vector 的几何增长使大量 push_back() 可以达到摊销 O(1)。
  19. reserve() 除了性能优化,也能在容量范围内减少因扩容造成的地址失效。
  20. vector 的扩容问题本质上仍然属于 C++ 对象生命周期和资源管理问题。

44. 推荐后续实验

下一步可以把前面写过的 Tracker 类放进 vector:

std::vector<Tracker> values;

然后故意使用较小 capacity:

values.reserve(2);

连续加入三个对象。

通过 Tracker 的日志观察扩容时:

  • 哪些对象被构造
  • 旧元素是否调用移动构造
  • 什么时候析构旧对象
  • noexcept 移动构造对 vector 搬迁策略有什么影响

这样可以把目前已经学习的两条线真正合并:

对象生命周期 / move / noexcept
              +
       vector 扩容机制
              ↓
      STL 容器资源管理

这是比单独背 vector 面试题更有效的一步。