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 的底层存储行为展开,重点验证以下问题:
std::vector的size和capacity有什么区别?- 连续
push_back()时,vector 什么时候需要重新分配内存? - vector 的容量是否一定按照 2 倍增长?
data()为什么可以用来观察底层存储地址变化?reserve()有什么作用?reserve()和resize()有什么区别?- vector 扩容后,之前取得的指针、引用和迭代器为什么会失效?
- 为什么普通运行时失效指针可能还能读出一个数字?
- ASan 为什么将这种错误报告为
heap-use-after-free? - 如何从 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
实验中需要观察:
sizecapacitydata()地址- 重新分配次数
- 失效指针访问结果
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::movenoexcept- 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. 本实验核心结论
std::vector使用连续存储。size()表示当前元素数量。capacity()表示不重新分配时能够容纳的最大元素数量。- 当
size == capacity且继续插入时,vector 通常需要重新分配更大的连续存储空间。 - 扩容倍率不是 C++ 标准规定的。
data()可以取得 vector 底层连续存储区域的首地址。- 比较扩容前后的
data()可以观察存储区域是否改变。 reserve(n)可以提前准备容量,减少重新分配。reserve()不等于resize()。- 发生重新分配后,所有指向原元素的指针、引用和迭代器都会失效。
- 指针仍保存旧地址,不代表旧对象仍然存在。
- 解引用失效指针属于未定义行为。
- 普通运行读出的垃圾值没有可靠意义。
- ASan 可以将本实验准确报告为
heap-use-after-free。 - ASan 的
freed by可以找到旧存储被释放的位置。 - ASan 的
previously allocated by可以找到旧存储最初被申请的位置。 - Shadow Byte
fd表示已经释放的堆区域。 - vector 的几何增长使大量
push_back()可以达到摊销 O(1)。 reserve()除了性能优化,也能在容量范围内减少因扩容造成的地址失效。- vector 的扩容问题本质上仍然属于 C++ 对象生命周期和资源管理问题。
44. 推荐后续实验
下一步可以把前面写过的 Tracker 类放进 vector:
std::vector<Tracker> values;
然后故意使用较小 capacity:
values.reserve(2);
连续加入三个对象。
通过 Tracker 的日志观察扩容时:
- 哪些对象被构造
- 旧元素是否调用移动构造
- 什么时候析构旧对象
noexcept移动构造对 vector 搬迁策略有什么影响
这样可以把目前已经学习的两条线真正合并:
对象生命周期 / move / noexcept
+
vector 扩容机制
↓
STL 容器资源管理
这是比单独背 vector 面试题更有效的一步。