针对内存层面,一种代码有好几种写法,可能实现的功能都差不多,但是性能可能会有很大的差异,关于这点具体有什么要注意的地方
几乎所有 C++ 的性能差异,最终都归结于硬件(尤其是 CPU Cache)是如何与我们的内存访问模式进行交互的。在现代 C++ 中,“高性能程序 = 贴合缓存的数据结构 + 算法”。C++ 几乎是唯一一个允许你(也要求你)精细控制内存布局的现代语言。
- 缓存为王:连续内存(std::vector) 碾压 指针跳转(std::list)
CPU 从内存读取数据不是一个字节一个字节读的,而是以“缓存行”(Cache Line)为单位(通常是 64 字节)来批量读取的。这是利用了空间局部性(Spatial Locality): 当你访问一个内存地址时,CPU 会自动把它和它后面相邻的 63 个字节一起加载到高速缓存(L1 Cache)中。
性能差异:std::vector<int>内存完全连续。当你遍历
vector 时,第一次访问 v[0],CPU 会把 v[0] 到 v[15](假设 int 是 4
字节,64/4=16)全部加载到缓存。接下来的 15 次访问将直接命中 L1
缓存,速度极快(几乎 0 延迟)。
std::list<int>内存完全不连续。每个节点 Node 包含
int value 和 Node* next。当你遍历 list 时,访问 node1,CPU
加载了它。然后你需要通过 node1->next
指针跳转到一个完全随机的内存地址去访问 node2。
结果:vector 遍历是一次内存访问 + 15 次缓存命中。list 遍历是 16 次“缓存未命中”(Cache Miss)。后者比前者慢 1~2 个数量级(10x ~ 100x)。
结论:永远优先使用 std::vector。只有当你需要在集合中间频繁地插入/删除(这会导致 vector 移动大量元素)时,才考虑使用 std::list 或 std::map。
- 堆栈之别:避免热循环中的内存分配
栈(Stack)分配: int a[100];。速度极快。它仅仅是把栈顶指针 rsp 向下移动几百个字节,这是一个单条 CPU 指令。 堆(Heap)分配: int* a = new int[100];。速度非常慢。它需要调用 malloc(),malloc 必须去加锁(因为堆是全局共享的)、查找一个合适大小的空闲内存块(可能涉及复杂的算法, 如果不足还涉及到系统调用 mmap 或 sbrk)、记录元数据,最后才返回指针。
结论:“不要在热循环(Hot Loop)中 new / delete”。
如果一个函数需要一个临时缓冲区,优先使用栈(如果大小确定且不大,如 std::array),或者在循环外 new 一次,在循环内复用(Reuse)它。
对于需要频繁创建和销毁的小对象(如金融数据包),使用“对象池”(Object Pool)或“内存池”(Memory Pool)来绕过 malloc,这是高频交易和量化中非常常见的优化。 - 这是一种“预先分配、循环使用”的自定义内存管理方案. 内存池 (Memory Pool)在启动前预分配 (Pre-allocation), 执行一次(或几次)巨大的 malloc,从操作系统那里“批发”一大块连续的内存。接着把这块大内存切成 N 个固定大小(例如,刚好能容纳一个“金融数据包”对象)的小块。 - 然后通过一个空闲列表 (Free List), 一个非常简单的数据结构(比如一个链表,称为“空闲列表”)把所有这些小块串起来。 - 对象池是基于内存池的一种更上层的封装。内存池 (Memory Pool)只管理原始内存 (raw memory), 给你返回一个 void* 指针。你需要自己负责在这块内存上构造对象(使用 placement new)。 - 而对象池 (Object Pool)管理完整的对象。acquire():它从池中拿出一个对象,这个对象可能已经构造好了(或者池会帮你调用构造函数)。release(obj):你归还一个对象时,池可能会帮你调用它的析构函数(或者更常见的,调用一个 reset() 方法将其重置为初始状态),然后将其放回空闲列表。
- 拷贝之痛:const&、移动语义(Move)和写时复制(COW)
const&: 对于“只读”的大对象(如 std::string, std::vector),永远使用 const& 传参,避免不必要的深拷贝。
移动语义(Move): 如果函数需要“接管”这个资源(例如 SetData(std:string s) { this->s_ = s; }),那么使用按值传参(SetData(std::string s))并 std::move(this->s_ = std::move(s);)。这允许调用者自动选择拷贝或移动。
写时复制(Copy-on-Write): 这是一种旧的(C++11前)优化。例如,string s2 = s1。s2 不会立刻复制 s1 的数据,而是共享它。只有当 s2 尝试修改数据时,它才会真正执行拷贝。
注意: std::string 在 C++11 后放弃了 COW,因为 COW 在多线程环境下需要原子引用计数,其开销(同步)比直接移动(Move)更大。
虚函数调用造成的 cache miss
虚函数(多态)的本质是在运行时才决定具体调用哪个函数。CPU 无法在编译时或执行前提前知道确切的函数地址。这种运行时的不确定性破坏了 CPU 缓存赖以高效工作的局部性原理,从而导致两种主要的 Cache Miss。
首先,现代 CPU 内部的 L1 缓存通常被分为两种:
- L1d (Data Cache):数据缓存。专门用于存储程序运行时需要读写的数据(例如变量、对象、vtable 本身)。
- L1i (Instruction Cache):指令缓存。专门用于存储 CPU 即将执行的代码(即函数体内的机器指令)。
这两种缓存是物理上分离的。
- 数据缓存未命中 (Data Cache Miss)
这是“查找函数地址”过程导致的。执行步骤:
- 当调用 ptr->doWork() 时,CPU 必须执行一次间接查找。
- 访问 ptr 指向的对象内存,读取其 vptr (虚函数表指针)。
- 根据 vptr 的地址,跳转去访问对应的 vtable (虚函数表)。
- 从 vtable 中取出 doWork() 的真实函数地址。
问题所在:vptr 指向的 vtable 通常位于内存中完全不同的区域(例如只读数据段),它和当前正在处理的对象数据在内存上不相邻。访问这个 vtable 的操作很大概率不在 CPU 的 L1/L2 数据缓存中,导致 Data Cache Miss。CPU 必须暂停,去主内存 (RAM) 读取 vtable。
- 指令缓存未命中 (Instruction Cache Miss)
这是“跳转执行函数代码”过程导致的。执行步骤: CPU 成功获取了 doWork() 的真实地址(例如,它现在知道要去执行 Derived2::doWork())。
问题所在:CPU 会将即将执行的指令(代码)加载到指令缓存 (Instruction Cache) 中。 如果 Derived2::doWork 这个函数最近没有被执行过(例如,循环中上一次执行的是 Derived1::doWork),它的代码就不在 L1i 缓存中,导致一次 Instruction Cache Miss。CPU 必须再次暂停,去 L2/L3/RAM 中把函数代码取回来。
同时, 假如如果程序(例如在一个循环中)交替调用不同的派生类实例(一会是 Derived1,一会是 Derived2),CPU 的指令缓存就会“抖动” (Thrashing)。CPU 刚加载了 Derived1::doWork 的代码,下次循环又要把它踢出去,换上 Derived2::doWork 的代码(导致 Instruction Cache Miss)。
这种无法预测的跳转目标也会导致严重的分支预测失败 (Branch Misprediction),其惩罚(清空 CPU 流水线)甚至比 Cache Miss 更大。