内存管理

掌握 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 有两套脚本运行时,它们对内存从哪里来、何时回收有根本区别。

曾是 Unity 的默认脚本后端。C# 编译为 IL、运行时由 JIT 即时翻译成机器码;GC 是经典的 ——每次回收全线程暂停(Stop-The-World),扫描所有托管对象,甚至因「保守式扫描」可能误判某些值为指针而不回收它们

是 Unity 当前的默认后端。构建时 IL → C++ → 平台原生代码 AOT,运行时不再有 JIT 开销;GC 可选 增量模式,分步执行而非一次性全暂停。

Mono vs IL2CPP 运行时MonoC#/IL → 运行时 JIT 编译Boehm GC(保守式)Stop-The-World · 不可预测暂停托管堆(Managed Heap)对象分配频繁触发 GC 扫描兼容好 · 启动快 · 移动端已废弃IL2CPPIL → C++ → AOT 静态编译增量 GC(可选)分步执行 · 可预测暂停本机堆(Native Heap)C++ 对象 · 手动/GC 混合管理iOS 必须 · 性能更好 · 内存更紧VS
Mono 运行时 JIT 编译 + Boehm GC,暂停不可预测;IL2CPP AOT 编译为 C++,GC 可选增量模式,内存更紧但性能更优。iOS 强制 IL2CPP。

为什么移动端只用 IL2CPP

iOS App Store 禁止 JIT(代码段不可写),Mono 直接不可用。Android 上 IL2CPP 的 AOT 省去 JIT 预热与内存;增量 GC 比 Boehm 更可预测帧时间。桌面/编辑器仍可能用 Mono,但打包移动端和 Console 必须 IL2CPP。

GC:分配、标记、清除

无论 Mono 还是 IL2CPP,托管堆的 GC 逻辑大同小异:对象在托管堆分配 → 堆满触发 GC → 标记存活对象 → 清除未引用的 → 整理碎片

GC 分配与回收全流程托管堆new Object() 分配在此持续分配,堆逐渐填满堆满GC 三阶段① Mark 标记所有可达对象② Sweep 回收不可达对象③ Compact 整理碎片化内存GC 后仅存活对象保留Profiler 中的 GC 热点GC.Alloc ↑ 频繁 newGC.Collect 偶尔触发对象池化后 GC 平稳每帧 new 大量临时对象 → GC.Alloc 飙升 → 触发 GC.Collect → 帧时间抖动解法:对象池 · 结构体替代类 · 缓存引用 · 拆大对象为值类型
托管堆持续分配 → 堆满触发 GC Mark·Sweep·Compact → 暂停帧。Profiler 中 GC.Alloc 居高是对象池化的信号。

为什么每帧 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())。

对象池:一次预热,零分配

是对「不停 new → GC 回收 → 再 new」的根本解法:一次预热,此后 Get 与 Return 零分配

对象池(Object Pooling)✗ 无对象池Instantiate()使用Destroy()每帧创建/销毁 — GC 压力大重复✓ 对象池预热:预创建 N 个对象Get使用Return池中空闲: 3/10 · 活跃: 7 · 自动扩容零 new · 零 GC.Alloc · 帧率稳定预热 运行时无分配 · Get/Return 均 O(1) · 适合 弹幕/粒子/敌人/UI 项对象池不是万能:大场景永久对象、唯一 UI 控件不需要池 —— 只为"重复创建/销毁"而生
无池:每帧 Instantiate + Destroy 产生大量 GC.Alloc;有池:一次预热后 Get → Use → 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 可写像素副本,内存约为不开启时的两倍。纯渲染纹理应关闭——只有运行时 Texture2D.SetPixelsReadPixels 或部分后处理才需要。

对远处物体减少闪烁并降低显存带宽,但额外占用约 33% 内存。UI、Sprite Atlas 平面元素不需要 mipmap。

同样适用:除运行时改 mesh.vertices 的网格外,一律关闭MeshCollider 烘焙完成后也应关闭。

Texture Import SettingsFormat(平台)RGBA324 B/pxASTC 6×6~0.36 B/pxMax Size4096源图 4K1024UI 图标够用Generate Mip Maps远处用低级 · +33% 内存1024² 纹理内存(含 mipmap ≈×1.33)RGBA32 ~5.3 MBASTC ~0.5 MB规则:按用途设 Max Size · 按平台选压缩格式 · 3D 场景纹理开 mipmap,UI/Sprite 常关源文件 4K 不代表运行时必须 4K——导入设置才是内存真相
纹理内存由格式 × 分辨率 ×(mipmap 约 1.33)决定;按平台选 ASTC/ETC2,按用途限制 Max Size。
Texture Import SettingsGenerate Mip Maps远处用低级 · +33% 内存1024² 纹理内存(含 mipmap ≈×1.33)RGBA32 ~5.3 MBASTC ~0.5 MB规则:按用途设 Max Size · 按平台选压缩格式 · 3D 场景纹理开 mipmap,UI/Sprite 常关源文件 4K 不代表运行时必须 4K——导入设置才是内存真相
纹理内存由格式 × 分辨率 ×(mipmap 约 1.33)决定;按平台选 ASTC/ETC2,按用途限制 Max Size。

动手:内存泄漏五步排查

下面走通「内存持续增长 → 定位保留对象 → 找出谁拽着不放」的全流程。每步配示意图示——建议打开 Memory Profiler + 一个「切关后内存不降」的场景同步操作。

猜一猜:切出 MainMenu、再切回 GameScene,纹理内存翻倍——是 AssetBundle 未 Unload、Read/Write 没关、还是静态事件未注销?

分步1 / 5

① Memory Profiler 快照:找谁在涨

GC 分配与回收全流程托管堆new Object() 分配在此持续分配,堆逐渐填满堆满GC 三阶段① Mark 标记所有可达对象② Sweep 回收不可达对象③ Compact 整理碎片化内存GC 后仅存活对象保留Profiler 中的 GC 热点GC.Alloc ↑ 频繁 newGC.Collect 偶尔触发对象池化后 GC 平稳每帧 new 大量临时对象 → GC.Alloc 飙升 → 触发 GC.Collect → 帧时间抖动解法:对象池 · 结构体替代类 · 缓存引用 · 拆大对象为值类型
托管堆持续分配 → 堆满触发 GC Mark·Sweep·Compact → 暂停帧。Profiler 中 GC.Alloc 居高是对象池化的信号。

在「离开关卡」前后各做一次 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(改代码型) 在你的项目中找一个 Updatenew 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 内存、看引用链定位谁拽着对象不放。

资料与写作方式声明

本章以Packt《Unity Game Optimization》第3版权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…