一. 内存管理

1. 堆 (Heap) 与 栈 (Stack) 的本质区别

  • 核心逻辑: 栈是操作系统自动分配释放的,连续且速度极快;堆是手动申请的(如 new/malloc),不仅慢,还容易产生碎片。

  • 游戏场景: 为什么我们要极力避免在 Tick每帧更新)里频繁 new 对象?因为堆分配会陷入内核态,且可能引发内存碎片化。像子弹对象池这种技术,本质上就是把“运行时的堆分配”转化为了“初始化时的集中分配”,后续复用内存,从而避开堆操作和 GC(垃圾回收)开销。

  • 面试高频: 栈溢出(Stack Overflow)一般是怎么导致的?(答:无限递归调用或在栈上分配了过大的局部数组/结构体)。

2. 内存对齐 (Memory Alignment)

  • 核心逻辑: CPU 读取内存不是一个字节一个字节读的,而是按块(Cache Line,通常是 64 字节)读取。如果一个数据结构跨越了两个 Cache Line,CPU 就需要读两次。

  • 游戏场景: 在 C++ 定义底层通信结构体(如网络同步的历史帧状态)或高频计算的数学向量时,合理排列成员变量的顺序(按占用空间从大到小,或利用 #pragma pack / alignas),可以大幅缩减结构体体积,提升 CPU Cache 缓存命中率。

  • 面试高频: 给定一个 struct 结构体(包含 char, int, double),问它在 64 位系统下占用多少字节?(注意对齐规则和填充字节 padding)。

3. 虚拟内存与缺页中断 (Page Fault)

  • 核心逻辑: 进程以为自己拥有整块连续内存,其实是系统通过页表 (Page Table) 映射到物理内存上的。

  • 游戏场景: 游戏在无缝大地图中奔跑时,突然发生严重的卡顿(掉帧)。底层原因之一可能是硬缺页中断(Major Page Fault)——CPU 访问的虚拟内存页当前不在物理内存中,操作系统必须阻塞当前线程,去磁盘(硬盘)里把资源(比如远处的贴图或模型)加载到物理内存里。

二、CPU底层原理

2.1 Cache Line(缓存行)

Cache Line(缓存行) 是 CPU 高速缓存(Cache)中数据传输的最小单位

它是连接“极快的CPU”和“较慢的内存”之间的桥梁。理解它,是理解你简历中 alignas(64) 优化原理的关键。


🏗️ 核心定义

  1. 最小传输块: CPU 不能只从内存里取 1 个字节或 1 个整数。当 CPU 发现 L1 Cache 里没有数据时,它会向内存发起请求,一次性拉取一整块数据到 Cache 中。这块数据就是 Cache Line
  2. 标准大小
    • 在 x86-64 架构(绝大多数现代 PC、服务器、游戏主机)中,Cache Line 的大小固定为 64 字节 (Bytes)
    • 这就是为什么你的代码中使用了 alignas(64) —— 为了让每个对象独占一条 Cache Line。

💡 通俗比喻:超市购物

想象 CPU 是顾客,内存是仓库,Cache 是购物车

  • 传统做法(指针乱序): 你想买一个苹果(访问一个对象)。 你去仓库,仓库管理员说:“我们这里一次只能按整箱发货”。一箱有 64 个苹果(Cache Line)。 结果:你为了拿 1 个苹果,不得不把整整一箱(64 个苹果,其中可能只有 1 个是你需要的,其他都是垃圾数据)都搬回购物车。

    • 浪费:你只用了 1/64 的数据,但付出了搬运整箱的时间和精力(带宽浪费)。
    • 后果:如果下一个你要买的苹果在仓库的另一端,你得再跑一趟,再搬一箱。
  • 你的优化(Cache Line 对齐): 你把所有需要的苹果(TSlot 结构体)整齐地码放在仓库里,确保每个苹果都独占一个箱子,且箱子之间紧挨着。

    • 效果:当你去拿第 1 个苹果时,仓库不仅给了你第 1 个苹果的箱子,还因为预取机制,顺手把第 2、3、4 个苹果的箱子也搬过来了(因为它们在物理上紧挨着)。
    • 命中率飙升:当你需要第 2 个苹果时,它已经在购物车(L1 Cache)里了,不需要再去仓库跑一趟。

🔍 为什么必须是 64 字节?

这是硬件设计的妥协与平衡:

  1. 太小(如 8 字节):每次访问都要频繁往返内存,总线拥堵,延迟高。
  2. 太大(如 256 字节):虽然减少了一次往返,但如果你只需要其中 1 个字节,剩下的 255 字节就浪费了宝贵的 Cache 空间(Cache Capacity),导致其他重要数据被挤出 Cache。
  3. 64 字节:经过几十年验证,这是空间局部性(Spatial Locality)的最佳平衡点。大多数程序访问的数据往往聚集在一起,64 字节足够覆盖一个小数组或几个相关变量。

⚠️ 致命陷阱:伪共享 (False Sharing)

这是你简历中提到的关键点!

场景: 假设你有两个线程,分别修改 AB 两个变量。

  • AB 在内存中紧挨着,都在同一个 64 字节 Cache Line 内。
  • 线程 1 修改 A -> CPU 标记这条 Cache Line 为“脏数据”,并通知其他核心:“我要改这个线了,你们别动!”
  • 线程 2 修改 B -> CPU 发现这条线已经被线程 1 锁定了,必须等线程 1 释放,或者强制刷新缓存。
  • 结果:尽管 A 和 B 是完全独立的变量,但因为它们在同一条 Cache Line 上,CPU 会认为它们互相干扰。两个线程会疯狂地在彼此的核心间同步状态(MESI协议),导致性能暴跌,甚至比单线程还慢!

你的解决方案

alignas(64) TSlot

通过强制对齐,你确保每个 TSlot独占一条 64 字节的 Cache Line。

  • Slot 0 占 Line 0。
  • Slot 1 占 Line 1。
  • ...

这样,即使两个线程同时修改不同的 Slot,它们操作的是不同的 Cache Line,完全互不干扰,彻底消除了伪共享。


📊 总结对比

特性 没有对齐 (传统方案) 对齐后 (你的方案)
内存布局 对象分散,大小不一 连续排列,每个对象占满 64 字节
Cache Line 利用率 低(一个 Line 里塞进多个小对象,或跨行) 高(一个对象独占一个 Line,无浪费)
伪共享风险 极高(多线程下性能灾难) (每个线程操作独立 Line)
预取效率 差(随机跳转,无法预测) 极好(顺序访问,预取器完美工作)
性能表现 慢(大量等待内存) 快(全速运行)

最大化空间布局

这个概念在初学底层优化时确实有些反直觉,但它是现代 CPU 性能调优(尤其是游戏引擎开发)的核心密码

要理解“最大化空间局部性”,我们只需要弄懂一个核心事实:CPU 计算的速度,远远大于它从内存(RAM)拿数据的速度。

为了不让 CPU 干等,硬件工程师设计了高速缓存(Cache)。接下来,我们用一个“厨房做菜”的通俗比喻,结合你项目的“子弹”场景,把这个概念彻底拆解。

1. 核心前置知识:CPU 怎么拿数据?(Cache Line)

CPU 从内存读取数据时,绝对不会“要一个字节,拿一个字节”

这就好比厨师(CPU)需要一个土豆,他不会让搬运工去仓库(RAM)只拿一个土豆。因为跑一趟太费时间了。

搬运工每次去仓库,都会装满一个固定大小的箱子带回来。这个箱子在计算机里叫 Cache Line(缓存行),通常大小是 64 字节。

不管你只要 1 个字节还是 4 个字节,CPU 都会一口气把包含目标数据的连续 64 个字节全部搬到高速缓存(L1 Cache)里。

2. 通俗比喻:什么是“空间局部性”?

空间局部性(Spatial Locality)指的是一种规律:如果你访问了内存中的某个位置,那么它相邻的位置很快也会被访问。

  • 没有空间局部性(原生的 AActor)

    假设我们要更新 1000 颗子弹的位置。传统的面向对象写法(AoS - 数组结构体)是把子弹的所有属性绑在一起。

    内存排布就像:[位置, 速度, 贴图指针, 音频组件, 碰撞体] | [位置, 速度, 贴图指针, 音频组件, 碰撞体]...

    当 CPU 要算第一颗子弹的“位置”时,搬运工搬回来一个 64 字节的箱子。结果箱子里只有 1 个“位置”,剩下的空间装的是“贴图、音频”这些当前计算根本不需要的垃圾数据。

    算第二颗子弹时,又要重新去仓库搬箱子(这就是 Cache Miss,缓存未命中,导致 200+ CPU 周期被浪费在等待上)。

  • 最大化空间局部性(你的 SoA 优化方案)

    你把所有子弹的同类数据剥离出来,存放在连续的数组里(SoA - 结构体数组)。

    内存排布变成了:[位置1, 位置2, 位置3, 位置4...][速度1, 速度2, 速度3, 速度4...]

    当 CPU 想要第一颗子弹的“位置”时,搬运工搬回来的 64 字节箱子里,密密麻麻塞满了十几颗子弹的位置数据

    CPU 算完第一颗,伸手一拿,第二颗、第三颗的数据已经在手边(L1 Cache 里)了,不需要再去仓库拿。CPU 可以像机关枪一样不间断地连续计算(这就是 Cache Hit,缓存命中率 > 95%)。

3. 总结归纳

利用连续内存布局最大化空间局部性”的意思就是:

你刻意改变了数据的存储结构,把 CPU 在这一帧(比如 Tick 位置)会集中使用的数据,紧紧凑凑地挨在一起放在内存里。这样 CPU 每次读取内存(拉取一个 Cache Line)时,带回来的全都是马上要用的有效热数据,没有任何空间浪费,从而将内存访问的延迟降到了最低。

这就是 DOD(面向数据设计)比 OOP(面向对象设计)在海量实体运算时快上几十倍的根本原因。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