STL容器性能探秘:stack、queue、deque的实现与CPU缓存命中率优化

目录

  • 前言
  • 一、stack的模拟实现
    • [1.1 适配器Container的封装概念](#1.1 适配器Container的封装概念)
  • 二、queue的模拟实现
  • 三、deque的介绍
    • [3.1 deque的使用](#3.1 deque的使用)
    • [3.2 CPU高速缓存访问命中率与数据访问效率](#3.2 CPU高速缓存访问命中率与数据访问效率)
      • [3.2.1 数据访问效率](#3.2.1 数据访问效率)
      • [3.2.2 CPU高速缓存访问命中率](#3.2.2 CPU高速缓存访问命中率)
  • 结语

🎬 云泽Q个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列

⛺️遇见安然遇见你,不负代码不负卿~


前言

大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~

一、stack的模拟实现

对于一般人来说模拟实现的栈的底层就是一个数组,让数组尾部做栈顶。无论是数组栈还是链式栈,很多的东西都是和顺序表和链表是类似的

但是STL中的栈不是这样实现的,STL在设计初的考量就是相似的一些东西直接复用,像实现一个数组栈,直接如图封装一个vector实现,就不用再实现顺序表和链表的内部的一些逻辑了。

1.1 适配器Container的封装概念

且C++设计STL的这些大佬的思路还更开阔一些,他们还加入了一种叫做容器适配器 (Container,转换的意思)的东西

这样封装适配器的思路是非常牛的

"自己从头实现栈" 的写法(比如注释里的T* _a+_top+_capacity)------ 需要自己管理动态数组的扩容、释放;而现在这个stack是 "复用现有容器(比如vector/list)的功能,包装成栈的接口",底层存储完全交给Container(比如vector),自己只封装 "栈需要的操作"(push/pop/top 等)。

这种适配结构的核心优点

  1. 【少写重复代码】复用现有容器,不用自己实现底层存储
    原来注释里的 "数组栈" 需要自己写:
    • 动态数组的初始化、扩容(比如_capacity满了要realloc);
    • 内存的释放(防止内存泄漏);
    • 下标访问、边界检查等。
      而现在的stack直接用vector/list作为底层容器 ------ 这些容器本身已经实现了动态扩容、内存管理、尾插 / 尾删 等功能,你只需要调用_con.push_back()/_con.pop_back(),相当于 "站在现有容器的肩膀上",不用重复造轮子。
  1. 【灵活切换场景】换底层容器只改模板参数,不用动栈的逻辑
    代码里用的是vector< int >作为底层(yunze::stack<int, vector< int >>),但如果场景变了:
  • 比如需要 "频繁在栈顶插入 / 删除,且不想承担vector扩容的开销"------ 只需要把模板参数换成list< int >,代码改成:
cpp 复制代码
yunze::stack<int, list<int>> st; // 底层变成链表,其他代码完全不用改
st.push(1); // 还是用同样的push接口
st.top();   // 还是用同样的top接口

底层容器换了,但栈的调用代码完全不用改------ 因为stack封装了统一的接口,不管底层是vector还是list,对外都是 "栈的操作"。

  1. 【统一接口】不用关心底层实现,只用记 "栈的用法"

    不管底层是vector(数组)还是list(链表),你用这个stack时,只需要调用push/pop/top------ 不用关心底层是 "数组尾插" 还是 "链表尾插",也不用记vector的push_back或list的push_back细节,只需要记 "栈的标准操作" 即可,降低了学习和使用的成本。

  2. 【稳定可靠】借现有容器的成熟性,减少 bug

    STL 里的vector/list是经过大量测试、优化的容器,比自己写的 "数组栈"(比如注释里的T* _a)更稳定:

  • 比如vector的扩容逻辑是经过性能优化的(通常是 2 倍扩容,减少频繁申请内存的开销);
  • list的尾插 / 尾删是 O (1) 且不会有内存碎片问题;
  • 这些容器本身也做了边界检查(比如empty()时调用back()会报错)。
    用它们作为底层,比自己手写的存储结构更不容易出 bug。
  1. 【适配多类型】底层容器支持的类型,栈都能直接用
    比如你想存string类型的栈,只需要写:
cpp 复制代码
yunze::stack<string, vector<string>> st;
st.push("hello");
cout << st.top() << endl; // 输出hello

因为vector< string >本身支持string类型,你的stack不需要额外写任何代码,直接适配所有Container支持的类型。

看到这里可能看着该全新的写法还有一些疑问,下面再看一下具体的模板参数实例化和函数调用过程





库中的STL还支持使用缺省参数deque(一个容器),deque既不是一个数组栈,也不是一个链式栈,而是一个双端队列适配出来的栈,双端队列不要求先进先出,其在功能上是list和vector的融合体

二、queue的模拟实现

队列是队尾入数据,队头出数据

也可以在queue的类中用erase代替pop_front();vector不支持直接头删,但是erase是支持头删的,但是这就让效率强行降低了,STL中的vector设计之初没有直接支持头删接口的原因就是希望少用(功能上还是支持的)

cpp 复制代码
void pop()
{
	_con.erase();
}

三、deque的介绍

deque(双端队列) :是一种双开口的"连续"空间的数据结构,双开口的含义是:可以在头尾两端进行插入和删除操作,且时间复杂度为O(1),也可以在中间任意位置插入删除,与vector比较,头插效率高,不需要搬移元素;与list比较,空间利用率比较高,其在功能上是list和vector的融合体,

接口和list和vector的接口都基本是类似的

3.1 deque的使用

仅管从功能上看deque好像可以替代vector和list,但是在实际情况下是不可以的,如果可以的话STL就没有vector和list两个容器了

deque更像是一个有着很好的设计初衷,为了解决vector和list的问题,但实际成品没有达到预期的一个容器,但是其也有很多可取之处

下图是vector和list的优缺点

3.2 CPU高速缓存访问命中率与数据访问效率

3.2.1 数据访问效率

CPU高速缓存访问命中率高间接的也就是说数据访问效率高,反之。该优缺点在归并排序和快速排序的性能测试尤其体现,这里的性能测试该篇文章有写C++ List 容器详解:迭代器失效、排序与高效操作

这里上面打错了,是100w个数据的数组。
排序的性能差异就是由于数据的访问效率低,排序的过程就要对数据结构中的数据进行大量的访问和交换,数据越多,这个差异就体现出来了

3.2.2 CPU高速缓存访问命中率

下面说一下CPU高速缓存访问命中率这个概念
一、计算机存储介质的层级特性

计算机的存储体系核心是 "速度越快→容量越小→成本越高" 的层级设计,从慢到快、从大到小分为:

  1. 硬盘:永久存储介质(断电数据不丢失),容量极大(几百 GB~ 数 TB),但访问速度最慢(毫秒级)。程序 / 文件的持久化存储依赖硬盘,读取硬盘数据时,需先将数据加载到内存中才能被 CPU 访问;
  2. 内存:临时存储介质(断电数据丢失),容量适中(8GB/16GB/32GB 为主),访问速度远快于硬盘(几十纳秒级),但仍慢于 CPU 运算速度。所有运行中的程序、数据结构(如 vector/list)的实际数据都存储在内存中;
  3. CPU 缓存(L1/L2/L3):介于内存和寄存器之间的高速存储,容量远小于内存(L1 几十 KB、L2 几百 KB、L3 几 MB~ 几十 MB),访问速度是内存的 10~100 倍(纳秒级);
  4. 寄存器 :CPU 内部的超高速存储(几字节~几十字节),速度最快(亚纳秒级),但容量极小,仅能存储 CPU 运算时的临时数据(如 ++ 操作的中间值)。


CPU 执行指令的核心逻辑:为什么缓存是刚需

以代码for(auto& e : con) { e++; }为例,CPU 执行 "e++" 的本质是:

  1. 从存储介质中读取 e 的数值;
  2. 把数值加载到寄存器中,通过加法器完成 ++ 运算;
  3. 把运算后的数值写回存储介质。

如果直接从内存读取 / 写入e,会因为内存速度远慢于 CPU 运算速度,导致 CPU 大部分时间处于 "等待数据" 的空闲状态(即 "CPU stall")。因此 CPU 设计了缓存层级

  • 优先从缓存 / 寄存器读取数据,只有缓存中没有目标数据时,才从内存加载;
  • 加载数据时,不是 "要多少读多少",而是基于 "局部性原理" 批量加载,最大化缓存的利用率。


CPU 三级缓存的结构与访问规则

如四核 CPU 缓存架构图所示:

  1. 缓存的层级归属
    • L1 缓存:每个 CPU 核心独占,且分为 "数据缓存(Data Cache)" 和 "指令缓存(Instruction Cache)"------ 程序的执行指令(如 for 循环的跳转、++ 运算指令)会加载到指令缓存,数据(如 vector/list 的元素)会加载到数据缓存;
    • L2 缓存:每个 CPU 核心独占,不区分数据 / 指令,容量比 L1 大,速度略慢;
    • L3 缓存:所有 CPU 核心共享,容量最大,速度比 L2 慢,但仍远快于内存。
  1. 缓存行(Cache Line):数据加载的最小单位
    CPU 从内存向缓存加载数据时**,必须按 "缓存行" 加载**(主流 CPU 的缓存行大小为 64 字节)------ 哪怕只需要 1 个 4 字节的int变量,CPU 也会把该变量所在的 "64 字节连续内存块" 全部加载到缓存中。
    原因:加载数据的硬件成本(如内存总线的 "探测线" 读取 0/1 比特位)与加载量无关,类似 "开公交车接人,接 1 人和接 30 人(64 字节)的发车成本一致",不如一次性加载更多数据。
  2. 缓存命中 / 未命中的访问流程
    CPU 访问数据时,按 "L1→L2→L3→内存" 的顺序检查:
    • 命中(Hit):目标数据在某一级缓存中,直接读取,效率最高;
    • 未命中(Miss):目标数据不在缓存中,需先从内存加载 "目标数据所在的 64 字节缓存行" 到 L3→L2→L1 缓存,再从缓存读取(此过程会产生 "缓存缺失延迟",是性能瓶颈的核心)。

局部性原理:缓存设计的核心依据

CPU 缓存的批量加载逻辑,完全基于局部性原理(计算机程序的核心运行规律):

  1. 时间局部性:访问过的某个内存地址数据,短期内大概率会再次访问(如 for 循环的迭代变量i会被多次读取 / 修改);
  2. 空间局部性 :访问过的某个内存地址数据,其相邻的连续内存地址数据短期内大概率会访问(如数组的第 i 个元素后,大概率会访问第 i+1 个元素)。

基于这一原理,CPU 加载 "当前数据 + 后续连续数据" 到缓存,能最大化 "缓存命中" 的概率 ------ 这也是vector和list缓存命中率差异的核心根源。

五、缓存命中率的定义与核心影响
缓存命中率 = 缓存命中的访问次数 / 总数据访问次数 × 100%。

  • 命中率越高:CPU 越少访问慢内存,数据访问效率越高,程序执行速度越快;
  • 命中率越低:CPU 频繁触发 "缓存未命中→内存加载",大量时间浪费在等待数据上,效率越低。

缓存污染

缓存容量是有限的,若加载了大量 "后续不会被访问" 的数据到缓存,会挤占 "有用数据" 的缓存空间,导致有用数据被挤出缓存→后续访问有用数据时触发 "未命中",这种现象称为缓存污染

  • 例如:遍历一个分散存储的 list 时,每次加载的缓存行只包含 1 个有效节点,其余 60 + 字节都是无用数据,这些无用数据会挤占缓存空间,导致其他有用数据被污染,进一步降低命中率。
  1. vector:极致利用缓存→命中率接近 100%
    vector<int> v = {1,2,3,4,5,...,20}为例(int 占 4 字节,20 个元素占 80 字节):
  • 当 CPU 访问第一个元素v[0]=1(地址0x100)时,触发 "缓存未命中",CPU 从内存加载0x100~0x13F(64 字节,包含前 16 个元素:1~16)到 L1 缓存;
  • 访问v[1]=2~v[15]=16时,这些元素已在 L1 缓存中,100% 命中,直接读取;
  • 访问v[16]=17时,触发第二次 "未命中",加载0x140~0x17F(64 字节,包含 17~20)到缓存,后续访问17~20仍为命中。

整个遍历过程中,仅触发 2 次缓存未命中,其余 18 次均为命中,命中率≈90%;且 vector 无缓存污染(加载的缓存行全是有效元素)。

  1. list:完全违背缓存设计→命中率接近 0
    list<int> l = {1,2,3,4}为例:
  • 访问第一个节点1(地址0x100):未命中,加载0x100~0x13F到缓存(但该缓存行中只有0x100是有效节点 1,其余 60 字节均为无用数据,缓存污染);
  • 访问第二个节点2(地址0x200):该地址不在上一个缓存行中,触发未命中,加载0x200~0x23F到缓存(仅0x200是有效节点 2);
  • 访问节点 3、4 时,重复上述过程,每访问一个节点都触发未命中,命中率≈0%。

且每次加载的缓存行中,有效数据仅占 4/64=6.25%,其余均为无用数据,严重污染缓存→挤占其他有用数据的缓存空间,进一步降低整体效率。

还有专门写该部分原理的博客程序员相关的CPU缓存知识


结语

相关推荐
talenteddriver2 小时前
java: Java8以后hashmap扩容后根据高位确定元素新位置
java·算法·哈希算法
无盐海2 小时前
Redis 哨兵模式
数据库·redis·缓存
APItesterCris2 小时前
高并发场景下的挑战:1688 商品 API 的流量控制、缓存策略与异步处理方案
大数据·开发语言·数据库·缓存
yyy(十一月限定版)2 小时前
c语言——栈和队列
java·开发语言·数据结构
本地运行没问题2 小时前
基于Java注解、反射与动态代理:打造简易ORM框架
java
ss2732 小时前
Java线程池全解:工作原理、参数调优
java·linux·python
J ..2 小时前
C++ Lambda 表达式完全指南
c++
麦麦鸡腿堡2 小时前
Java_MySQL介绍
java·开发语言·mysql
shoubepatien2 小时前
JavaWeb_Web基础
java·开发语言·前端·数据库·intellij-idea