编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

    • 开发技巧

      • 信号崩溃快速排查
      • ASan内存三件套
      • GDB十命令速查
      • CoreDump破案
      • perf火焰图实战
      • 迭代器失效陷阱
        • 1. 案例引入:两条主线
          • 1.1 主线一:边遍历边删
          • 1.2 主线二:reserve 之憾
          • 1.3 顺藤摸到根因
          • 1.4 本篇要回答什么
        • 2. 迭代器失效的本质
          • 2.1 迭代器是什么
          • 2.2 失效等于野指针
          • 2.3 三种失效形态
          • 2.4 迭代器与引用同命
          • 2.5 标准的承诺与边界
        • 3. 容器内存模型
          • 3.1 vector 连续内存
          • 3.2 deque 分段映射
          • 3.3 list 链表节点
          • 3.4 map 红黑树
          • 3.5 unordered 桶数组
          • 3.6 失效的内存视角
        • 4. 六大容器失效矩阵
          • 4.1 vector 失效规则
          • 4.2 deque 反直觉规则
          • 4.3 list 最宽松
          • 4.4 map/set 同 list
          • 4.5 unordered 容器
          • 4.6 string 与 SSO
          • 4.7 总表速查
        • 5. 标准三招修法
          • 5.1 erase 返回值惯用法
          • 5.2 索引回避法
          • 5.3 erase_if 一行流
          • 5.4 三招对比
        • 6. erase-remove 惯用法
          • 6.1 remove 不删元素
          • 6.2 经典两段式
          • 6.3 remove_if 谓词版
          • 6.4 unique 去重同理
          • 6.5 C++20 的 erase_if
        • 7. 关联容器陷阱
          • 7.1 map erase 老式写法
          • 7.2 节点提取 extract
          • 7.3 unordered rehash 全废
          • 7.4 reserve 与 maxloadfactor
          • 7.5 multi 容器的等价范围
        • 8. 多线程并发场景
          • 8.1 标准容器不线程安全
          • 8.2 读写锁的迭代器
          • 8.3 拷贝快照法
          • 8.4 写时复制 COW
          • 8.5 并发安全容器
        • 9. 调试与捕获
          • 9.1 _GLIBCXX_DEBUG 模式
          • 9.2 libc++ debug mode
          • 9.3 ASan 抓 UAF
          • 9.4 静态分析工具
          • 9.5 编译告警与 lint
        • 10. 五步排查方法论
          • 10.1 重现:把概率拉满
          • 10.2 定位:失效点回放
          • 10.3 假设:哪类失效模式
          • 10.4 修复:三种修法选
          • 10.5 防御:CI 加保险
        • 11. 典型场景速查
          • 11.1 边遍历边删
          • 11.2 push 后引用失效
          • 11.3 缓存指向 vector
          • 11.4 跨函数返回迭代器
          • 11.5 范围 for 中改容器
          • 11.6 嵌套循环交叉删
          • 11.7 erase 返回的"下一个"
        • 12. 工程化最佳实践
          • 12.1 选容器先想增删
          • 12.2 reserve 是契约
          • 12.3 长生命周期句柄
          • 12.4 范围视图 ranges
          • 12.5 团队规范五条
          • 12.6 成熟度模型
        • 13. 综合案例串讲
          • 13.1 案例真相揭晓
          • 13.2 一次失效的一生
          • 13.3 设计哲学回扣
          • 13.4 失效修复速查表
          • 13.5 思考题
      • 智能指针选型
      • 异常安全RAII
      • 多线程锁选型
      • 编译期防御
  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 开发技巧
杨充
2026-06-15
目录

迭代器失效陷阱

# 第27章:迭代器失效陷阱

# 目录介绍

  • 1. 案例引入:两条主线
    • 1.1 主线一:边遍历边删
    • 1.2 主线二:reserve 之憾
    • 1.3 顺藤摸到根因
    • 1.4 本篇要回答什么
  • 2. 迭代器失效的本质
    • 2.1 迭代器是什么
    • 2.2 失效等于野指针
    • 2.3 三种失效形态
    • 2.4 迭代器与引用同命
    • 2.5 标准的承诺与边界
  • 3. 容器内存模型
    • 3.1 vector 连续内存
    • 3.2 deque 分段映射
    • 3.3 list 链表节点
    • 3.4 map 红黑树
    • 3.5 unordered 桶数组
    • 3.6 失效的内存视角
  • 4. 六大容器失效矩阵
    • 4.1 vector 失效规则
    • 4.2 deque 反直觉规则
    • 4.3 list 最宽松
    • 4.4 map/set 同 list
    • 4.5 unordered 容器
    • 4.6 string 与 SSO
    • 4.7 总表速查
  • 5. 标准三招修法
    • 5.1 erase 返回值惯用法
    • 5.2 索引回避法
    • 5.3 erase_if 一行流
    • 5.4 三招对比
  • 6. erase-remove 惯用法
    • 6.1 remove 不删元素
    • 6.2 经典两段式
    • 6.3 remove_if 谓词版
    • 6.4 unique 去重同理
    • 6.5 C++20 的 erase_if
  • 7. 关联容器陷阱
    • 7.1 map erase 老式写法
    • 7.2 节点提取 extract
    • 7.3 unordered rehash 全废
    • 7.4 reserve 与 max_load_factor
    • 7.5 multi 容器的等价范围
  • 8. 多线程并发场景
    • 8.1 标准容器不线程安全
    • 8.2 读写锁的迭代器
    • 8.3 拷贝快照法
    • 8.4 写时复制 COW
    • 8.5 并发安全容器
  • 9. 调试与捕获
    • 9.1 _GLIBCXX_DEBUG 模式
    • 9.2 libc++ debug mode
    • 9.3 ASan 抓 UAF
    • 9.4 静态分析工具
    • 9.5 编译告警与 lint
  • 10. 五步排查方法论
    • 10.1 重现:把概率拉满
    • 10.2 定位:失效点回放
    • 10.3 假设:哪类失效模式
    • 10.4 修复:三种修法选
    • 10.5 防御:CI 加保险
  • 11. 典型场景速查
    • 11.1 边遍历边删
    • 11.2 push 后引用失效
    • 11.3 缓存指向 vector
    • 11.4 跨函数返回迭代器
    • 11.5 范围 for 中改容器
    • 11.6 嵌套循环交叉删
    • 11.7 erase 返回的"下一个"
  • 12. 工程化最佳实践
    • 12.1 选容器先想增删
    • 12.2 reserve 是契约
    • 12.3 长生命周期句柄
    • 12.4 范围视图 ranges
    • 12.5 团队规范五条
    • 12.6 成熟度模型
  • 13. 综合案例串讲
    • 13.1 案例真相揭晓
    • 13.2 一次失效的一生
    • 13.3 设计哲学回扣
    • 13.4 失效修复速查表
    • 13.5 思考题

# 1. 案例引入:两条主线

讲迭代器失效,最忌讳"光列规则不讲案例"。本篇用两条真实主线贯穿全文:一条来自生产环境的偶发崩溃,一条来自十几行的最小可复现代码。前者展示"高并发场景下迭代器失效如何隐藏数月才爆发",后者展示"reserve 一行代码如何决定生死"。

# 1.1 主线一:边遍历边删

某订单调度服务,长期稳定,最近一周开始每天凌晨 2 点偶发崩溃——core dump 显示死在 std::_List_iterator::operator++,但代码看着无懈可击:

// scheduler.cpp —— 订单超时清理
struct Order {
    uint64_t id;
    int64_t  expire_at;
    State    state;
};

std::list<Order> pending_;   // 待处理订单(list 不是 vector!)

