内存管理
掌握 Mono/IL2CPP、GC 分配、对象池与纹理/网格内存,排查泄漏与峰值。
学习目标
- 能解释 Mono 与 IL2CPP 运行时的内存模型差异,以及 IL2CPP 为何是移动端的默认选择
- 能使用 Memory Profiler 定位 GC.Alloc 热点,并用对象池、结构体替代或缓存引用等手段消除
- 能回答:场景切出后再切回,纹理内存翻了一倍——是 Read/Write 没关、AssetBundle 未 Unload、还是静态引用未清除?
原书边界:Chapter 8
Packt 第三版把本章命名为 Masterful Memory Management。官方目录列出的核心范围如下;这些条目共同构成本章的一对一边界:
- The Mono platform:纳入本章实验与验收,不拆到别章重复计数。
- Code compilation:纳入本章实验与验收,不拆到别章重复计数。
- Profiling memory:纳入本章实验与验收,不拆到别章重复计数。
- Memory management performance enhancements:纳入本章实验与验收,不拆到别章重复计数。
区分托管堆、原生对象、显存、临时分配与资产引用链,分别测驻留量、分配率、峰值和回收停顿。IL2CPP、增量 GC 与新版 Memory Profiler 改变工具和停顿形态,但装箱、闭包、字符串、对象池和引用生命周期仍需证据。 因此,本章既保留 2019/2020 语境,也明确标出迁移到现代 Unity 时哪些是稳定原理、哪些只是版本相关接口。
内存是隐形的天花板
上一章你对 GPU 帧预算数着毫秒省,帧率稳住了。可玩家切了几个场景后——游戏闪退了。打开 Memory Profiler,纹理几十兆、Animation 也占了一大片,GC.Collect 每隔几秒就来一次,帧时间图上锯齿扎眼。
渲染优化让你的画面跑得足够快,内存管理让游戏跑得足够久。没有它,再流畅的画面也会在「切关那一帧」卡死或被系统 OOM 杀掉。这章解决的核心问题:每一帧 new 了谁、栈上还是堆上、什么时候回收、谁还拽着不放。
像图书馆:书只能借,不能撕
想象一座图书馆。每次「要一本书」——你跑印刷厂排一本新的(= new / Instantiate),看完扔掉、次日又来排一本同名的——印刷厂排队的成本远超借书本身。
Unity 托管堆就是图书馆书架:书(对象)有借有还(GC 回收),但还书前占据书架空间。对象池相当于「把热门书复印若干本锁在柜台」——借时取、用完还,不再进印刷厂。内存泄漏则是书被借走但永远不还——书架渐渐堆满旧书,新书无处置放。
Mono vs IL2CPP:托管内存跑在哪
Unity 有两套脚本运行时,它们对内存从哪里来、何时回收有根本区别。
↡基于 Mono 的 .NET 运行时,运行时代码 JIT 编译为机器码,GC 使用 Boehm 保守式回收器。 曾是 Unity 的默认脚本后端。C# 编译为 IL、运行时由 JIT 即时翻译成机器码;GC 是经典的 ↡Boehm-Demers-Weiser 保守式垃圾回收器,Stop-The-World 扫描所有内存,不可预测暂停时间。 ——每次回收全线程暂停(Stop-The-World),扫描所有托管对象,甚至因「保守式扫描」可能误判某些值为指针而不回收它们。
↡将 IL 中间码 AOT 编译为 C++ 再编译为原生代码,iOS 强制、移动端首选;GC 可选增量模式。 是 Unity 当前的默认后端。构建时 IL → C++ → 平台原生代码 AOT,运行时不再有 JIT 开销;GC 可选 增量模式,分步执行而非一次性全暂停。
为什么移动端只用 IL2CPP
iOS App Store 禁止 JIT(代码段不可写),Mono 直接不可用。Android 上 IL2CPP 的 AOT 省去 JIT 预热与内存;增量 GC 比 Boehm 更可预测帧时间。桌面/编辑器仍可能用 Mono,但打包移动端和 Console 必须 IL2CPP。
GC:分配、标记、清除
无论 Mono 还是 IL2CPP,托管堆的 GC 逻辑大同小异:对象在托管堆分配 → 堆满触发 GC → 标记存活对象 → 清除未引用的 → 整理碎片。
为什么每帧 new 一个 List 是灾难
// ❌ 每帧产生 GC.Alloc
void Update() {
var hits = new List<RaycastHit>(Physics.RaycastAll(ray));
// ... 用完 hits 被丢弃 → 下次 GC 回收
}RaycastAll 返回的 RaycastHit[] 和 new List<RaycastHit> 都是托管堆分配。60 FPS 下每秒 120 次分配——GC.Collect 大约几百 KB 触发,几秒就一次全暂停。
// ✅ 缓存 + NonAlloc API
private RaycastHit[] m_Hits = new RaycastHit[32];
void Update() {
int count = Physics.RaycastNonAlloc(ray, m_Hits);
for (int i = 0; i < count; i++) { /* 使用 m_Hits[i] */ }
}RaycastNonAlloc 写入预分配的 m_Hits 数组,零托管分配。List<T> 可用 .Clear() 复用而非重新 new;字符串拼接换 StringBuilder(缓存实例并每次 Clear())。
对象池:一次预热,零分配
↡预先创建固定数量的对象并放在池中,用 Get/Return 代替 Instantiate/Destroy,消除运行时托管堆分配。 是对「不停 new → GC 回收 → 再 new」的根本解法:一次预热,此后 Get 与 Return 零分配。
通用对象池骨架
public class ObjectPool<T> where T : Component {
private Queue<T> m_Pool = new Queue<T>();
private T m_Prefab;
public ObjectPool(T prefab, int prewarm) {
m_Prefab = prefab;
for (int i = 0; i < prewarm; i++) AddToPool();
}
void AddToPool() {
var obj = Object.Instantiate(m_Prefab);
obj.gameObject.SetActive(false);
m_Pool.Enqueue(obj);
}
public T Get() {
T obj = m_Pool.Count > 0 ? m_Pool.Dequeue() : Object.Instantiate(m_Prefab);
obj.gameObject.SetActive(true);
return obj;
}
public void Return(T obj) {
obj.gameObject.SetActive(false);
m_Pool.Enqueue(obj);
}
}预热 N 个实例,Get() 取活跃、Return() 退回池——二者均为 O(1)。若池耗尽,自动 Instantiate 扩容;Return 过多按需缩容或设上限。
什么应该进池
必须进池:子弹/弹幕、粒子 (ParticleSystem)、敌人/NPC、UI 列表项。这些每帧大量创建/销毁,不进池 = 每帧几十次 GC.Alloc。
不需要进池:场景唯一的主角、UI 主面板、背景音乐源——生命周期与关卡等长、创建一次用完即弃。
Unity 2021+ 内置 UnityEngine.Pool.ObjectPool<T>(IObjectPool<T> 接口),集成了 Get/Release 与自动扩容缩容,推荐优先使用。
纹理与网格内存
美术资源导入时的开关——Read/Write、MipMap、Max Size、Compression——直接决定运行时内存翻几倍。
↡导入时是否在内存保留 CPU 可写副本;开启后纹理内存约翻倍,仅运行时需改像素时开启。
保留一份 CPU
可写像素副本,内存约为不开启时的两倍。纯渲染纹理应关闭——只有运行时
Texture2D.SetPixels、ReadPixels 或部分后处理才需要。
↡预生成逐级缩小的纹理副本供远处物体采样,约增加 33% 内存;3D 场景纹理常开启,UI 和 Sprite 常关闭。 对远处物体减少闪烁并降低显存带宽,但额外占用约 33% 内存。UI、Sprite Atlas 平面元素不需要 mipmap。
↡导入时 Mesh 数据在 CPU 侧的可读副本;运行时仅渲染的网格应关闭,内存约减半。
同样适用:除运行时改 mesh.vertices 的网格外,一律关闭。MeshCollider
烘焙完成后也应关闭。
动手:内存泄漏五步排查
下面走通「内存持续增长 → 定位保留对象 → 找出谁拽着不放」的全流程。每步配示意图示——建议打开 Memory Profiler + 一个「切关后内存不降」的场景同步操作。
猜一猜:切出 MainMenu、再切回 GameScene,纹理内存翻倍——是 AssetBundle 未 Unload、Read/Write 没关、还是静态事件未注销?
① Memory Profiler 快照:找谁在涨
在「离开关卡」前后各做一次 Capture(Memory Profiler → Take Snapshot),对比 "New Objects" 列表。按 Managed Object Count 排序——切关后仍存在的物体是泄漏嫌疑。
容易踩的坑
小结
- Mono JIT + Boehm GC(全暂停)vs IL2CPP AOT + 增量 GC——移动端必须 IL2CPP
- GC 三阶段 Mark → Sweep → Compact;每帧
new临时对象 = GC.Alloc 热点 → 帧时间抖动 - 对象池 预热后 Get/Return 零分配;子弹/粒子/敌人类必须进池,Unity 2021+ 内置
ObjectPool<T> - 纹理/网格:Read/Write 默认关、3D 纹理开 mipmap/UI 关、Max Size 按用途设、平台选 ASTC/ETC2
- 泄漏排查:Memory Profiler 快照对比 → 静态引用/事件 → AssetBundle Unload → 对象池归还
练习
问题 1(改代码型) 在你的项目中找一个 Update 里 new List 或字符串拼接的脚本。用 Profiler 记录改前改后的 GC.Alloc——把 List 换字段复用 + .Clear(),字符串换 StringBuilder 缓存,看分配量下降多少。
问题 2(问答型) IL2CPP 相比 Mono,除 AOT 编译外,在 GC 行为上有什么关键差异?为什么移动端帧率对 GC 暂停更敏感?
问题 3(实战型) 写一个简单的 BulletPool(单个 Queue<Bullet>,预热 20 枚)。Get() 激活子弹并发射,Return() 在子弹飞出屏幕或命中后回收。对比 Instantiate/Destroy 版本的 Profiler 差异。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- Mono
Unity 旧版 .NET 脚本后端:运行时 JIT 编译,Boehm GC 保守式回收。iOS 不可用,移动端已淘汰。详见 MonoIl2cppRuntimeDiagram 左侧。
- IL2CPP
Unity 当前默认脚本后端:构建时 IL→C++→AOT 编译,GC 可选增量模式。iOS 强制,移动端首选。详见 MonoIl2cppRuntimeDiagram 右侧。
- Boehm GC
Mono 默认的保守式 GC,Stop-The-World 全暂停扫描所有托管内存,暂停不可预测。
- GC(垃圾回收,Garbage Collection)
托管堆上不再被任何引用链到达的对象被标记为垃圾并回收。三阶段:Mark 标记存活、Sweep 清除不可达、Compact 整理碎片。
- GC.Alloc
Profiler 中记录托管堆分配总量的计数器。每帧 create new 对象 = GC.Alloc 上涨 → 累积触发 GC.Collect → 帧时间尖峰。
- 对象池(Object Pooling)
预热创建 N 个对象,后续 Get/Return 替代 Instantiate/Destroy,消除运行时堆分配。详见 ObjectPoolDiagram。
- Texture Read/Write Enabled
纹理导入时保留 CPU 可写像素副本,内存约为不开启的 2 倍。纯渲染纹理应关闭。详见 TextureImportDiagram。
- Generate Mip Maps
预生成逐级缩小纹理副本供远处采样,约增 33% 内存。3D 场景纹理常开,UI/Sprite 常关。详见 TextureImportDiagram highlight mipmap。
- Mesh Read/Write Enabled
网格导入时保留 CPU 可写顶点副本,非运行时改顶点的网格一律关闭。详见 Ch4 美术资源优化。
- Memory Profiler
Unity 内存快照工具(Package Manager 安装),可对比两时刻的物体数、Managed/ Native 内存、看引用链定位谁拽着对象不放。