Appearance
引擎岗套卷
C++ 对象内存布局
一句话定义
C++ 对象内存布局,就是一个对象在内存里实际放了哪些东西:非静态成员变量、基类子对象、对齐填充;如果有虚函数,通常还会多一个 vptr 指向虚表。
标准回答
普通对象里主要存:
c
非静态成员变量
padding 对齐填充
基类子对象
可能存在的 vptr / vbptr不在对象里的有:
c
成员函数代码
static 成员变量
static 成员函数
虚表 vtable 本身成员函数只有一份代码,调用时编译器隐式传入 this 指针,所以每个对象不会复制一份成员函数。
底层原理
普通类一般按成员声明顺序排列,但为了满足对齐要求,编译器会插入 padding。
如果类有虚函数,对象里通常会有一个虚指针 vptr。vptr 指向该类型的虚表 vtable,虚表里保存虚函数地址。调用虚函数时,大概是:
c
对象 -> vptr -> vtable -> 函数地址多继承时,一个对象里可能包含多个基类子对象,也可能有多个 vptr。把派生类指针转成第二个基类指针时,还可能发生指针偏移。
代码观察
c
#include <iostream> // 引入标准输出。
class Plain // 定义一个普通类。
{ // 类开始。
public: // 公开成员区域。
char tag; // 1 字节成员,后面可能产生 padding。
int hp; // 4 字节成员,需要按 int 对齐。
float speed; // 4 字节成员,表示速度。
}; // 普通类结束。
class WithVirtual // 定义一个带虚函数的类。
{ // 类开始。
public: // 公开成员区域。
int hp; // 普通数据成员。
virtual void Update() // 定义虚函数,通常会让对象多出 vptr。
{ // 函数开始。
} // 函数结束。
}; // 带虚函数类结束。
int main() // 程序入口。
{ // main 函数开始。
std::cout << sizeof(Plain) << std::endl; // 输出普通对象大小,通常包含 padding。
std::cout << sizeof(WithVirtual) << std::endl; // 输出带虚函数对象大小,通常包含 vptr。
return 0; // 返回 0 表示程序正常结束。
} // main 函数结束。面试重点
NOTE
sizeof(obj) 包含对象自身成员、padding、可能的 vptr,但不包含指针指向的堆内存。
空类大小通常是 1,因为不同对象必须有不同地址。
具体布局和编译器、平台、ABI 有关,不能把复杂 C++ 对象直接当二进制协议或存档格式乱写。
在游戏引擎里,大量对象如果分散在堆上,会导致 cache miss;所以 ECS、SoA、内存池会更关注数据连续性。
编译链接流程
一句话定义
C++ 编译链接流程就是把 .cpp/.h 经过预处理、编译、汇编、链接,最终生成可执行文件或动态库。
标准流程
预处理:处理 #include、宏替换、条件编译。头文件会被展开进 .cpp,形成一个翻译单元。
编译:对预处理后的代码做语法检查、语义检查、模板实例化、优化,然后生成汇编或中间代码。
汇编:把汇编转成机器码,生成 .o 或 .obj 目标文件。目标文件里有代码段、数据段、符号表、重定位信息。
链接:把多个目标文件和库合并,解析函数和全局变量符号,修正地址,生成 .exe、.dll、.so 等产物。
一句话区分
编译阶段主要管“语法对不对、单个 .cpp 能不能变成目标文件”。
链接阶段主要管“多个目标文件放在一起后,函数和变量定义能不能找到”。
代码例子
c
#include <iostream> // 预处理阶段会展开标准库头文件。
int Add(int a, int b); // 这里只是函数声明,告诉编译器这个函数存在。
int main() // 定义程序入口函数。
{ // main 函数开始。
std::cout << Add(1, 2) << std::endl; // 调用 Add,编译期允许通过,但链接期要找到定义。
return 0; // 返回 0 表示程序正常结束。
} // main 函数结束。
int Add(int a, int b) // 定义 Add 函数,链接器会用这个定义解析前面的调用。
{ // Add 函数开始。
return a + b; // 返回两个整数的和。
} // Add 函数结束。如果只有 int Add(int a, int b); 声明,没有 Add 的定义,编译可能能过,但链接会报 unresolved external symbol。
静态库和动态库
静态库链接时,相关代码会被拷贝进最终程序,发布简单,但可执行文件可能变大。
动态库不会完整拷贝进程序,程序记录导入信息,运行时由加载器加载 .dll 或 .so,方便更新和共享,但要处理版本、路径和 ABI 兼容。
常见链接错误原因
声明了函数,但没有提供定义。
函数签名不一致,比如参数、命名空间、const 不一致。
只编译了 .cpp,但没有把对应 .obj 加入链接。
库目录或库文件没有配置。
C++ 名字改编导致 C 和 C++ 混用时符号对不上,需要 extern "C"。
头文件里放了非 inline 的全局函数或变量定义,导致多重定义,违反 ODR。
游戏工程里的意义
IMPORTANT
大型 C++ 游戏工程会很关注编译链接速度,所以常用预编译头、增量编译、模块化、静态库拆分、动态库插件化。引擎层还会关心 ABI 稳定性,否则一个模块的结构体布局或函数签名变化,可能导致插件或 DLL 兼容问题。
内存池设计
一句话定义
内存池就是预先申请一大块内存,再按固定大小或分级大小切成小块复用,避免频繁 new/delete 走系统堆造成卡顿和内存碎片。
核心设计
最常见的是固定块内存池:
先申请一整块连续内存。
把它切成很多个大小相同的 block。
空闲 block 用 free list 串起来。
申请时从链表头取一个 block。
释放时把 block 插回链表头。
这样分配和释放基本都是 O(1)。
和对象池区别
对象池复用的是“对象实例”,比如子弹、特效、怪物。
内存池复用的是“内存块”,对象可以通过 placement new 构造在这块内存上。
所以内存池更底层,对象池可以建立在内存池之上。
简化 C++ 手写版
c
#include <cstddef> // 引入 size_t 和 max_align_t。
#include <cstdlib> // 引入 malloc 和 free。
class FixedBlockPool // 定义固定块内存池。
{ // 类开始。
private: // 私有成员区域开始。
struct FreeNode // 定义空闲链表节点。
{ // FreeNode 开始。
FreeNode* next; // 指向下一个空闲块。
}; // FreeNode 结束。
unsigned char* _memory; // 保存整块连续内存的起始地址。
FreeNode* _freeList; // 保存空闲链表头指针。
size_t _blockSize; // 保存每个块的大小。
size_t _capacity; // 保存块数量。
public: // 公开成员区域开始。
FixedBlockPool(size_t blockSize, size_t capacity) // 定义构造函数。
: _memory(nullptr), _freeList(nullptr), _blockSize(Align(blockSize)), _capacity(capacity) // 初始化成员变量。
{ // 构造函数开始。
if (_blockSize < sizeof(FreeNode)) // 如果块太小放不下 next 指针。
{ // if 开始。
_blockSize = Align(sizeof(FreeNode)); // 至少保证能存 FreeNode。
} // if 结束。
_memory = static_cast<unsigned char*>(std::malloc(_blockSize * _capacity)); // 一次性申请大块内存。
for (size_t i = 0; i < _capacity; ++i) // 遍历每一个 block。
{ // for 开始。
void* address = _memory + i * _blockSize; // 计算当前 block 地址。
Free(address); // 把当前 block 挂入空闲链表。
} // for 结束。
} // 构造函数结束。
~FixedBlockPool() // 定义析构函数。
{ // 析构函数开始。
std::free(_memory); // 释放整块内存。
} // 析构函数结束。
void* Allocate() // 从池中申请一个内存块。
{ // Allocate 开始。
if (_freeList == nullptr) // 如果空闲链表为空。
{ // if 开始。
return nullptr; // 返回空,表示池子没有可用块。
} // if 结束。
FreeNode* node = _freeList; // 取出链表头节点。
_freeList = _freeList->next; // 链表头移动到下一个空闲块。
return node; // 返回取出的内存块。
} // Allocate 结束。
void Free(void* ptr) // 把内存块归还给池。
{ // Free 开始。
if (ptr == nullptr) // 如果传入空指针。
{ // if 开始。
return; // 直接返回。
} // if 结束。
FreeNode* node = static_cast<FreeNode*>(ptr); // 把内存块当作空闲节点使用。
node->next = _freeList; // 当前块指向原来的链表头。
_freeList = node; // 当前块成为新的链表头。
} // Free 结束。
private: // 私有工具函数区域开始。
static size_t Align(size_t size) // 定义对齐函数。
{ // Align 开始。
size_t align = alignof(std::max_align_t); // 获取平台通用最大对齐值。
return (size + align - 1) & ~(align - 1); // 把 size 向上对齐到 align 的倍数。
} // Align 结束。
}; // 类结束。工程里要补的保护
上面是面试手写版,真实项目还要加:
重复释放检测。
越界写检测,比如 block 前后加 guard 字节。
内存泄漏统计,比如当前使用数量和历史峰值。
线程安全策略,比如每线程一个池,减少锁竞争。
扩容策略,比如池满时新建 chunk,或直接返回失败。
优缺点
优点是分配释放快、碎片少、性能稳定,适合子弹、粒子、组件、小消息包、临时帧内对象。
缺点是会占用常驻内存,块大小不合适会浪费空间,而且调试难度比直接 new/delete 高。
面试关键句
NOTE
内存池的核心不是简单缓存指针,而是自己控制内存分配策略。固定块池用 free list 做 O(1) 申请释放,适合大量同尺寸对象;如果尺寸差异大,可以做分级池;如果是一帧内临时数据,可以用线性分配器。
ECS 和 OOP 对比
一句话定义
OOP 是“对象组织代码”,对象里有数据也有行为;ECS 是“数据组织代码”,Entity 只是 ID,Component 只存数据,System 批量处理逻辑。
核心区别
OOP 里你会写:
c
Monster.Update()也就是每个怪物对象自己更新自己。它的好处是封装直观,适合业务逻辑、UI、任务、背包、剧情、复杂交互对象。
ECS 里你会写:
MoveSystem 批量处理所有拥有 Position 和 Velocity 的 Entity。
它的好处是数据连续、批量处理、cache 友好,更适合大量同类实体,比如子弹、粒子、怪物移动、状态同步、简单 AI。
底层原理
OOP 对象通常分散在内存里,遍历时可能频繁跳指针,CPU cache 命中率不高。如果还有虚函数、多态、复杂继承,调用和内存访问都会更分散。
ECS 倾向把同类 Component 放在连续数组里,比如:
c
Position[]
Velocity[]
Health[]System 遍历这些连续数组,CPU 能更容易预取数据,也更适合 SIMD、Job System、多线程并行。
优缺点
OOP 优点是好理解、封装强、写业务快。缺点是大量对象时可能 cache 不友好,继承层级复杂后容易僵硬。
ECS 优点是性能好、数据清晰、适合海量对象和并行。缺点是心智负担高,调试更绕,写简单业务反而可能变复杂。
Unity 里怎么理解
传统 Unity 的 GameObject + Component + MonoBehaviour 更接近 OOP + 组合模式。
Unity DOTS 的 Entity + IComponentData + System 更接近 ECS。
实际项目里不是非黑即白:UI、任务、剧情、背包我会用 OOP;大量怪物、子弹、特效、地图格子、战斗数值批处理,我会考虑 ECS 或数据导向写法。
面试关键句
CAUTION
ECS 不是为了替代 OOP,而是为了解决大量同类对象更新时的数据访问和并行问题。业务表达清晰优先 OOP,性能热点和海量实体优先 ECS 或 DOD。
Game Loop 设计
一句话定义
Game Loop 是游戏的主循环:不断采集输入、推进逻辑、更新物理、刷新动画、提交渲染,并控制每一帧的时间节奏。
标准设计思路
一个成熟的 Game Loop 通常会把“逻辑 tick”和“渲染 frame”分开。
逻辑层适合固定步长,比如 1/60 秒一次。这样物理、战斗判定、帧同步、回放更稳定。
渲染层可以按真实帧率走,比如 60 FPS、90 FPS、120 FPS 都能渲染。为了画面平滑,可以用上一帧和当前帧的状态做插值。
常见结构是:
c
采集输入 → 累加 deltaTime → while 能跑 fixed tick 就跑逻辑 → Update 表现 → Render 渲染为什么不能全用可变 deltaTime
如果所有逻辑都用可变 deltaTime,帧率波动会影响物理、碰撞、同步和手感。比如某一帧卡了 200ms,角色可能瞬移很远,碰撞也更容易穿透。
所以工程里常用固定 tick 处理关键逻辑,再让渲染层做插值。
C# 简化版 Game Loop
c
using System; // 引入 Math 等基础类型。
public sealed class SimpleGameLoop // 定义一个简化游戏主循环类。
{ // 类开始。
private const double FixedDeltaTime = 1.0 / 60.0; // 定义固定逻辑步长为 60 tick 每秒。
private const int MaxFixedSteps = 4; // 定义单帧最多补 4 次逻辑,防止死亡螺旋。
private double _accumulator; // 保存尚未消费的累计时间。
public void Tick(double frameDeltaTime) // 每个渲染帧调用一次 Tick。
{ // Tick 方法开始。
frameDeltaTime = Math.Min(frameDeltaTime, 0.25); // 限制最大帧间隔,避免后台回来后一次补太多。
PollInput(); // 采集当前帧输入,形成输入快照。
_accumulator += frameDeltaTime; // 把本帧经过的时间加入累加器。
int fixedStepCount = 0; // 记录本帧已经执行了多少次固定逻辑。
while (_accumulator >= FixedDeltaTime && fixedStepCount < MaxFixedSteps) // 当累计时间足够且未超过最大补帧次数时。
{ // while 循环开始。
FixedTick(FixedDeltaTime); // 推进一次固定逻辑,比如物理、战斗、网络同步。
_accumulator -= FixedDeltaTime; // 从累加器里扣掉已经消费的固定步长。
fixedStepCount++; // 增加本帧固定逻辑执行次数。
} // while 循环结束。
double alpha = _accumulator / FixedDeltaTime; // 计算渲染插值比例。
UpdatePresentation(frameDeltaTime); // 更新表现层逻辑,比如 UI、相机、动画参数。
Render(alpha); // 使用插值比例渲染当前画面。
} // Tick 方法结束。
private void PollInput() // 定义输入采集方法。
{ // 方法开始。
} // 方法结束。
private void FixedTick(double fixedDeltaTime) // 定义固定逻辑更新方法。
{ // 方法开始。
} // 方法结束。
private void UpdatePresentation(double deltaTime) // 定义表现层更新方法。
{ // 方法开始。
} // 方法结束。
private void Render(double alpha) // 定义渲染方法。
{ // 方法开始。
} // 方法结束。
} // 类结束。Unity 里怎么对应
Unity 底层有自己的 PlayerLoop。我们平时写的生命周期函数,本质上是挂在 PlayerLoop 不同阶段:
FixedUpdate:固定步长,适合物理、Rigidbody、帧同步 tick。
Update:每帧一次,适合输入、普通逻辑、计时。
LateUpdate:Update 后执行,适合相机跟随、表现层修正。
Render:Unity 内部渲染阶段,提交相机、剔除、绘制、后处理等。
常见坑
不要让卡顿后一帧无限补逻辑,否则越补越卡,叫死亡螺旋。要限制最大补帧次数。
暂停系统要区分 scaled time 和 unscaled time。游戏暂停时,战斗逻辑停,但 UI 动画和加载进度可能还要走。
网络帧同步通常强依赖固定 tick,输入也要带 tick 编号,不能用不稳定帧率直接推进核心逻辑。
面试关键句
WARNING
Game Loop 的核心是时间管理。关键逻辑用固定 tick 保证稳定和可复现,渲染用可变帧率保证画面流畅,中间用 accumulator 和插值连接,同时限制最大追帧,避免卡顿后雪崩。
渲染管线流程
一句话定义
渲染管线就是把场景里的模型、材质、灯光等数据,经过 CPU 准备和 GPU 绘制,最终变成屏幕像素的完整流程。
标准流程
一帧渲染大概可以分成两大段:CPU 阶段和 GPU 阶段。
CPU 阶段主要做:
收集场景里可渲染对象。
做视锥剔除、遮挡剔除、LOD 选择。
按照 Render Queue、材质、深度等排序。
做合批、GPU Instancing、SRP Batcher 等优化。
设置 Shader、材质、纹理、Mesh、渲染状态。
提交 DrawCall 或 CommandBuffer 给 GPU。
GPU 阶段主要做:
顶点着色器:把模型顶点从模型空间变换到裁剪空间,也就是常说的 MVP。
图元装配和裁剪:把顶点组成三角形,裁掉看不见的部分。
光栅化:把三角形转成屏幕上的片元。
片元着色器:采样贴图、计算光照、输出颜色。
深度测试和模板测试:决定这个片元能不能写入。
混合:处理透明、半透明、叠加等效果。
后处理:Bloom、Color Grading、抗锯齿、景深等。
最终 Present 到屏幕。
Unity 里怎么理解
Built-in、URP、HDRP 细节不同,但核心都是:
c
Culling → Sorting → Draw → Shader → Depth/Blend → PostProcess → PresentURP/HDRP 属于 SRP,会用 Render Pass / ScriptableRenderContext 更明确地组织流程。
Forward 渲染通常是物体绘制时计算光照,透明支持更直接。
Deferred 渲染通常先写 GBuffer,再统一做光照,适合多光源,但透明物体仍然麻烦。
性能怎么看
CPU 瓶颈常见在 DrawCall 多、SetPass 多、脚本提交渲染命令重。
GPU 瓶颈常见在片元太多、Overdraw 高、阴影贵、后处理贵、带宽压力大。
移动端尤其要注意透明特效、全屏后处理、实时阴影和高分辨率 RenderTexture。
面试关键句
IMPORTANT
渲染管线本质是 CPU 把“要画什么、用什么状态画”组织成命令,GPU 把几何数据经过顶点、光栅、片元、测试、混合变成最终像素。优化时要先判断瓶颈在 CPU 提交还是 GPU 绘制,不能只盯 DrawCall。
Frame Graph 或渲染队列设计
一句话定义
渲染队列负责“这一帧哪些物体按什么顺序画”,Frame Graph 负责“这一帧哪些渲染 Pass 按什么依赖执行,资源怎么读写和复用”。
渲染队列怎么设计
我会先把场景里的可渲染对象收集成 RenderItem,里面通常包含:
c
Mesh
Material
Shader Pass
World Matrix
Bounds
Render Queue
Depth
SortKey然后分队列:
不透明队列:通常前到后排序,减少 Overdraw,同时按材质、Shader 排序减少状态切换。
透明队列:通常后到前排序,保证 Alpha Blend 正确。
AlphaTest 队列:介于不透明和透明之间,能写深度,但片元可能 discard。
UI / Overlay 队列:最后绘制。
核心是生成一个 SortKey,把 queue、shader、material、depth 等信息打包进去,排序后顺序提交 DrawCommand。
Frame Graph 怎么设计
Frame Graph 不是管单个物体顺序,而是管一帧里的渲染 Pass 和资源依赖。
每个 Pass 声明自己:
读哪些资源。
写哪些资源。
生成哪些临时 RenderTexture。
是否有副作用。
例如:
Depth Prepass 写 DepthTexture
GBuffer Pass 写 GBuffer
Lighting Pass 读 DepthTexture + GBuffer,写 ColorTexture
PostProcess Pass 读 ColorTexture,写 BackBuffer
Frame Graph 根据读写关系自动排出执行顺序,还可以做 Pass 裁剪、临时资源复用、资源生命周期管理、Barrier 插入。
两者关系
渲染队列更像“物体层面的排序系统”。
Frame Graph 更像“Pass 层面的调度系统”。
一个真实引擎里通常是两者结合:Frame Graph 先决定这一帧有哪些 Pass,每个 Pass 内部再使用自己的渲染队列去提交 RenderItem。
伪代码骨架
c
public struct RenderItem // 定义一个渲染项。
{ // 结构体开始。
public Mesh Mesh; // 保存要绘制的网格。
public Material Material; // 保存使用的材质。
public Matrix4x4 WorldMatrix; // 保存世界变换矩阵。
public ulong SortKey; // 保存排序键,用于控制绘制顺序。
} // 结构体结束。
public sealed class RenderPass // 定义一个渲染 Pass。
{ // 类开始。
public string Name; // 保存 Pass 名称。
public List<TextureHandle> Reads = new List<TextureHandle>(); // 保存该 Pass 读取的资源。
public List<TextureHandle> Writes = new List<TextureHandle>(); // 保存该 Pass 写入的资源。
public List<RenderItem> Items = new List<RenderItem>(); // 保存该 Pass 内部要绘制的物体。
public void Execute(CommandBuffer cmd) // 执行当前 Pass。
{ // 方法开始。
Items.Sort((a, b) => a.SortKey.CompareTo(b.SortKey)); // 按 SortKey 对渲染项排序。
foreach (RenderItem item in Items) // 遍历排序后的渲染项。
{ // foreach 开始。
cmd.SetMaterial(item.Material); // 设置当前渲染项的材质。
cmd.DrawMesh(item.Mesh, item.WorldMatrix); // 提交绘制命令。
} // foreach 结束。
} // 方法结束。
} // 类结束。优缺点
渲染队列简单直接,适合传统 Forward 管线和单 Pass 物体排序,但它不擅长管理多个 RenderTexture、Pass 依赖和资源复用。
Frame Graph 更适合现代渲染管线,能自动推导依赖、裁剪没用的 Pass、复用临时纹理、减少显存峰值;代价是系统复杂,需要工具可视化,否则调试很痛苦。
面试关键句
IMPORTANT
Render Queue 解决物体绘制顺序和状态切换问题,Frame Graph 解决 Pass 依赖和资源生命周期问题。真正的渲染架构通常是 Frame Graph 管一帧的 Pass DAG,每个 Pass 内部再用 Render Queue 排序和提交 DrawCommand。
资源句柄和生命周期管理
一句话定义
资源句柄就是“访问资源的安全钥匙”。业务层不直接拿裸资源或裸指针,而是拿一个 Handle,再通过资源管理器找到真正的资源,这样可以统一控制加载、引用计数、依赖、卸载和失效检查。
底层原理
如果直接把 Texture、Prefab、AssetBundle、UnityEngine.Object 到处传,很容易出现几个问题:谁负责释放不清楚、异步加载没完成就使用、资源被卸载后还持有旧引用、同一个资源被重复加载、切场景时依赖资源提前释放。
所以资源系统通常会维护一张资源表:
c
ResourceHandle -> ResourceRecord -> 真正资源Handle 里一般保存:
id:资源记录在表里的位置generation:版本号,用来判断旧句柄是否失效key/path:资源名或 Addressables keymanager:通过资源管理器访问真实资源
ResourceRecord 里一般保存:
- 真实资源对象
- 加载状态:
Loading / Loaded / Failed / Unloading - 引用计数:
refCount - 依赖列表:比如 Prefab 依赖 Mesh、Material、Texture
- 最近使用时间:用于 LRU 或延迟卸载
- 调试信息:谁加载的、谁没释放
生命周期流程
资源生命周期通常是:
c
LoadAsync -> Acquire -> Use -> Release -> Unload加载时,如果资源已经在加载中,就合并请求;如果已经加载好,就增加引用计数;如果不存在,就创建记录并开始异步加载。
使用时,业务层不直接假设资源一定存在,而是通过句柄检查状态。释放时,refCount--,如果引用计数变成 0,不一定马上卸载,可以放进延迟释放队列,避免刚释放又马上重新加载造成抖动。
依赖资源也要计数。比如一个角色 Prefab 依赖材质,材质依赖贴图。释放 Prefab 时,不能直接把贴图卸掉,要看还有没有其他 Prefab 或 UI 也在用这张贴图。
为什么需要 generation
如果资源表里第 5 个槽位原来是 Player.prefab,后来释放了,又复用这个槽位加载了 Enemy.prefab。
旧句柄如果只保存 id = 5,就可能错误访问到新资源。
所以句柄一般还保存 generation:
c
public readonly struct ResourceHandle<T> where T : class // 定义一个只读资源句柄,T 表示资源类型。
{ // 句柄结构体开始。
public readonly int Id; // 保存资源记录在资源表中的编号。
public readonly int Generation; // 保存资源记录的版本号,用来防止旧句柄误用。
public ResourceHandle(int id, int generation) // 构造句柄时传入编号和版本号。
{ // 构造函数开始。
Id = id; // 记录资源编号。
Generation = generation; // 记录资源版本。
} // 构造函数结束。
} // 句柄结构体结束。面试重点
强句柄会增加引用计数,保证资源不会被卸载;弱句柄不增加引用计数,只能在使用前 TryGet 检查资源是否还活着。
Unity 里可以类比 Addressables 的 AsyncOperationHandle。加载后要保存 handle,释放时调用 Addressables.Release(handle)。如果只保存 asset,不保存 handle,后面就很容易不知道该释放谁。
常见坑
资源加载和释放一定要成对。重复释放要做幂等保护,否则引用计数可能变成负数。
异步加载完成前不能直接使用资源,要等状态变成 Loaded。
释放父资源时不能提前释放依赖资源,要看依赖资源的引用计数。
切场景时不要一边加载新场景一边立刻释放旧场景所有资源,否则可能造成峰值内存或卡顿。更稳的做法是分批卸载、延迟卸载,并在低风险时机调用清理。
面试可以这样说
TIP
我会把资源生命周期收口到资源管理器里,业务层只持有句柄,不直接决定资源何时销毁。资源记录里维护状态、引用计数和依赖关系,释放时先减少引用计数,归零后进入延迟卸载队列。为了防止旧句柄访问新资源,我会给句柄加 generation 校验。这样能避免重复加载、悬空引用、依赖提前释放和切场景峰值内存问题。
Job System 任务依赖
标准答案
Job System 的任务依赖,就是用 JobHandle 描述任务之间的先后关系。它不是简单地“开线程然后等线程”,而是先构建一张任务图:没有数据冲突的 Job 可以并行跑,有读写先后关系的 Job 必须等前面的 Job 完成后再执行。
底层原理
Job 调度后会返回一个 JobHandle。这个 handle 可以理解成“任务完成凭证”。后续任务如果依赖它,就把它传进 Schedule,Unity 的调度器会保证前一个 Job 完成后,后一个 Job 才开始执行。
比如:
MoveJob计算位置SenseJob计算 AI 感知- 这两个 Job 互不冲突,可以并行
ResolveJob要用它们的结果,所以必须依赖它们- 最后主线程
Complete(),再把结果应用到GameObject / Animator / Transform
关键点是:依赖关系越清晰,Unity 越能安全并行;依赖乱写,就容易主线程等待、数据竞争、结果不稳定。
代码示例
c
using Unity.Burst; // 引入 Burst 编译相关能力,让 Job 有机会被高性能编译。
using Unity.Collections; // 引入 NativeArray 等 Unity Job 安全容器。
using Unity.Jobs; // 引入 IJob、IJobParallelFor、JobHandle 等 Job System 类型。
using Unity.Mathematics; // 引入 float3 等数学类型。
[BurstCompile] // 标记这个 Job 可以被 Burst 优化。
public struct MoveJob : IJobParallelFor // 定义一个可以并行处理数组元素的移动 Job。
{ // MoveJob 结构体开始。
public NativeArray<float3> Positions; // 保存所有单位的位置,当前 Job 会写入它。
[ReadOnly] public NativeArray<float3> Velocities; // 保存所有单位的速度,只读可以降低数据竞争风险。
public float DeltaTime; // 保存当前帧的时间间隔,用于帧率无关移动。
public void Execute(int index) // 每个元素都会调用一次 Execute。
{ // Execute 函数开始。
Positions[index] += Velocities[index] * DeltaTime; // 根据速度和时间更新当前位置。
} // Execute 函数结束。
} // MoveJob 结构体结束。
public static class JobDependencyExample // 定义一个演示任务依赖的工具类。
{ // 工具类开始。
public static void ScheduleJobs(NativeArray<float3> positions, NativeArray<float3> velocities, int count, float deltaTime) // 定义调度多个 Job 的方法。
{ // 方法开始。
MoveJob moveJob = new MoveJob { Positions = positions, Velocities = velocities, DeltaTime = deltaTime }; // 创建移动 Job,并传入纯数据。
JobHandle moveHandle = moveJob.Schedule(count, 64); // 调度移动 Job,每批 64 个元素,并得到任务完成凭证。
JobHandle resolveHandle = moveHandle; // 假设后续结算依赖移动结果,这里先保存依赖句柄。
resolveHandle.Complete(); // 主线程真正需要结果时再等待 Job 完成。
} // 方法结束。
} // 工具类结束。Unity 工程实践
我在项目里会尽量“先调度,晚 Complete”。也就是一帧开始先把能并行的 Job 全部 Schedule 出去,中间让工作线程跑,等主线程真的要读取结果或操作 Unity API 时再 Complete()。
Job 里不要直接操作大多数 Unity API,比如 Transform.position、GameObject.SetActive、Animator.SetTrigger。Job 里主要处理纯数据,最后回到主线程把结果同步给表现层。
常见坑
TIP
过早 Complete() 会让主线程提前等待,等于把并行变回同步。
忘记传依赖,可能导致一个 Job 还在写数据,另一个 Job 已经开始读数据。
多个 Job 写同一个 NativeArray,如果没有正确拆分范围或声明依赖,就可能产生数据竞争。
面试里可以这样总结:JobHandle 的价值不是“等一个线程结束”,而是把任务之间的读写关系交给调度器,让 Unity 在保证安全的前提下最大化并行。
C++ 手写 LRU 或线程安全队列
标准答案
这类手写题一般考两种能力:LRU 考“哈希表 + 双向链表”的数据结构组合;线程安全队列考“锁 + 条件变量”的并发边界。面试时如果让我二选一,我会先写 LRU,因为它更高频;如果岗位偏引擎、多线程、网络或日志系统,就很可能追线程安全队列。
LRU 核心代码
c
#include <list> // 使用 std::list 维护从新到旧的双向链表。
#include <unordered_map> // 使用 std::unordered_map 做 key 到链表节点的快速索引。
template <class K, class V> class LruCache { // 定义泛型 LRU 缓存类。
using Node = std::pair<K, V>; // 定义链表节点类型,保存 key 和 value。
std::list<Node> nodes_; // 保存缓存节点,链表头表示最新访问。
std::unordered_map<K, typename std::list<Node>::iterator> index_; // 保存 key 到链表迭代器的映射。
size_t capacity_; // 保存缓存最大容量。
public: // 对外公开接口开始。
explicit LruCache(size_t capacity) : capacity_(capacity) {} // 构造函数初始化容量。
bool Get(const K& key, V& out) { // 查询 key,并通过 out 返回 value。
auto it = index_.find(key); // 在哈希表中查找 key。
if (it == index_.end()) return false; // 如果没找到,说明缓存未命中。
nodes_.splice(nodes_.begin(), nodes_, it->second); // 命中后把节点移动到链表头。
out = it->second->second; // 把节点中的 value 写到输出参数。
return true; // 返回 true 表示命中。
} // Get 函数结束。
void Put(const K& key, const V& value) { // 插入或更新一个缓存项。
if (capacity_ == 0) return; // 容量为 0 时不缓存任何数据。
auto it = index_.find(key); // 先检查 key 是否已经存在。
if (it != index_.end()) { // 如果 key 已经存在。
it->second->second = value; // 更新旧节点里的 value。
nodes_.splice(nodes_.begin(), nodes_, it->second); // 更新后也要移动到链表头。
return; // 已完成更新,直接返回。
} // 已存在分支结束。
if (nodes_.size() >= capacity_) { // 如果缓存已经达到容量上限。
K oldKey = nodes_.back().first; // 取出链表尾部最久未使用节点的 key。
index_.erase(oldKey); // 从哈希表中删除旧 key。
nodes_.pop_back(); // 从链表尾部删除最旧节点。
} // 淘汰逻辑结束。
nodes_.push_front({key, value}); // 把新节点插入链表头。
index_[key] = nodes_.begin(); // 在哈希表中记录新节点的位置。
} // Put 函数结束。
}; // LruCache 类结束。线程安全队列核心代码
c
#include <condition_variable> // 使用条件变量让消费者在队列为空时睡眠。
#include <mutex> // 使用互斥锁保护共享队列。
#include <queue> // 使用 std::queue 保存任务或消息。
#include <utility> // 使用 std::move 减少不必要拷贝。
template <class T> class ThreadSafeQueue { // 定义泛型线程安全队列。
std::queue<T> queue_; // 保存真实数据队列。
std::mutex mutex_; // 保护 queue_ 和 closed_ 的互斥锁。
std::condition_variable cv_; // 用于阻塞等待和唤醒消费者。
bool closed_ = false; // 标记队列是否已经关闭。
public: // 对外公开接口开始。
void Push(T value) { // 生产者向队列中放入数据。
{ // 创建一个局部作用域,让锁尽早释放。
std::lock_guard<std::mutex> lock(mutex_); // 加锁保护共享队列。
if (closed_) return; // 队列关闭后不再接收新数据。
queue_.push(std::move(value)); // 把数据移动进队列。
} // 离开作用域后自动解锁。
cv_.notify_one(); // 唤醒一个正在等待的消费者。
} // Push 函数结束。
bool WaitPop(T& out) { // 消费者阻塞等待并取出数据。
std::unique_lock<std::mutex> lock(mutex_); // 加锁,并允许条件变量临时释放锁。
cv_.wait(lock, [this] { return closed_ || !queue_.empty(); }); // 等到关闭或队列非空。
if (queue_.empty()) return false; // 如果队列为空,说明是关闭导致醒来。
out = std::move(queue_.front()); // 移动队首元素到输出参数。
queue_.pop(); // 删除已经取出的队首元素。
return true; // 返回 true 表示成功取到数据。
} // WaitPop 函数结束。
void Close() { // 关闭队列并唤醒所有等待线程。
{ // 创建局部作用域控制锁生命周期。
std::lock_guard<std::mutex> lock(mutex_); // 加锁修改关闭标记。
closed_ = true; // 设置队列已经关闭。
} // 离开作用域后自动解锁。
cv_.notify_all(); // 唤醒所有等待中的消费者。
} // Close 函数结束。
}; // ThreadSafeQueue 类结束。面试关键点
LRU 的关键是:unordered_map 负责 O(1) 找节点,list 负责 O(1) 移动节点和删除尾节点。命中要移动到头部,更新已有 key 也要移动到头部,容量满了淘汰尾部。
线程安全队列的关键是:所有共享数据必须在锁内访问,wait 必须带谓词,防止虚假唤醒。notify_one 最好在释放锁后调用,减少被唤醒线程又立刻抢锁失败的概率。
游戏场景
NOTE
LRU 可以用于贴图、音频、配置表、寻路结果缓存。线程安全队列可以用于日志上报、网络消息分发、资源加载任务、主线程回调队列。