// 每分钟跑一次:清理已过期/已完成的订单
void cleanup_expired(int64_t now) {
    for (auto it = pending_.begin(); it != pending_.end(); ++it) {
        if (it->expire_at < now || it->state == State::DONE) {
            pending_.erase(it);   // ⚠️ erase 后 it 失效,但还 ++it
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

现象:

  • 测试环境(订单量小、几乎不触发清理路径):100% 通过
  • 生产环境(每天 2 点 4 万订单到期):SIGSEGV,调用栈在 list iterator increment

直觉怀疑:是不是 expire_at 字段被踩?打开 core 看:

(gdb) bt
#0  std::_List_iterator<Order>::operator++ (this=0x7ffd...) at stl_list.h:178
#1  cleanup_expired (now=1718524800) at scheduler.cpp:14

(gdb) frame 1
(gdb) p it
$1 = {_M_node = 0x7f8a... <已被释放>}

(gdb) p *it
Cannot access memory at address 0x7f8a...
1
2
3
4
5
6
7
8
9
10

铁证:it._M_node 是已被 delete 的链表节点——erase(it) 释放节点后,it 仍然持有那个已死的节点指针,下一次 ++it 试图读 _M_node->_M_next,撞到刚被 glibc tcache 回收的内存。

list::erase 只让"被删那一个迭代器"失效;但继续用它就是 use-after-free——这是 C++ 区别于 GC 语言的核心痛点。

为什么测试环境过、生产环境炸?

  • 测试场景没触发 if 分支 → erase 从未被调用 → 永远不会失效;
  • 生产凌晨 2 点订单批量过期 → erase 被频繁调用 → 必崩。

这是条件分支隐藏的 UAF——bug 写下去 8 个月才被触发一次。

# 1.2 主线二:reserve 之憾

另一位同学发来求助:

"我就写了个回调注册函数,跑起来前 4 个回调好的,第 5 个就崩了。完全看不懂。"

打开他的项目,逻辑抽离出来其实只有 18 行——这就是最小可复现案例(MCVE):

// crash.cpp —— 全文第二条主线,18 行
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    v.push_back(10);
    v.push_back(20);
    v.push_back(30);

    int& ref = v[1];                  // ① 拿到 v[1] 的引用,值是 20

    for (int i = 0; i < 100; ++i) {
        v.push_back(i);               // ② 不断 push_back,触发扩容
    }

    std::cout << "ref = " << ref      // ③ 打印 "ref"
              << " expect 20\n";
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

编译运行:

$ g++ crash.cpp -O0 -g -o crash
$ ./crash
ref = 1234567                          # ← 垃圾值,或随机数,或直接段错误
1
2
3

两个现象非常扎眼:

  • 没有任何崩溃信号(多数情况)—— 程序"正确"运行结束;
  • ref 的值已经不是 20 —— 它指向的是已被 delete[] 的旧内存。

好的调试,第一步永远是把问题简化到 MCVE。复杂工程里的迭代器失效无非三种:

  1. 显式 erase 后继续用旧迭代器(主线一);
  2. push_back / insert / rehash 触发底层重分配(主线二);
  3. 跨函数返回迭代器,被调用者持有过久。

# 1.3 顺藤摸到根因

带着两条主线往下挖,至少藏着这些原理点:

① 迭代器到底是什么? 它内部存了啥?              → 第 2 章
② vector / list / map / unordered 失效规则差别? → 第 4 章
③ 边遍历边删的"标准答案"长啥样?                → 第 5 章
④ erase-remove 为什么是两段式?                 → 第 6 章
⑤ unordered_map 为什么 insert 也能让全部失效?  → 第 7 章
⑥ 多线程下迭代器还能跨锁吗?                    → 第 8 章
⑦ debug mode 怎么把失效在第一现场抓住?         → 第 9 章
⑧ 复杂工程里怎么用方法论一步步逼到根因?        → 第 10 章
1
2
3
4
5
6
7
8

# 1.4 本篇要回答什么

层次 你将学到
原理层 迭代器的本质(指针/节点/桶下标)、失效就是野指针
容器层 vector/deque/list/map/unordered/string 六大容器的失效矩阵
修法层 erase 返回值、erase-remove、erase_if、extract 四种标准修法
工程层 reserve、句柄替代迭代器、范围视图、debug mode
防御层 _GLIBCXX_DEBUG / ASan / clang-tidy / CI 四道防线

📌 本篇定位:这是排查篇的第六篇。前五篇讲"代码崩了之后怎么破案"——本篇讲"代码为什么会崩"中最具有 C++ 特色、最容易被忽视的一类——容器修改后旧迭代器/旧引用/旧指针的全面失效。读完本篇,再看任何"莫名其妙的 UAF",都能立刻回答:"这是哪种失效、踩了哪条规则、怎么改才不是临时打补丁"。

# 2. 迭代器失效的本质

进入失效矩阵之前,先把"迭代器到底是什么"讲清楚。理解了底层结构,所有失效规则都不需要硬背。

# 2.1 迭代器是什么

一句话定义:迭代器是 C++ 对"指针"概念的泛化抽象——能 *it 解引用、能 ++it 走到下一个、能 == 比较,仅此而已。

但不同容器的迭代器底层实现完全不同:

容器 迭代器底层 sizeof(典型)
vector<T> 裸指针 T* 8 字节
array<T,N> 裸指针 T* 8 字节
deque<T> 块指针 + 块内偏移 + 起止 32 字节
list<T> 节点指针 Node* 8 字节
map<K,V> 红黑树节点指针 + 哨兵 8 字节
unordered_map<K,V> 桶指针 + 链表节点指针 16 字节

关键洞察:迭代器内部存的就是某种"地址"——不管是裸指针、节点指针,还是块下标。容器一改,地址所指向的东西就可能不再是原物。

// libstdc++ vector 迭代器(简化版)
template <typename T>
struct __normal_iterator {
    T* _M_current;        // ← 就是个裸指针
    T& operator*()  const { return *_M_current; }
    T* operator->() const { return  _M_current; }
};

// libstdc++ list 迭代器(简化版)
template <typename T>
struct _List_iterator {
    _List_node_base* _M_node;   // ← 节点指针
    T& operator*() const { return static_cast<_List_node<T>*>(_M_node)->_M_data; }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

任何 C++ 标准容器的迭代器,本质上都是某种地址。只要容器修改后地址指向的内存不是原物,迭代器就失效了——这和"野指针"没有任何本质区别。

# 2.2 失效等于野指针

std::vector<int> v(3, 0);
int* p = &v[0];          // 裸指针
auto it = v.begin();     // 迭代器(也是裸指针)

v.push_back(99);         // 可能触发扩容 → realloc

*p  = 1;                 // ❌ 野指针写
*it = 2;                 // ❌ 野迭代器写——和 *p 完全等价
1
2
3
4
5
6
7
8

it 和 p 都是 8 字节地址,扩容后两个地址都指向已被 delete[] 的旧 buffer。再访问就是 use-after-free。

核心心法:迭代器失效 = 野指针。所有 UAF 排查工具(ASan、_GLIBCXX_DEBUG)能抓野指针,就一定能抓失效迭代器。

但失效比野指针更隐蔽,原因在于:

  1. 看起来像合法对象:it 类型还是 vector<int>::iterator,不像裸指针那么显眼;
  2. 失效时机藏在容器内部:push_back 看起来"无副作用",但偷偷做了 realloc;
  3. 不一定立刻崩:旧 buffer 在 glibc tcache 里可能还活几秒,写进去成功 → 数据莫名错乱(最可怕的形态)。

# 2.3 三种失效形态

形态 含义 表现
指向失效 容器没动,但被指元素被 erase 了 迭代器解引用 = 访问已 free 的元素,UAF
重分配失效 容器底层 buffer 整体搬家 所有迭代器/引用/指针全部指向旧地址,UAF
逻辑失效 迭代器还合法,但语义错位 例:sort 后旧迭代器还能用,但指向的不再是"那个元素"

重分配失效是最恐怖的——一行 push_back 可能让你所有保存的迭代器/引用/指针集体 UAF。这就是主线二的成因。

# 2.4 迭代器与引用同命

很多教程只讲"迭代器失效",但实际上:

迭代器失效的同时,所有指向同一容器元素的指针 / 引用,全部一起失效。

std::vector<int> v = {1, 2, 3};
auto  it  = v.begin();      // 迭代器
int*  p   = &v[0];          // 指针
int&  ref = v[0];           // 引用

v.push_back(4);             // 触发扩容
// 此后 it / p / ref 全部失效(如果发生扩容)
1
2
3
4
5
6
7

主线二案例里的 int& ref = v[1],本质上和"持有 it = v.begin()+1"一模一样。用引用躲不开失效问题。

# 2.5 标准的承诺与边界

C++ 标准对每个容器的每个操作,显式列出"哪些迭代器/引用/指针保留有效"。这是排查失效的最权威依据。

cppreference 上每个容器的每个成员函数都有这一栏:

vector::push_back
  Invalidation: If the new size() > capacity(), then all iterators
  and references (including the past-the-end iterator) are invalidated.
  Otherwise only the past-the-end iterator is invalidated.
1
2
3
4

两条边界:

  1. 标准只保证"列出的那些"有效——没列出的就是未指定,写依赖未指定行为的代码就是赌博;
  2. 标准的"失效"包括 end()——end() 也算迭代器,扩容后旧的 end() 也失效。

坑点:很多人以为"size() 没变就 OK"——错!vector::reserve(n)、shrink_to_fit()、assign() 都可能让所有迭代器失效,即使 size() 不变。判定标准是"底层 buffer 是否搬家",不是 size。

# 3. 容器内存模型

不同容器的失效规则差异巨大,根因就在于底层内存布局不同。把布局画出来,规则就一目了然。

# 3.1 vector 连续内存

vector<int> v = {10, 20, 30}; capacity()=4

栈上                                堆上
┌──────────────┐                   ┌──┬──┬──┬──┐
│ _M_start  ───┼──────────────────→│10│20│30│  │
│ _M_finish ───┼──────────────────→╳ (next free)
│ _M_end_of_   │                   └──┴──┴──┴──┘
│  _storage────┼──────────────────→╳ (capacity end)
└──────────────┘
1
2
3
4
5
6
7
8
9

push_back 路径:

  • size < capacity:原地写入新位置 → 只有 end() 失效;
  • size == capacity:重新申请更大 buffer(通常 1.5x 或 2x)→ memcpy 旧数据 → 释放旧 buffer。这是主线二的根因。
扩容前:[10][20][30][__]   p,it,ref → [20]
                              ↓ push_back(40) 扩容
扩容后:[10][20][30][40][__][__][__][__]   ← 新 buffer,新地址
                              ↑
        旧 buffer 已 delete[],p/it/ref 仍指向旧地址 → UAF
1
2
3
4
5

# 3.2 deque 分段映射

deque 是"分段连续"——一张指针表指向多个固定大小的 chunk:

栈上                  指针表(map)         堆上 chunk(每块 512B)
┌──────┐              ┌────┬────┬────┐    ┌──────────┐
│ map ─┼─────────────→│ p0 │ p1 │ p2 │───→│ ...64 个 │
│ ...  │              └─┬──┴──┬─┴────┘    └──────────┘
└──────┘                ↓     ↓
                    ┌──────┐ ┌──────┐
                    │64 个 │ │64 个 │
                    └──────┘ └──────┘
1
2
3
4
5
6
7
8

特殊性:push_front/push_back 不会让"中间的元素"搬家——只是在指针表两端追加新 chunk。所以:

  • deque::push_back 不让既有元素的指针/引用失效,但让所有迭代器失效(迭代器要重算块边界);
  • deque::insert 在中间:所有迭代器和引用都失效。

这是 deque 最反直觉的地方:指针/引用 vs 迭代器,失效规则不一样。

# 3.3 list 链表节点

list<int> l = {10, 20, 30};

  Node                Node                Node
┌──────────┐       ┌──────────┐       ┌──────────┐
│prev│next ┼──────→│prev│next ┼──────→│prev│next │
│ ↑  │     │       │    │     │       │    │ ↓   │
│data: 10  │       │data: 20  │       │data: 30  │
└──────────┘       └──────────┘       └──────────┘
1
2
3
4
5
6
7
8

每个节点单独 new,节点位置永不搬家。所以 list 的失效规则最宽松:

  • push_back / push_front / insert:全部迭代器/引用/指针保留有效;
  • erase(it):只有 it 自己失效。

但代价是:随机访问 O(n)、每个节点多 16~24 字节开销、cache 极不友好。只在频繁中间插入删除时才用。

# 3.4 map 红黑树

map / set 底层是红黑树,每个节点单独 new:

              [50]
             /    \
          [25]    [75]
          /  \    /  \
       [10][30][60][90]
1
2
3
4
5

和 list 一样,节点不搬家:

  • insert:全部迭代器/引用/指针保留有效;
  • erase(it):只有 it 自己失效(树会自动旋转保持平衡,但节点地址不变)。

巧妙之处:红黑树旋转只改 parent/child 指针,不移动节点本身。所以即使旋转 100 次,外部持有的迭代器照样有效——这是 map 优于 vector 的一个重要场景。

# 3.5 unordered 桶数组

unordered_map 底层是"桶数组 + 桶内链表"(哈希表):

buckets (vector<Node*>)        chain
┌───┐
│ 0 │──→ [k1,v1] ──→ [k7,v7]
│ 1 │──→ nullptr
│ 2 │──→ [k4,v4]
│ 3 │──→ [k2,v2] ──→ [k5,v5] ──→ [k9,v9]
│...│
└───┘
1
2
3
4
5
6
7
8

关键陷阱:桶数组本身是 vector。当 load_factor > max_load_factor(默认 1.0),自动 rehash——重新 new 一个更大桶数组、把所有节点重新分配到新桶。

  • 节点本身不会被 delete/new——节点指针仍然有效;
  • 但迭代器失效——迭代器内部要存桶下标 + 节点指针,rehash 后桶数组变了,旧迭代器读到的桶下标不再有意义。

所以 unordered 的规则是:insert 触发 rehash → 全部迭代器失效,但引用和指针仍有效。

# 3.6 失效的内存视角

把"失效"翻译成"内存事件",三类一目了然:

失效类型 内存事件 谁失效
元素被 erase 节点/位置被 free 仅指向该元素的
buffer 搬家 realloc → 旧 buffer free 所有指向旧 buffer 的
桶数组搬家(unordered) 桶数组 realloc,节点不动 迭代器(有桶下标)失效;引用指针有效

所有失效问题都能用这三类映射。下一章把这个映射展开成可背的对照表。

# 4. 六大容器失效矩阵

把第 3 章的内存模型直接翻译成可查的失效矩阵。这一章是全文最值得背诵的部分——记不住其他都没关系,记住这张表 80% 的失效 bug 都能避开。

# 4.1 vector 失效规则

操作                     谁失效
──────────────────────────────────────────────
push_back / emplace_back
  ├─ 未触发扩容          只有 end() 失效
  └─ 触发扩容(cap满)   全部失效(including end)

insert(pos, x)
  ├─ 未触发扩容          pos 及之后全部失效
  └─ 触发扩容            全部失效

erase(pos)               pos 及之后全部失效(前面的有效)
erase(first, last)       first 及之后全部失效

reserve(n)
  ├─ n <= capacity()     无失效
  └─ n > capacity()      全部失效(强制扩容)

resize(n)
  ├─ n <= capacity()     仅 end() 失效(如果增大)
  └─ n > capacity()      全部失效

shrink_to_fit()          可能全部失效(实现可缩可不缩)
clear()                  全部失效(end 仍有效但范围空)
swap(other)              迭代器**附在 other 上**仍有效(swap 整体搬运)
operator=                全部失效
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

最常见的两个坑:

// 坑 1:先存 begin/end 再 push_back
auto begin = v.begin(), end = v.end();
v.push_back(x);                  // 可能扩容
for (auto it = begin; it != end; ++it) ... // ❌ 集体 UAF

// 坑 2:在 erase 中递增 it
for (auto it = v.begin(); it != v.end(); ++it) {
    if (cond(*it)) v.erase(it);   // ❌ erase 后 ++it = UAF
}
1
2
3
4
5
6
7
8
9

# 4.2 deque 反直觉规则

deque 是失效规则最复杂的标准容器,反直觉之处有三:

操作                     迭代器       引用/指针
──────────────────────────────────────────────
push_back/push_front     全部失效     全部有效   ← 反直觉!
insert(中间)             全部失效     全部失效
erase(中间)              全部失效     全部失效
erase(首/尾)             仅被删的     仅被删的
1
2
3
4
5
6

为什么 push_back 让迭代器失效但引用不失效?

deque 的迭代器内部存"块指针 + 块内偏移 + 起止指针"——push_back 在尾部追加新块时,起止指针变了 → 迭代器内部缓存的"起止"失效;但既有元素的物理地址没变 → 引用/指针仍指向有效内存。

std::deque<int> d = {10, 20, 30};
int& ref = d[1];                  // 引用 20
auto it  = d.begin() + 1;         // 迭代器指向 20

d.push_back(40);
// ref:仍然有效,仍然是 20      ← 反直觉
// it :未指定行为                ← 反直觉
1
2
3
4
5
6
7

经验法则:deque 不要长期持有任何迭代器。push 一次就当全部失效处理。

# 4.3 list 最宽松

操作                     谁失效
──────────────────────────────────────────────
push_back / push_front   无失效
insert(pos)              无失效
splice                   无失效(被剪接节点的迭代器仍有效)
erase(it)                只有 it 自己失效
sort / reverse           无失效(仅改链接关系)
remove / remove_if       被删元素的迭代器
clear                    全部失效
1
2
3
4
5
6
7
8
9

list 是唯一一个连 sort 都不让迭代器失效的容器(因为只改 prev/next 指针,节点地址不动)。这也是为什么有些场景宁可牺牲 cache 性能也要用 list——长生命周期的迭代器/引用。

# 4.4 map/set 同 list

map / set / multimap / multiset 的失效规则几乎和 list 一致:

操作                     谁失效
──────────────────────────────────────────────
insert / emplace         无失效
insert(hint, ...)        无失效
erase(it)                只有 it 自己失效
erase(key)               找到的那个迭代器
extract(it) / extract(k) 节点提取,迭代器失效但**节点本身有效**
merge / swap             无失效(节点搬走,但仍是同一节点)
clear                    全部失效
1
2
3
4
5
6
7
8
9

多线程下的有用结论:在锁保护的 map 里,读写两侧持有不同迭代器互不影响(前提:不能 erase 对方在用的那个)。这是无锁 LRU 等数据结构的基础。

# 4.5 unordered 容器

操作                          迭代器        引用/指针
──────────────────────────────────────────────────────
insert / emplace
  ├─ 未触发 rehash             只有 end()    全部有效
  └─ 触发 rehash               全部失效      全部有效   ← 关键
erase(it)                      只有 it 自己   只有被删的
rehash(n) / reserve(n)         全部失效      全部有效
clear                          全部失效      全部失效
1
2
3
4
5
6
7
8

核心规则:

unordered 容器的引用和指针永远比迭代器活得久——因为节点不会因 rehash 而被 delete,只是"挂到新桶上"。

这是 unordered 容器唯一比 vector 优越的失效维度。

# 4.6 string 与 SSO

std::string 也有迭代器失效问题,且因为 SSO(Small String Optimization) 多了一层戏剧性:

std::string s = "hi";       // SSO 模式:内容存在栈上的 buffer
const char* p = s.data();   // p 指向栈上 buffer

s += " world this is a longer string";  // 超出 SSO,转堆分配
// p 指向已废弃的栈 buffer ← 别用!
1
2
3
4
5

libstdc++ 的 SSO 阈值是 15 字节、libc++ 是 22 字节。写跨平台代码时不要假定 SSO 边界——一律按"任何修改都可能让 c_str/data 失效"对待。

string 的失效规则等同 vector:

  • += / append / push_back:未扩容时仅 end 失效,扩容时全部失效;
  • insert / erase:插入/删除点之后全部失效;
  • c_str() / data() 返回的 const char* 也按相同规则失效。

# 4.7 总表速查

横向对照,背一张就够:

操作 vector deque list map/set unordered
push_back(不扩容) end() 迭代器全失效,引用 OK 无 — end(),引用 OK
push_back(扩容) 全部 (同上) 无 — 迭代器全部,引用 OK
insert(中) pos 之后 全部 无 无 同 push_back
erase(中) pos 之后 全部 仅被删 仅被删 仅被删(引用同)
reserve(增大) 全部 — — — 迭代器全部,引用 OK
sort (视实现) — 无 — —

记忆口诀:

vector 一搬全废、list 各自独立、map 像 list、unordered 看 rehash、deque 引用强于迭代器。

# 5. 标准三招修法

知道哪些操作会让谁失效,下一步是"边遍历边删"该怎么写。本章给三种标准答案。

# 5.1 erase 返回值惯用法

C++ 标准库的 erase 统一返回"被删元素的下一个迭代器"——这是教科书答案:

// 万能写法(vector / deque / list / map / set / unordered_map / string)
for (auto it = c.begin(); it != c.end(); /* 注意:这里不 ++ */) {
    if (should_remove(*it)) {
        it = c.erase(it);    // erase 返回下一个有效迭代器
    } else {
        ++it;
    }
}
1
2
3
4
5
6
7
8

三个细节常被搞错:

  1. for 的第三段不要写 ++it——否则跳一个元素;
  2. 必须分支两边都管 it 的前进——erase 返回的是已经"前进过一次"的;
  3. 不要写 it = c.erase(it++)——erase 接收的是已经 ++ 的迭代器,那是个错误参数。

对照主线一的修复:

void cleanup_expired(int64_t now) {
    for (auto it = pending_.begin(); it != pending_.end(); ) {
        if (it->expire_at < now || it->state == State::DONE) {
            it = pending_.erase(it);   // ← list::erase 返回 next
        } else {
            ++it;
        }
    }
}
1
2
3
4
5
6
7
8
9

为什么这个写法能跨容器复用? 因为 C++11 之后,所有标准容器的 erase 都统一返回 next 迭代器——这是 C++11 对 C++03 的一个重要改进(C++03 的 set::erase 返回 void)。

# 5.2 索引回避法

vector 这种支持随机访问的容器,用下标索引也能避开迭代器失效:

// 从后往前删——索引天然不会"跨过"未处理的元素
std::vector<int> v = {1, 2, 3, 4, 5};
for (int i = (int)v.size() - 1; i >= 0; --i) {
    if (should_remove(v[i])) {
        v.erase(v.begin() + i);
    }
}
1
2
3
4
5
6
7

为什么从后往前? 从前往后删:删第 0 个后,原来的第 1 个变成第 0 个,下次 i=1 跳过它了。从后往前删,前面元素的下标不变,永远安全。

复杂度:每次 erase 是 O(n)(要 memmove),整体 O(n²)。只在小数据量上用。大数据用 erase-remove(第 6 章)。

# 5.3 erase_if 一行流

C++20 终于把"边遍历边删"做成了自由函数,对所有容器都通用:

#include <algorithm>   // C++20
#include <list>
#include <map>

std::vector<int> v = {1, 2, 3, 4, 5};
std::erase_if(v, [](int x) { return x % 2 == 0; });
// v == {1, 3, 5}

std::list<int> l = {1, 2, 3, 4, 5};
std::erase_if(l, [](int x) { return x > 3; });

std::map<int, std::string> m = {{1,"a"}, {2,"b"}, {3,"c"}};
std::erase_if(m, [](const auto& p) { return p.first % 2 == 0; });

std::string s = "hello world";
std::erase_if(s, [](char c) { return c == 'l'; });
// s == "heo word"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

优点:

  • 一行代码,意图清晰;
  • 库实现自动选最优策略(vector 用 erase-remove,list 用节点剪接);
  • 不存在迭代器失效风险——库帮你处理了。

缺点:仅 C++20+;少数老编译器(gcc < 10、clang < 11)不支持。

// C++17 的 fallback:手写一个 erase_if
template <typename Container, typename Pred>
void erase_if_impl(Container& c, Pred pred) {
    for (auto it = c.begin(); it != c.end(); ) {
        if (pred(*it)) it = c.erase(it);
        else           ++it;
    }
}
1
2
3
4
5
6
7
8

# 5.4 三招对比

方案 容器适用 复杂度 可读性 C++ 版本
erase 返回值 所有容器 O(n) for list/map;O(n²) for vector 中 C++11
索引从后往前 vector / deque / string O(n²) 高(小数据) C++03
erase_if 所有容器 库自动最优 极高 C++20

实战选择:

  • 项目支持 C++20 → 首选 erase_if;
  • 不支持 → vector 用 erase-remove(下一章),其他用 erase 返回值惯用法;
  • 小数据量、追求可读性 → 索引从后往前。

# 6. erase-remove 惯用法

vector 上用 erase 一次只能删一个,整体 O(n²)。如果要批量删除,标准答案是 erase-remove 惯用法——这是 C++ 程序员必须背的"成语"。

# 6.1 remove 不删元素

std::remove 是 C++ 命名最坑的函数之一——它根本不删元素:

std::vector<int> v = {1, 2, 3, 2, 5, 2};
auto new_end = std::remove(v.begin(), v.end(), 2);

// 此时 v 的内容是:{1, 3, 5, ?, ?, ?}
//                          ↑ new_end 指向这里
// v.size() 还是 6!只是把"留下的元素"前移,"被删的"位置内容未指定
1
2
3
4
5
6

remove 只做逻辑删除:把不要删的元素全部前移、返回"逻辑结束位置"。真正物理删除要靠 erase:

v.erase(new_end, v.end());      // 把尾部那些"垃圾位置"真正释放
// 现在 v == {1, 3, 5}
1
2

两步合一就是 erase-remove:

v.erase(std::remove(v.begin(), v.end(), 2), v.end());
1

# 6.2 经典两段式

// 模板 1:删所有等于某值的元素
v.erase(std::remove(v.begin(), v.end(), value), v.end());

// 模板 2:删所有满足谓词的元素(最常用)
v.erase(std::remove_if(v.begin(), v.end(),
                       [](int x) { return x % 2 == 0; }),
        v.end());

// 模板 3:去重(前提 v 已排序)
std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end());
1
2
3
4
5
6
7
8
9
10
11

为什么这样比循环 erase 快?

循环 erase:每删一个元素,后面所有元素 memmove 一次 → O(n²)
erase-remove:先一次扫描把"留下的"前移 → O(n),然后 erase 一次切尾巴 → O(1)

10 万元素 + 删一半的实测对比:
  循环 erase     : ~5000 ms
  erase-remove   : ~2 ms
                   ↑ 2500x!
1
2
3
4
5
6
7

# 6.3 remove_if 谓词版

remove_if 接受一个谓词(返回 bool 的可调用对象),返回 true 的元素被"删除":

struct Order { int id; bool done; };
std::vector<Order> orders;

// 删所有已完成订单
orders.erase(
    std::remove_if(orders.begin(), orders.end(),
                   [](const Order& o) { return o.done; }),
    orders.end()
);
1
2
3
4
5
6
7
8
9

坑点:lambda 不能改变元素的"键域"——remove_if 内部会前移元素,如果谓词依赖正在被前移的"上下文",行为就乱了。谓词应该是纯函数(同输入同输出,无副作用)。

# 6.4 unique 去重同理

std::unique 也是逻辑删除:

std::vector<int> v = {1, 1, 2, 2, 2, 3, 1, 1};

std::sort(v.begin(), v.end());        // {1,1,1,1,2,2,2,3}
auto new_end = std::unique(v.begin(), v.end());
// {1,2,3,?,?,?,?,?}, new_end 指向第 4 个位置

v.erase(new_end, v.end());            // {1,2,3}
1
2
3
4
5
6
7

坑点:unique 只去相邻重复——必须先 sort!否则只能去掉相邻的重复。

# 6.5 C++20 的 erase_if

C++20 把 erase-remove 封装成一个函数,不再需要"两段式":

// C++17 的 erase-remove
v.erase(std::remove_if(v.begin(), v.end(), pred), v.end());

// C++20 的 erase_if(一行)
std::erase_if(v, pred);

// 删等于某值(C++20)
std::erase(v, 42);
1
2
3
4
5
6
7
8

std::erase 和 std::erase_if 是自由函数,对 vector / deque / list / forward_list / basic_string / map / set / unordered_* 都通用。项目能升 C++20 就升,迭代器失效问题至少能消灭一半。

记忆:

写法 C++03 C++11 C++20
删等值 v.erase(remove(b,e,42), e) 同左 std::erase(v, 42)
删谓词 v.erase(remove_if(b,e,pred), e) 同左 std::erase_if(v, pred)
边遍边删 手写 erase 循环 同左 + 用返回值 std::erase_if(v, pred)

# 7. 关联容器陷阱

map / set / unordered_map / unordered_set 这一类有自己独特的失效坑——和顺序容器很不一样。

# 7.1 map erase 老式写法

C++03 时代 map::erase(it) 返回 void——所以有大量遗留代码这样写:

// C++03 时代的"标准答案",C++11 之后已经过时
for (auto it = m.begin(); it != m.end(); ) {
    if (should_remove(it->first)) {
        m.erase(it++);          // 后置 ++ 先取 it 值,再让 it 前进一步,最后 erase 旧值
    } else {
        ++it;
    }
}
1
2
3
4
5
6
7
8

为什么这能工作? it++ 是后置递增——返回旧值的副本,副本被传给 erase;it 自己已经前进到下一位置(erase 之前)。erase 删的是旧位置的节点,不影响 it 当前指向的节点。

陷阱:这个写法仅对 map / set / list 有效,对 vector / deque 是错的——后者 erase 会让 it 之后所有迭代器失效,it++ 后的 it 已经在"之后",照样失效。

C++11 之后请永远用统一写法:

// 统一:所有容器都安全
for (auto it = m.begin(); it != m.end(); ) {
    if (should_remove(it->first)) {
        it = m.erase(it);
    } else {
        ++it;
    }
}
1
2
3
4
5
6
7
8

# 7.2 节点提取 extract

C++17 给 map / set / unordered_* 增加了一个"杀手级特性"——extract:把节点从容器中拆下来,但不 delete 节点本身:

std::map<int, std::string> m1 = {{1,"a"}, {2,"b"}};
std::map<int, std::string> m2 = {{3,"c"}};

// 把 m1 中 key=1 的节点搬到 m2
auto node = m1.extract(1);
// node 是 node_type,持有那个节点(堆内存还在,不会被 delete)
m2.insert(std::move(node));
// 现在 m1 == {{2,"b"}}, m2 == {{1,"a"},{3,"c"}}
1
2
3
4
5
6
7
8

威力:

  • 零拷贝迁移:节点在堆上的内存原地转手,不拷贝 K/V;
  • 可改 key:取出 node 后能 node.key() = newkey 再 insert;
  • 失效特性:extract 让被取节点的迭代器失效,但其他迭代器和引用全部有效(节点本身没动地址)。
// 改 key(C++17 之前几乎不可能不重建容器)
auto node = m.extract(old_key);
node.key() = new_key;
m.insert(std::move(node));
1
2
3
4

# 7.3 unordered rehash 全废

回到主线二的 vector 扩容——unordered_* 也有完全等价的现象,且更隐蔽:

std::unordered_map<int, std::string> m;
auto& v = m[1] = "first";    // v 是引用 ✅ 引用永远有效(unordered 节点不动)

auto it = m.find(1);          // 迭代器
for (int i = 2; i <= 10000; ++i) {
    m[i] = "x";               // 当 size > bucket_count * max_load_factor 时 rehash
}

std::cout << v;               // ✅ 仍然 "first"(引用 OK)
std::cout << it->second;      // ❌ 迭代器可能失效!
1
2
3
4
5
6
7
8
9
10

触发 rehash 的条件:

load_factor() = size() / bucket_count()
当 load_factor() > max_load_factor() 时触发 rehash

max_load_factor() 默认 = 1.0
bucket_count() 增长策略:通常翻倍到下一个质数
1
2
3
4
5

主线二的 unordered 版:

std::unordered_map<int, int> m;
for (int i = 0; i < 5; ++i) m[i] = i;
auto it = m.find(2);
for (int i = 100; i < 100000; ++i) m[i] = i;   // 一定 rehash 数十次
std::cout << it->second;                        // ❌ 迭代器失效
1
2
3
4
5

# 7.4 reserve 与 max_load_factor

要彻底避开 rehash,预先 reserve:

std::unordered_map<int, std::string> m;
m.reserve(10000);             // 提前分配足够桶,避免 rehash
m.max_load_factor(0.5);       // 降低负载因子,进一步提前

for (int i = 0; i < 9000; ++i) m[i] = "x";   // 全程不会 rehash
auto it = m.find(42);
for (int i = 9000; i < 9500; ++i) m[i] = "x";
std::cout << it->second;       // ✅ 安全
1
2
3
4
5
6
7
8

性能附赠:reserve 不仅避免迭代器失效,还能消除 rehash 的延迟尖峰——这对延迟敏感服务非常重要。任何长期存在的 unordered 容器都应该 reserve。

# 7.5 multi 容器的等价范围

multimap / multiset 允许重复 key,操作时容易踩"等价范围"陷阱:

std::multimap<int, std::string> mm = {{1,"a"}, {1,"b"}, {2,"c"}};

// 错误:erase(key) 会删所有等于该 key 的元素
mm.erase(1);                  // 现在 mm 只剩 {{2,"c"}}

// 正确:用 equal_range 拿到范围,然后选择性删
auto [first, last] = mm.equal_range(1);
for (auto it = first; it != last; ) {
    if (it->second == "b") it = mm.erase(it);
    else                   ++it;
}
1
2
3
4
5
6
7
8
9
10
11

坑点:在 equal_range 拿到的 [first, last) 区间内 erase,last 本身不会失效(map/set 的 erase 只让被删的失效);但 vector 上做同样事情,last 和后面所有都失效。容器决定一切。

# 8. 多线程并发场景

迭代器失效在单线程下已经够坑——多线程下还要叠加"另一个线程偷偷改了容器"的维度。

# 8.1 标准容器不线程安全

C++ 标准容器不是线程安全的——这是必须先内化的前提:

标准的承诺(仅 const 操作可并发):
  ✅ 多线程同时调 const 成员(size / empty / find / begin 等)
  ❌ 任何一个线程在做 non-const 操作(push_back / erase / [] 写入),
     所有其他线程都不能访问该容器(不论 const 与否)
1
2
3
4
// ❌ 经典并发 UAF
std::vector<int> shared;

// 线程 A:写
std::thread([&]{ for (int i=0; i<1000; ++i) shared.push_back(i); });

// 线程 B:读
std::thread([&]{
    for (auto it = shared.begin(); it != shared.end(); ++it) {
        std::cout << *it;     // ⚠️ 写线程一次扩容就 UAF
    }
});
1
2
3
4
5
6
7
8
9
10
11
12

两个独立问题:

  1. 数据竞争:begin() 读取 _M_start 和 push_back 写 _M_start 同时发生 → UB;
  2. 迭代器失效:写线程扩容后,读线程的 it 集体失效 → UAF。

# 8.2 读写锁的迭代器

最常见的并发模式:读写锁 + 锁内取迭代器:

std::shared_mutex mu_;
std::map<int, Order> orders_;

// 写:独占锁
void add_order(Order o) {
    std::unique_lock lk(mu_);
    orders_.emplace(o.id, std::move(o));
}

// 读:共享锁
std::optional<Order> find_order(int id) {
    std::shared_lock lk(mu_);
    if (auto it = orders_.find(id); it != orders_.end()) {
        return it->second;          // ✅ 锁内拷贝出来
    }
    return std::nullopt;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

铁律:迭代器/引用绝不可跨锁边界:

// ❌ 反模式
auto it = orders_.end();
{
    std::shared_lock lk(mu_);
    it = orders_.find(42);          // 锁内拿到迭代器
}                                   // 锁释放
// 此后另一个线程可能 erase(42)
return it->second;                  // UAF
1
2
3
4
5
6
7
8

# 8.3 拷贝快照法

频繁遍历的场景,长时间持锁会拖累并发——用快照:

std::shared_mutex mu_;
std::vector<Order> orders_;

// 取一份快照
std::vector<Order> snapshot() {
    std::shared_lock lk(mu_);
    return orders_;             // 拷贝构造,锁内完成
}

// 不持锁慢慢遍历
void process_all() {
    auto snap = snapshot();
    for (const auto& o : snap) {
        slow_process(o);        // 不需要锁,因为是独立副本
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

代价:内存翻倍。收益:读侧零阻塞、迭代器永远有效(来自独立副本)。

# 8.4 写时复制 COW

更彻底的方案:整个容器用 shared_ptr<const T> 持有,写时整体替换:

class OrderBook {
    using Map = std::map<int, Order>;
    std::atomic<std::shared_ptr<const Map>> data_;

public:
    OrderBook() : data_(std::make_shared<const Map>()) {}

    // 读:原子拿快照,无锁遍历
    std::shared_ptr<const Map> snapshot() const {
        return data_.load();
    }

    // 写:拷贝、改、原子替换
    void add(Order o) {
        for (;;) {
            auto old = data_.load();
            auto neu = std::make_shared<Map>(*old);   // 拷贝
            (*neu)[o.id] = std::move(o);
            if (data_.compare_exchange_weak(old, neu)) break;
        }
    }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

优点:读路径零阻塞、迭代器在快照内永远有效(快照是 const,没人会改)。适合:读多写少(10:1 以上)。不适合:高频写——每次写都要全量拷贝。

# 8.5 并发安全容器

实现 模式 适用
tbb::concurrent_hash_map 分段锁 + 显式 accessor 通用并发 map
tbb::concurrent_vector 增长不搬家(块状) push 多、不需要中间 erase
folly::ConcurrentHashMap 带 RCU 读极多
boost::lockfree::queue CAS 无锁 SPSC/MPMC 队列

关键差异:这些容器重新定义了"迭代器"语义——比如 TBB 的 concurrent_vector::iterator 不会因 push 而失效(因为内部用块数组,块不搬家)。

结论:要么用标准容器+严格的锁纪律(迭代器不出锁),要么用并发安全容器+了解它们的迭代器语义。第三条路(裸标准容器+乐观假设)就是 UAF 工厂。

# 9. 调试与捕获

迭代器失效的可怕之处是多数情况不立刻崩——访问已 free 但还没被 reuse 的内存可能"看起来正常"。本章讲怎么把它在第一现场抓出来。

# 9.1 _GLIBCXX_DEBUG 模式

libstdc++ 提供了专门检测迭代器失效的 debug 模式:

g++ -D_GLIBCXX_DEBUG -g -O0 a.cpp
1

开启后,所有标准容器都被替换为带运行时检查的版本:

std::vector<int> v = {1,2,3};
auto it = v.begin();
v.push_back(4);              // 触发扩容
*it;                         // ❌ 直接 abort 并输出诊断
1
2
3
4

输出(节选):

/usr/include/c++/11/debug/safe_iterator.h:362:
Error: attempt to dereference a singular iterator.

Objects involved in the operation:
    iterator "this" @ 0x... {
      type = std::__debug::vector<int>::iterator (mutable iterator);
      state = singular;
    }
1
2
3
4
5
6
7
8

特点:

  • 检查 push/insert/erase 后旧迭代器的所有访问;
  • 检查 begin > end、it 不属于该容器、跨容器混用;
  • 运行时开销 2-3x,仅用于测试/CI 阶段。

对应到 libc++(macOS/Clang):-D_LIBCPP_DEBUG=1。

# 9.2 libc++ debug mode

libc++ 的 debug mode 比 libstdc++ 更系统:

clang++ -D_LIBCPP_DEBUG=1 -g a.cpp     # libc++ 11 起
# 或
clang++ -D_LIBCPP_ENABLE_DEBUG_MODE=1   # libc++ 18 起
1
2
3

抓的失效种类:

检查项 例子
容器外部迭代器 v.begin() == w.end() 跨容器比较
已失效迭代器解引用 erase/insert 后用旧 it
已失效引用 int& r = v[0]; v.push_back(); use(r);
out-of-range v.at(100) 抛异常 vs v[100] 静默 UB

# 9.3 ASan 抓 UAF

迭代器失效本质是 UAF,ASan 也能抓——但要看场景:

std::vector<int> v = {1,2,3};
auto it = v.begin();
v.push_back(4);                 // realloc → 旧 buffer free
*it = 99;                       // ASan 抓到 heap-use-after-free
1
2
3
4

ASan 报告会给三栈:

==12345==ERROR: AddressSanitizer: heap-use-after-free
WRITE of size 4 at 0x60200000eef0 thread T0
    #0 main.cpp:7 in main         ← 失效访问点(it 解引用)

freed by thread T0 here:
    #0 operator delete(void*)
    #1 std::vector::reallocate    ← 释放点(push_back 触发)
    #2 main.cpp:6 in main

previously allocated by thread T0 here:
    #0 operator new(size_t)
    #1 std::vector::reserve       ← 分配点(最初构造)
    #2 main.cpp:3 in main
1
2
3
4
5
6
7
8
9
10
11
12
13

ASan vs _GLIBCXX_DEBUG 的差异:

维度 _GLIBCXX_DEBUG ASan
检测方式 容器内部记录"哪些迭代器关联我" 内存层面 redzone + quarantine
误报 极少 极少
漏报 几乎没有 旧内存还没被 reuse 时的 UAF 抓不到的少数情况
性能 2-3x 2-3x + 内存 2x
与优化兼容 必须 -O0 或 -O1 兼容到 -O2

实战配方:CI 中两个都开(不同 build target)。

# 9.4 静态分析工具

# clang-tidy 自带的检查
clang-tidy --checks='bugprone-use-after-move,
                     bugprone-dangling-handle,
                     cppcoreguidelines-*' a.cpp

# 检查 erase 后继续使用 it 的常见模式
clang-tidy --checks='cert-mem57-cpp'  # 迭代器失效系列规则
1
2
3
4
5
6
7

clang-tidy 能在编译期抓到主线一这种"erase 后继续 ++it"的模式——前提是写法足够"标准"。但对于复杂控制流(迭代器跨函数传递),仍然需要运行时工具。

Coverity / SonarCloud / PVS-Studio 等商业静态分析在迭代器失效检查上比 clang-tidy 强一些,但 ROI 看团队规模。

# 9.5 编译告警与 lint

GCC/Clang 的编译告警虽然不专门针对迭代器失效,但能拦住一些:

g++ -Wall -Wextra -Wpedantic -Wshadow a.cpp
1

更激进的:

# 范围 for 中改容器,多数编译器会警告
g++ -Wrange-loop-construct ...

# C++20 模式下检查容器的 erase_if 等惯用法
clang++ -std=c++20 -Wshadow -Wold-style-cast ...
1
2
3
4
5

经验:编译期能抓的 bug 比运行期便宜 100 倍。即便 -Wall -Wextra 不能直接报"迭代器失效",也能拦住相关的"未初始化变量"、"shadowing"、"unused result" 这些容易引发失效的环境。

主线二在 _GLIBCXX_DEBUG 下的现场:

$ g++ -D_GLIBCXX_DEBUG -g -O0 crash.cpp
$ ./a.out
/usr/include/c++/11/debug/safe_iterator.h:362:
Error: attempt to dereference a singular iterator.

Objects involved in the operation:
    iterator "this" @ 0x7ffd... {
      type = std::vector<int>::iterator;
      state = singular;
    }
Aborted
1
2
3
4
5
6
7
8
9
10
11

直接停在解引用那一行,比"看 ref=12345 然后猜"清楚 100 倍。测试环境永远开 _GLIBCXX_DEBUG。

# 10. 五步排查方法论

把主线一、主线二的排查过程抽象成可复用的流程:

  ┌─────────────────────────────────────────┐
  │ 1. 重现:把概率拉满                       │
  │    把"凌晨偶发"逼成"每次必现"             │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 2. 定位:失效点回放                       │
  │    锁定"哪一行让迭代器失效"               │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 3. 假设:哪类失效模式                     │
  │    对照失效矩阵确定根因                   │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 4. 修复:三种修法选                       │
  │    标准三招 + reserve / 句柄替代          │
  └──────────────────┬──────────────────────┘
                     ↓
  ┌─────────────────────────────────────────┐
  │ 5. 防御:CI 加保险                        │
  │    _GLIBCXX_DEBUG / ASan / lint 三道防线  │
  └─────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 10.1 重现:把概率拉满

主线一最大的难点是"每天凌晨 2 点才出"——这是典型的触发条件依赖时间。

重现技巧:

  • 数据放大:把订单量从 1 千扩到 10 万,过期清理路径必走;
  • 时间压缩:把"过期时间"从 24h 改成 1s,10 秒就重现一次;
  • 断点拦截:在 cleanup_expired 入口下断点,单步走过 erase 后的 ++it;
  • 开 _GLIBCXX_DEBUG:原本"概率性 UAF"变成"100% abort"。

把"偶现"逼成"必现",是迭代器失效排查最关键的一步。

# 10.2 定位:失效点回放

迭代器失效的 core dump,栈往往在 operator++ 或 operator* 内部——离 bug 还差一步。关键是找出"谁让 it 失效":

(gdb) bt
#0  std::_List_iterator<Order>::operator++ ...
#1  cleanup_expired (now=...) at scheduler.cpp:14

(gdb) frame 1
(gdb) print it._M_node          # 看节点地址
(gdb) watch it._M_node          # 给迭代器内部指针下 watchpoint
(gdb) reverse-continue          # 倒回去找谁改了它(rr 或 gdb-rr)
1
2
3
4
5
6
7
8

rr (record-replay) 是反向调试神器:

rr record ./scheduler
rr replay
(rr) reverse-continue            # 反向执行到上一次 it 被改的位置
1
2
3

无 rr 的环境里,用 _GLIBCXX_DEBUG 报告里的"singular iterator"提示反推 erase 调用点。

# 10.3 假设:哪类失效模式

现场拿到后,对照第 4 章失效矩阵,99% 的失效落在 4 类之一:

# 模式 典型代码 第一查
1 显式 erase 后继续用 it erase(it); ++it; for 循环里有 erase 吗
2 push/insert 触发底层重分配 for(...) v.push_back(); use(it); 容器是 vector/string/unordered?
3 跨函数返回迭代器持有过久 auto it = find_in(c); modify(c); use(it); 有传出 iterator 的接口吗
4 多线程中跨锁/跨快照 it = c.find(); release_lock(); use(it); 锁结构是否包住 it 的生命周期

对照主线一:循环中 erase(it) 后没用 it = erase(it)——模式 #1。 对照主线二:int& ref 后 push_back 多次扩容——模式 #2。

有了清单,根因 5 分钟内能锁定。

# 10.4 修复:三种修法选

修复 A:最小改动(一行)

// 主线一
for (auto it = pending_.begin(); it != pending_.end(); ) {
    if (...) it = pending_.erase(it);
    else     ++it;
}

// 主线二
v.reserve(200);                  // 一行 reserve,避免扩容
int& ref = v[1];
for (...) v.push_back(i);
1
2
3
4
5
6
7
8
9
10

适用:紧急 hotfix。局限:依赖人肉记忆"我 reserve 了",下次有人改了 reserve 数还会再炸。

修复 B:防御式编程

// 主线二:用索引代替引用
int idx = 1;                     // 记下下标,不是引用
for (...) v.push_back(i);
int val = v[idx];                // 用时再取
1
2
3
4

适用:代码多人维护、容器规模不可预测。下标是"延迟解引用"——只要容器还活着、idx < size(),就永远有效。

修复 C:根治式重构

// 主线一:直接 erase_if(C++20)
std::erase_if(pending_, [&](const Order& o) {
    return o.expire_at < now || o.state == State::DONE;
});

// 主线二:用稳定句柄(unordered_map 节点引用)
std::unordered_map<int, int> m;
m.reserve(1000);                 // 提前足够大
for (int i = 0; i < 100; ++i) m[i] = i;
int& ref = m[42];                // unordered_map 引用永远有效(rehash 不动节点)
for (int i = 100; i < 5000; ++i) m[i] = i;
use(ref);                        // ✅ 永远 OK
1
2
3
4
5
6
7
8
9
10
11
12

三层修复对比:

修复 改动量 能不能防未来 推荐场景
A:补 reserve / erase 返回值 1 行 不能 紧急发版
B:索引/快照替代迭代器 5 行 防 80% 过渡期
C:换容器/换 API 重写 10 行 全防 长期代码

资深工程师选择:A + C —— 紧急 A 止血,下迭代 C 重构。

# 10.5 防御:CI 加保险

修复完不算完——让同样的 bug 永远不能再被引入:

三层防御模型:

┌────────────────────────────────────────────┐
│ 编译期:clang-tidy bugprone-* 系列         │
│         -Wall -Wextra -Werror              │
├────────────────────────────────────────────┤
│ 运行期:-D_GLIBCXX_DEBUG                   │
│         -fsanitize=address                 │
├────────────────────────────────────────────┤
│ 回归期:单元测试 + ASan + 边界数据         │
└────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

沉淀为 CI 用例:

// test_iter_fail.cpp
#include <gtest/gtest.h>

TEST(Scheduler, CleanupExpiredDoesNotInvalidateIter) {
    Scheduler s;
    for (int i = 0; i < 1000; ++i) s.add({i, i % 2 ? PAST : FUTURE});
    s.cleanup_expired(now());        // 如果回归成旧代码,_GLIBCXX_DEBUG 抓
    EXPECT_EQ(s.size(), 500);
}

TEST(Vector, RefSurvivesExpansion) {
    std::vector<int> v;
    v.reserve(200);                  // 契约
    v.push_back(20);
    int& ref = v[0];
    for (int i = 0; i < 150; ++i) v.push_back(i);
    EXPECT_EQ(ref, 20);              // 没 reserve 时这条会失败/UB
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

心法六条:

  • 先记内存模型,再背失效矩阵:理解了才能举一反三;
  • erase 永远用返回值:跨容器的统一安全写法;
  • reserve 是契约:一旦持有 vector 的迭代器/引用,必须 reserve 到容器最终大小;
  • 跨函数的迭代器是炸药:能用下标/key 就别用迭代器;
  • 多线程下迭代器不出锁:要出锁就拷贝快照;
  • _GLIBCXX_DEBUG + ASan 是测试环境标配:成本 2-3x,收益 100x。

# 11. 典型场景速查

把第 1-10 章的方法论,落到 7 个最高频的失效场景。

# 11.1 边遍历边删

// ❌ 经典错误
for (auto it = v.begin(); it != v.end(); ++it) {
    if (cond(*it)) v.erase(it);
}
1
2
3
4

特征:vector 上崩在 erase 之后某次 *it;list/map 上崩在 ++it。

修法:

// ✅ 标准修法
for (auto it = v.begin(); it != v.end(); ) {
    if (cond(*it)) it = v.erase(it);
    else           ++it;
}

// ✅ C++20
std::erase_if(v, cond);
1
2
3
4
5
6
7
8

# 11.2 push 后引用失效

// ❌ 主线二
std::vector<int> v = {1,2,3};
int& ref = v[1];
for (int i = 0; i < 100; ++i) v.push_back(i);
use(ref);                         // UAF
1
2
3
4
5

特征:reading 时拿到垃圾值;ASan/_GLIBCXX_DEBUG 立即报。

修法:

// ✅ 提前 reserve
v.reserve(200);
int& ref = v[1];                  // 现在 push_back 不会扩容
for (...) v.push_back(i);

// ✅ 改用索引
int idx = 1;
for (...) v.push_back(i);
use(v[idx]);

// ✅ 改容器(节点稳定)
std::list<int> l = {...};
auto it = std::next(l.begin());
for (...) l.push_back(i);
use(*it);                         // list 节点不动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 11.3 缓存指向 vector

类成员保存了"指向 vector 元素的指针",是最常见的隐藏炸弹:

// ❌ 反模式
class Cache {
    std::vector<Entry> entries_;
    Entry* hot_;                  // 指向 entries_ 的某个元素
public:
    void add(Entry e) {
        entries_.push_back(e);    // 一旦扩容,hot_ 就 UAF
    }
    void access() { hot_->touch(); }
};
1
2
3
4
5
6
7
8
9
10

修法:

// ✅ 用 index
class Cache {
    std::vector<Entry> entries_;
    size_t hot_idx_ = 0;
public:
    void access() { entries_[hot_idx_].touch(); }
};

// ✅ 容器换成 deque(push 不让引用失效)/ unordered_map(rehash 不动节点)
class Cache {
    std::deque<Entry> entries_;
    Entry* hot_;                  // deque push_back 不动既有元素
};

// ✅ 用 list(最宽松)+ key 索引
class Cache {
    std::list<Entry> entries_;
    std::unordered_map<Key, std::list<Entry>::iterator> index_;
    // LRU 经典实现
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 11.4 跨函数返回迭代器

// ⚠️ 危险接口:返回迭代器给调用方
auto find_order(int id) -> std::list<Order>::iterator;

// 调用方
auto it = pool_.find_order(42);
pool_.cleanup();                  // 内部可能 erase 任意元素
it->process();                    // ⚠️ 可能失效
1
2
3
4
5
6
7

问题:调用方不知道"哪些操作会让 it 失效"——耦合泄露。

修法:

// ✅ 返回 optional<value> / 拷贝
std::optional<Order> find_order(int id);

// ✅ 返回稳定的 key/handle
struct OrderHandle { uint64_t id; };
OrderHandle find_order(int id);

// ✅ 暴露查询函数代替原始迭代器
template <typename F>
void with_order(int id, F&& f);   // 内部找到、调 f、控制生命周期
1
2
3
4
5
6
7
8
9
10

经验:API 边界应该用 key/handle/value,不用迭代器。迭代器是容器的"内部产物",不该穿越抽象边界。

# 11.5 范围 for 中改容器

// ❌ 范围 for 内部就是迭代器,改容器照样 UAF
for (auto& x : v) {
    if (x.cond) v.push_back(other);    // ⚠️ 可能扩容,后续迭代器失效
}
1
2
3
4

修法:

// ✅ 收集到临时容器,循环外统一处理
std::vector<T> to_add;
for (const auto& x : v) {
    if (x.cond) to_add.push_back(other);
}
v.insert(v.end(), to_add.begin(), to_add.end());

// ✅ 用下标避免迭代器
size_t n = v.size();
for (size_t i = 0; i < n; ++i) {        // 注意 n 是固定的
    if (v[i].cond) v.push_back(other);
}
1
2
3
4
5
6
7
8
9
10
11
12

# 11.6 嵌套循环交叉删

// ❌ 双重循环里一边修一边遍历
for (auto it1 = a.begin(); it1 != a.end(); ++it1) {
    for (auto it2 = a.begin(); it2 != a.end(); ++it2) {
        if (cond(*it1, *it2)) a.erase(it2);   // ⚠️ it1 可能失效
    }
}
1
2
3
4
5
6

修法:

// ✅ 先收集要删的 key
std::vector<int> to_remove;
for (const auto& x : a) for (const auto& y : a)
    if (cond(x, y)) to_remove.push_back(y.id);

// 统一删除
for (int id : to_remove) a.erase_by_id(id);
1
2
3
4
5
6
7

# 11.7 erase 返回的"下一个"

最微妙的坑:以为 erase 返回的是被删的"前一个",结果跳过元素或越界:

// ❌ 误用
auto it = v.begin();
it = v.erase(it);            // 删 v[0],返回新的 v[0]
++it;                        // ⚠️ 跳过了新的 v[0]
1
2
3
4

正确理解:

原 vector:[A, B, C, D]
            ↑
            it 指向 A

erase(it) 后:
原 vector:[B, C, D]
返回的迭代器 it' → 指向 B(即"被删元素的下一个位置")

如果再 ++it':
此时 it' → 指向 C,跳过了 B
1
2
3
4
5
6
7
8
9
10

口诀:it = erase(it),然后 else ++it——分支两边都管,不要再加 ++it。

# 12. 工程化最佳实践

把零散经验拼成体系。

# 12.1 选容器先想增删

容器选型决策树:

需要随机访问?
  ├─ 是 → 元素数量 < 1000 且 cache 重要 → vector(默认)
  │       元素数量大且频繁中间删 → deque
  │       需要 key→value 映射 → unordered_map / map
  └─ 否 → 频繁两端 push/pop → deque
          频繁中间 insert/erase 且持久迭代器 → list
          按 key 查找 → unordered_map(默认)/ map(要序)
1
2
3
4
5
6
7

90% 的场景应该用 vector——cache 友好、内存紧凑、push_back 摊还 O(1)。只有当迭代器/引用必须长期持有,才考虑 list / map。

# 12.2 reserve 是契约

vector 的 reserve 不只是性能优化——它是**"保证后续 N 次 push_back 不让迭代器失效"的契约**:

// 反模式:先 push 几个,然后保存引用,然后再 push
std::vector<Item> items;
items.push_back(...);
items.push_back(...);
Item& important = items.back();    // ⚠️ 后面再 push 会扩容
items.push_back(...);
items.push_back(...);
process(important);                // UAF 概率事件

// 正模式:reserve 锁定 capacity
std::vector<Item> items;
items.reserve(100);                // 明确告知"最多 100 个"
items.push_back(...);
items.push_back(...);
Item& important = items.back();    // ✅ reserve 内永远不扩容
for (int i = 0; i < 50; ++i) items.push_back(...);
process(important);                // ✅
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

写代码 review 时的红灯:任何 vector 持有引用/迭代器/指针的代码,必须前面有 reserve 或注释明确"不会再 push"。

# 12.3 长生命周期句柄

需要"长期持有指向容器元素的引用"——不要用迭代器,用稳定句柄:

句柄类型 容器 稳定性
std::list::iterator list 永远稳定(除被 erase 自己)
Map::iterator (map/set) map/set 永远稳定(除被 erase 自己)
unordered_map::iterator unordered 仅 size 不变时稳定
unordered_map[k] 引用 unordered rehash 也稳定
T* from make_unique 任意 永远稳定
整数索引(size_t) vector/deque size 增长时稳定(idx < size 时)
业务 ID(key) 任意 + 索引表 100% 稳定,最贵但最稳

LRU 缓存的标准实现:list + unordered_map 组合,用 list::iterator 作为永久句柄:

class LRUCache {
    std::list<std::pair<Key, Value>> entries_;
    std::unordered_map<Key, decltype(entries_)::iterator> index_;
    // ...
};
1
2
3
4
5

# 12.4 范围视图 ranges

C++20 的 <ranges> 让"边过滤边遍历"不再依赖原地修改:

#include <ranges>

std::vector<int> v = {1,2,3,4,5,6};

// 不修改原 vector,懒求值
auto evens = v | std::views::filter([](int x){return x%2==0;});
for (int x : evens) std::cout << x;       // 2 4 6

// 物化到新 vector
auto v2 = v | std::views::filter(...) | std::ranges::to<std::vector>();
1
2
3
4
5
6
7
8
9
10

好处:原容器不动 → 没有失效问题;惰性求值 → 不需要中间临时容器。

坑点:view 持有原容器的迭代器,原容器变了 view 也失效——所以 view 不能跨容器修改使用。

# 12.5 团队规范五条

写到 wiki / coding style 里,code review 强制执行:

  1. 任何 for + erase 必须用 it = c.erase(it) 形式;vector 大批量删用 erase-remove;C++20 直接 std::erase_if。
  2. vector 持有引用/迭代器/指针前必须 reserve;reserve 数量必须有依据(最大可能值)。
  3. 不允许跨函数返回迭代器;查询接口返回 optional<value> / key / 闭包回调。
  4. 多线程下迭代器不出锁;要出锁就拷贝出 value。
  5. 测试环境必开 _GLIBCXX_DEBUG + ASan;CI 跑两个独立 build target。

# 12.6 成熟度模型

阶段 能力 典型团队
Level 1 知道"erase 后不能用 it",但写得不一致 新团队
Level 2 全员用 it = erase(it) 惯用法 有 review 文化
Level 3 容器选型时考虑迭代器稳定性,会用 reserve 中级团队
Level 4 API 边界禁迭代器,用 key/handle 中后台/平台
Level 5 CI 强制 _GLIBCXX_DEBUG + ASan,零容忍 基础设施

绝大多数团队卡在 Level 2-3。升到 Level 4-5 的关键是把"失效"从"运行时事件"变成"编译/测试期事件"——只要在 CI 一定能抓住,开发不用每次都手写防御。

# 13. 综合案例串讲

# 13.1 案例真相揭晓

回到第 1 节的两条主线,八个疑问现在能逐条作答:

疑问 答案
① 迭代器到底是什么? 第 2.1:泛化的指针——内部就是某种地址(裸指针/节点指针/桶下标)
② 失效本质? 第 2.2:迭代器失效 = 野指针,所有 UAF 工具都能抓
③ 容器失效规则差别? 第 4:vector 一搬全废、list 各自独立、map 像 list、unordered 看 rehash、deque 引用强于迭代器
④ 边遍历边删怎么写? 第 5:it = c.erase(it) else ++it;C++20 用 std::erase_if
⑤ erase-remove 为什么这样? 第 6:remove 只逻辑删(前移有效元素),erase 才物理切尾
⑥ unordered 为什么 insert 也让全失效? 第 7.3:触发 rehash 后桶数组搬家,旧迭代器内的桶下标不再有效
⑦ 多线程下能否跨锁持迭代器? 第 8.2:永远不行——迭代器/引用必须在锁内消费完
⑧ 怎么让失效在第一现场暴露? 第 9:-D_GLIBCXX_DEBUG + ASan,把"概率 UAF"变成"100% abort"

最终诊断:

主线一:cleanup_expired 中 erase(it) 后控制流走 ++it——erase 已 free 节点,it._M_node 成为悬挂指针;++it 读 _M_node->_M_next 撞已被 glibc tcache 回收的节点头,触发 SIGSEGV。根因:忽视了"list::erase 让被删迭代器自身失效"。

主线二:int& ref = v[1] 拿到 vector 内部 buffer 的地址;后续 push_back 100 次必然多次扩容,每次扩容都 delete[] 旧 buffer。ref 一直指向已 free 的旧 buffer,访问得到的是垃圾值或 SIGSEGV。根因:忽视了"vector::push_back 触发扩容时,所有引用/迭代器/指针全失效"。

修复方案(按优劣排序):

方案 A:立即修复——惯用法/reserve

// 主线一
for (auto it = pending_.begin(); it != pending_.end(); ) {
    if (need_clean(*it)) it = pending_.erase(it);
    else                 ++it;
}

// 主线二
v.reserve(200);
int& ref = v[1];
for (...) v.push_back(i);        // 不会扩容
1
2
3
4
5
6
7
8
9
10

代价:靠人肉记忆,下次还会再写错。

方案 B:标准库一行流(C++20 推荐)

// 主线一
std::erase_if(pending_, [&](const Order& o) {
    return o.expire_at < now || o.state == State::DONE;
});

// 主线二
std::vector<int> stable_idx;     // 用下标代替引用
int idx = 1;
for (...) v.push_back(i);
use(v[idx]);                     // 永远 OK,size 增长不影响 idx
1
2
3
4
5
6
7
8
9
10

代价:要升 C++20。

方案 C:架构级——选对容器

// 主线一:本来就是 list(节点稳定),换成 unordered_map<id, Order> 反而更好
std::unordered_map<int64_t, Order> pending_;
pending_.reserve(100000);
std::erase_if(pending_, [&](const auto& kv) {
    return kv.second.expire_at < now || kv.second.state == State::DONE;
});

// 主线二:要长期引用 → 换 deque 或直接用 unordered_map
std::deque<int> v = {10, 20, 30};
int& ref = v[1];
for (...) v.push_back(i);        // deque 引用永远有效
use(ref);                        // ✅
1
2
3
4
5
6
7
8
9
10
11
12

生产建议:方案 A 紧急止血,方案 B(C++20)+ 方案 C(容器选型)作为长期解。

# 13.2 一次失效的一生

把 dmesg: segfault at 0x7f8a... 这一行的全过程串成一棵知识树:

程序: pending_.erase(it); ++it;     ← 主线一的 bug
        │
        ├─ 编译期
        │   └─ 生成 mov (%rdi), %rax / call _ZNSt8__detail...erase
        │
        ├─ 运行期 - erase 调用
        │   ├─ list::erase 内部:
        │   │   ├─ unlink 节点:prev->next = next, next->prev = prev
        │   │   └─ delete it._M_node                ─── 第 3.3 节
        │   │
        │   └─ erase 返回 next 迭代器(但调用方没接)
        │
        ├─ 运行期 - 用旧 it 继续
        │   ├─ ++it 展开:it._M_node = it._M_node->_M_next
        │   ├─ 但 it._M_node 已 free                ─── 第 2.2 节
        │   ├─ 读 free 的节点头 ── glibc tcache 已回收
        │   └─ 多数情况:读到 0x4141414141 (毒标记)
        │
        ├─ MMU 翻译
        │   ├─ VA 0x4141414141 → 不在任何 VMA       ─── 第 4.1 节
        │   └─ CPU 触发 #PF 异常
        │
        ├─ 内核处理
        │   ├─ do_page_fault → SIGSEGV (SEGV_MAPERR)
        │   └─ task->pending bit 11 置位
        │
        └─ 事后排查
            ├─ gdb ./scheduler core
            ├─ bt → list_iterator::operator++       ─── 第 7.1 节
            ├─ p it._M_node → 0x4141...             ─── 第 9.3 节
            ├─ ASan 重跑 → 三栈:访问/释放/分配      ─── 第 9.3 节
            └─ _GLIBCXX_DEBUG 重跑 → singular iter   ─── 第 9.1 节
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

理解一次"erase 后继续 ++"从代码到 SIGSEGV 的全链路,就是理解所有迭代器失效的总骨架。

# 13.3 设计哲学回扣

整理本篇的四条跨篇适用的设计哲学:

哲学 1:迭代器是泛化指针——它就是地址

C++ 没有"安全的迭代器"——所有迭代器最终都翻译成某种地址。理解了这一点,所有失效规则都不用硬背——只需想"这次操作有没有让那个地址所指向的内存搬家或被 free"。这是和 Java/C# 的 GC 迭代器最本质的不同。

哲学 2:标准的承诺即边界——没承诺的就是赌博

C++ 标准对每个容器的每个操作都明确列出"哪些迭代器保留有效"——这份列表就是你能依赖的全部。任何"实测好像也行"的依赖都是赌博:换编译器、换标准库版本、换数据规模就翻车。写代码只能依赖标准的承诺,不能依赖实现的偶然行为。

哲学 3:第一现场原则——失效要在产生处暴露

野指针的最大危害是"产生处和暴露处分离"——本篇的所有调试武器(_GLIBCXX_DEBUG、ASan、clang-tidy)都在做同一件事:把"暴露处"拉回到"产生处"。_GLIBCXX_DEBUG 在 erase 时记录"哪些迭代器关联我",下次解引用立即 abort——而不是等到几秒后某个 ++it 撞到毒标记才崩。第一现场调试的成本是事后调试的 1/100。

哲学 4:API 边界不暴露内部句柄

迭代器是容器的实现细节——它跨函数、跨锁、跨线程时,所有失效假设都被打破。好的 API 用 key、handle、value、闭包——这些是稳定的"业务概念",不依赖容器实现。让迭代器永远停留在"使用容器的那一段代码内部",是工程级 C++ 的基本素养。

# 13.4 失效修复速查表

一张图保存以备查:

容器 让全失效的操作 仅让被删失效 引用稳定性
vector push_back(扩容)/ insert / reserve / resize(扩容) erase(pos) 之后部分 同迭代器
deque 中间 insert / erase 首尾 push/pop 比迭代器强:push 不让引用失效
list clear erase / remove 引用永远有效
map/set clear erase 引用永远有效
unordered_* rehash(自动或 reserve) erase 比迭代器强:rehash 不让引用失效
string += / append(扩容) 同 vector 同 vector

60 秒诊断命令清单:

# 1. 把"偶现"逼成"必现"
g++ -D_GLIBCXX_DEBUG -g -O0 a.cpp
./a.out                         # 100% 触发,停在解引用那一行

# 2. 用 ASan 拿三栈(访问/释放/分配)
g++ -fsanitize=address -g -fno-omit-frame-pointer a.cpp
./a.out                         # 输出三栈

# 3. clang-tidy 在编译期捕获
clang-tidy --checks='bugprone-use-after-move,
                     cert-mem57-cpp,*-iterator-*' a.cpp

# 4. core dump 后看 it 内部
(gdb) p it._M_node              # list 节点指针
(gdb) p it._M_current           # vector 裸指针
(gdb) p it._M_cur               # deque 块内指针

# 5. rr 反向调试(找谁让 it 失效)
rr record ./a.out
rr replay
(rr) reverse-continue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

"是不是失效"60 秒判定:

崩在 operator++ / operator* 内部?
  ├─ 是 → 看是哪个容器
  │       ├─ vector → 上次 push_back/insert/reserve/resize 之后?
  │       ├─ list/map/set → 上次 erase 那个 it 是不是又被用?
  │       └─ unordered_* → 总元素数有没有跨过 bucket_count * max_load_factor?
  └─ 否 → 不是失效问题,看 04.CoreDump破案 / 02.ASan内存诊断

引用/指针拿到了垃圾值?
  ├─ vector 元素的引用 → 后续有没有 push_back/insert/resize?
  ├─ deque/list/map 元素引用 → 那个元素有没有被 erase?
  └─ string 的 c_str/data → 后续有没有改 string?
1
2
3
4
5
6
7
8
9
10
11

惯用法速记:

// 边遍历边删(万能)
for (auto it = c.begin(); it != c.end(); )
    if (pred(*it)) it = c.erase(it); else ++it;

// vector 批量删除(O(n))
v.erase(std::remove_if(v.begin(), v.end(), pred), v.end());

// C++20 一行流
std::erase_if(c, pred);

// vector 持有引用前
v.reserve(N);  int& ref = v[i];   // N >= 最终 size

// 关联容器节点搬家
auto node = m.extract(key);
node.key() = new_key;
m.insert(std::move(node));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 13.5 思考题

  1. std::vector<bool> 的 operator[] 返回的是一个代理对象 reference 而不是 bool&。这给迭代器失效带来了什么额外的复杂度?

  2. 为什么 deque::push_back 让所有迭代器失效,但不让现有元素的引用失效?这背后的内存模型是什么?

  3. std::list::splice 把节点从一个 list 转移到另一个 list——转移后旧 list 上指向被搬走节点的迭代器还有效吗?为什么?

  4. 你在 std::unordered_map 里 m.insert(...) 后立刻保存了返回的迭代器auto [it, ok] = m.insert(...),然后又 m.insert(...) 50 次。第二次 m.insert 没触发 rehash 的话,it 还能用吗?怎么验证?

  5. 主线二的 int& ref 改成 auto it = v.begin() + 1,结果会变吗?两者在内存层面有什么区别?

  6. 多线程读写 std::shared_ptr<const std::vector<int>> 是不是天然线程安全?读端持有的 vector 迭代器会失效吗?

  7. std::deque 上 for (auto& x : d) x = ...; d.push_back(x); 这种"遍历中追加"是 OK 的吗?为什么和 vector 的同样写法有差异?

  8. C++20 std::erase_if(v, pred) 内部对 vector 是怎么实现的?为什么比手写 for + erase 快?

  9. _GLIBCXX_DEBUG 模式怎么记录"哪些迭代器关联到这个容器"?为什么不能在 release 模式默认开(除了性能)?

  10. 如果你是某团队的技术负责人,要把"迭代器失效"从 P1 故障原因清单里彻底消除,你会按什么 ROI 顺序投入?为什么?


容器是 C++ 工程的脊梁,迭代器失效则是这条脊梁上最隐蔽的裂纹。 真正的能力,是从"erase 之后能不能 ++"这种小问题,看到"指针 vs 节点 vs 桶 vs 块"的内存全景图。

下一篇:本篇讲了"容器修改后旧句柄怎么办",至此排查篇六件套(01.信号崩溃快速排查 → 02.ASan内存诊断 → 03.GDB命令速查表 → 04.CoreDump破案实录 → 05.perf火焰图速查 → 06.迭代器失效修复法)已经形成完整闭环:事前阻断 → 现场调试 → 事后破案 → 持续优化 → 容器陷阱。配套阅读:01.进程地址空间布局(理解堆 buffer 何时 free、为何 glibc tcache 让 UAF 看起来"暂时正常")。

上次更新: 2026/06/16, 19:31:50
perf火焰图实战
智能指针选型

← perf火焰图实战 智能指针选型→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式