UPL11——内存管理

垃圾回收时机

首先,我们应该尽量避免垃圾回收,因为垃圾回收将带来极大的 CPU 开销
但是如果我们不得不进行垃圾回收时,都建议在合适的时机手动进行,降低玩家的感知度

比如:

  1. 游戏暂停时
  2. 游戏过场景时
  3. 游戏显示读条界面时

等等

总之,需要在玩家察觉不到或不关心突然的性能下降而打断游戏行为时手动调用垃圾回收

结构体参数传递时的考虑

由于值传递和引用传递的区别,导致如果大结构体在函数之间传递时比起引用传递是更耗性能的
因此在进行大型结构体数据传递时,我们可以权衡是否使用 ref 传递

注意,如果希望函数内部对结构体对象的修改不影响外部,则不能使用 ref 传递

用 StringBuilder 替代 String

只要你要对字符串做大量修改,比如追加、拼接、替换等等
并且循环次数不确定或较大,就必须用 StringBuilder

举例:

1
2
3
4
5
string s = "";
for (int i = 0; i < 10000; i++)
{
s += i; // 糟糕
}

如果一个字符串频繁变化,使用 string​ 去存储它,那么在变化的过程中会产生很多垃圾
由于 string​ 的规则(string​ 对象不可变,新字符串会生成新对象),每次变化都会分配新内容,拷贝旧内容,产生内存垃圾
10000 次循环,就是 10000 次分配和拷贝,以及垃圾的产生,而如果我们使用 StringBuilder

1
2
3
4
5
6
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
sb.Append(i);
}
s = sb.ToString();

使用它不仅可以减少性能消耗,还可以减少垃圾的产生

使用准则:

StringBuilder 的一般情况:

  1. 多次循环拼接(成千上万)
  2. 大量 Replace(替换)
  3. 不确定长度的输入流,可能频繁分配时
  4. 记录日志信息时

string 的一般情况:

  1. 字符串常量
  2. 几次简单拼接
  3. 少量参数的 Format 拼接

避免装箱拆箱

C# 中一切皆对象,万物之父都是 System.Object

装箱:

1
2
int x = 10;
object obj = x;

把一个值类型(int​、float​、struct​)放进一个 object​ 变量
值类型在栈上,引用类型(包括 object​)在堆上
所以当值类型要被当成 object 使用时,就需要创建一个新的堆对象,把值类型的值拷贝进去
就好像,做一个盒子,把值类型放进去的感觉,所以称为装箱

主要成本:
此时 obj​ 指向堆,10 被赋值到堆中,原本的 x 仍在栈中
整个过程创建了一个新的堆对象(需要 GC 管理),成本很高,会给 GC 带来压力

拆箱:

1
2
3
int x = 10;
object obj = x;
int y = (int)obj; // 拆箱

object 再提取出值类型,拆箱不是取值,而是先检查类型,再从堆对象里拷贝出内部值

主要成本:类型检查和值拷贝回栈,成本消耗中等

如何避免装箱拆箱:

  1. 使用泛型

  2. 正确使用内置 API

    1
    2
    Debug.Log("Frame " + Time.frameCount);             // 无装箱
    Debug.LogFormat("Frame {0}", Time.frameCount); // 装箱,因为 LogFormat 的变长参数类型为 object[]

等等

数据内存布局的重要性

假设定义了如下的类和结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Lesson108Test
{
int myInt;
float myFloat;
bool myBool;
string myString;
}

public struct Lesson108TestStruct
{
int myInt;
float myFloat;
bool myBool;
string myString;
}
  • 方式一

    1
    Lesson108Test[] array = new Lesson108Test[1000];

    内存布局是不连续的,极度分散的,因为引用在堆上的位置是随机的

    特点:

    1. CPU 的缓存命中率是极差的
    2. 堆上分配,需要 GC 管理
    3. 每次访问元素都需要指针定位追逐
    4. 内存占用也最大,有对象头
  • 方式二

    1
    Lesson108TestStruct[] array2 = new Lesson108TestStruct[1000];

    特点:

    1. 缓存命中是友好的
    2. 遍历是快速的
    3. 内存占用齐次,需要内存对齐
  • 方式三

    1
    2
    3
    4
    int[] myInt = new int[1000];
    float[] myFloat = new float[1000];
    bool[] myBool = new bool[1000];
    string[] myString = new string[1000];

    特点:

    1. 每个字段都是完全连续的
    2. CPU 缓存命中非常好,并且会缓存对齐
    3. 是性能最强的数据布局方式
    4. 内存占用最少

注意:不要一上来就上数据数组,它不是日常写法,只是用来优化特定情况的写法
日常开发时,能用 class​ 就先用 class​,性能不够时,先换成 struct,再不够,再对最核心那段逻辑引入数据数组

Unity API 中的数组

Unity API 中很多指令会导致堆内存分配,比如:

  1. GetComponents<T>()
  2. Mesh.vertices
  3. renderer.materials
  4. Camera.allCameras
  5. Physics.RaycastAll()

等等

使用这些 API 时 Unity 内部会分配该数组的全新数组对象
我们应该避免使用,或者使用时将数据缓存,而不是频繁调用这些 API 获取数据

InstanceID 的妙用

对于继承自 MonoBehaviour​ 或 ScriptableObject​ 的对象,都可以调用 GetInstanceID()​ 方法来获取对象的唯一 ID
该 ID 在 Unity 中用于表示某一个对象的唯一标识值,在整个生命周期中不会发生变化,也不会出现两个对象重复的情况
因此我们可以用它来做 ID,比如字典的键,对象的唯一 ID 等等
但是 GetInstanceID() 方法会有开销,建议初始化时获取一次缓存下来

1
2
3
4
5
6
public int id;

void Start()
{
id = this.GetInstanceID();
}

foreach 的使用

在早期的版本中,Unity 2018 以前,用 foreach​ 遍历 Transform​ 会导致每次循环分配堆内存,产生内存垃圾
但是在现今版本的 Unity 中,已经修复了该问题,我们在使用 foreach 遍历时只要遵守以下规则即可:

  1. 可以放心用 foreach​ 遍历数组、List​、Dictionary

  2. 仅在遇到这几种情况要提高警惕(特别是老版本 Unity)

    • foreach (Transform child in transform)
    • foreach (var x in 某个Unity自己暴露的集合)

    这些最好在 Profiler 里看一眼 GC Alloc,如果有,就换成 for 或改迭代器实现

总之:别再用一律不用 foreach 这种老规矩,现在版本的 C#、Unity 都已经优化了很多,用 Profiler 判断哪里有 GC,再精确优化哪里

协程的合理使用

启动一个协程会消耗少量内存,如果内存消耗和 GC 是严重问题
可以尝试避免产生太多短时间的协程,避免在运行时大量调用 StartCoroutine
因为一个协同程序,在底层本质上是一个状态机对象

举例:

1
2
3
4
5
IEnumerator MyCoroutine()
{
yield return new WaitForSeconds(1f);
Debug.Log("Done");
}

编译器实际上会把它翻译成一个隐藏的类,类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyCoroutine_d__0 : IEnumerator
{
int _state;
object _current;
MonoBehaviour _this; // 捕获外部 this

public bool MoveNext()
{
switch (_state)
{
case 0:
_state = 1;
_current = new WaitForSeconds(1f);
return true;
case 1:
_state = -1;
Debug.Log("Done");
return false;
}
return false;
}
}

当我们调用 StartCoroutine​ 时,实际上是 new​ 了一个堆对象,然后交给 Unity 的协程调度器去管理
而且当我们在协程中使用 yield​ 时,经常也会使用一些 new 对象的操作,比如:

1
yield return new WaitForSeconds(1f);

这时也可能会 new 堆对象产生内存分配

谨慎使用闭包

闭包会捕获外部变量生成隐藏类,容易无意中延长对象生命周期、引发内存泄漏、增加 GC、造成 bug
因此在 Unity 中性能敏感场景需谨慎使用

举例:

1
2
int hp = 100;
button.onClick.AddListener(() => Debug.Log(hp));

编译器变成:

1
2
3
4
5
6
class DisplayClass {
public int hp;
}

var dc = new DisplayClass { hp = 100 };
button.onClick.AddListener(dc.Method);

减少 Linq 和正则表达式的使用

Linq(Where()​, Select()​, ToList()​, OrderBy() 等)带来的问题主要有三个

  1. 大量隐式对内存分配

    Linq 使用了迭代器、委托、闭包、延迟执行等机制

    比如:

    1
    var aliveEnemies = enemies.Where(e => e.hp > 0);

    这里会:生成一个迭代器对象(class​),生成一个委托
    可能生成中间 IEnumerable​ 对象,最终遍历时还会 new Enumerator 对象
    每一步都可能分配堆对象,产生垃圾,造成GC频繁触发

  2. 性能不透明(每帧执行成本难预测)

    Linq 的内部写法是高抽象的,每个操作都包含方法调用链、delegate 调用
    对 JIT / IL2CPP 来说优化难度大(IL2CPP 转 C++ 之后反而更慢)

  3. 链式调用会产生多个临时 Enumerator / 集合对象

    比如:

    1
    var result = list.Where(...).Select(...).ToList();

    这一条就可能创建:

    1. 一个捕获闭包对象
    2. 一个 WhereIterator(位置迭代器)
    3. 一个 SelectIterator(选择迭代器)
    4. 最终一个 List<T>​(ToList​ 必然 new

    对大量数据来说十分昂贵

所以虽然 Linq 写法优雅、看起来短,但 CPU 和 GC 成本极高,不要在性能敏感逻辑使用(Update​ / FixedUpdate / AI / 物理)

正则表达式的问题主要是:

  1. 正则表达式运行成本极高:解释器 + 状态机
  2. 正则太慢,Unity 性能敏感逻辑里的大忌
  3. 正则本身复杂,容易隐藏性能问题

因此我们应该尽量不用它们,或者尽量少用它们

对象池

对象池(缓存池)是内存性能优化中必不可少的手段,是所有项目中都应该有的功能
对象池实现具体可见:UFL3——缓存池(对象池)模块

集合(List、Dictionary)的容量与临时分配

  1. List​ / Dictionary 频繁扩容会产生很多小垃圾

    new List<int>();​ 默认容量很小,Add 多了就不断扩容、拷贝、产生垃圾旧数组

    建议:

    1. 已知大概元素数量时,提前指定容量:new List<int>(预计数量);
    2. 对于长期存在的容器,用 Clear()​ 重用,而不是每次都 new​ 一个新的 List
    3. 对于只在函数内部用一次的超大 List,不要泄露到静态字段,避免长期占内存
  2. Dictionary<K,V> 的注意点

    1. 同样尽量在初始化时给个合适的容量 capacity,避免扩容时大拷贝
    2. 作为 Key​ 的结构体要保证是小而稳定的值类型,避免因为 hash 不稳定产生隐藏 bug
    3. 不要在 foreach(Dictionary) 的同时修改移除元素,容易导致报错异常或额外分配
  3. 避免频繁使用丢弃

    比如:每帧 new List​ 或 new Dictionary​ 收集一些数据、用完就丢
    这种场景适合用:对象池 配合 Clear 重用容器,或者共享静态缓存

静态引用、单例与事件退订

  1. 静态字段、单例是根引用,只要它们持有引用,对象就不会被 GC

    常见问题:

    1. 单例上有成员变量引用了场景上的各种对象

    2. 各种管理器里的静态字典里引用了大量资源、对象

      场景切换后不去清空或释放它们,则仍然会持有旧场景对象,造成内存泄漏

  2. C# 事件的退订

    如果使用了事件监听机制:SomeManager.OnEvent += Handle;
    而没有在对象移除后去移除事件监听:SomeManager.OnEvent -= Handle;
    那么管理器会一直持有这个 MonoBehaviour 的委托引用,会导致对象不会被 GC,造成内存泄漏
    这和闭包泄漏类似,但即便不用闭包,事件本身也会造成泄漏

建议:

  1. 所有订阅到 长生命周期单例、静态事件 的监听者,都要在 OnDisable​、OnDestroy 中退订。
  2. 定期检查单例中维护的列表、字典,在场景切换时清理掉已经无效的引用

资源加载与释放

脚本层的 GC 只是内存的一部分,Unity 中大头往往是:贴图、网格、音频、动画等资源占用。

  1. Resources​、Addressables、AB 包加载的资源,用完要记得释放:

    1. Resources.Load 的资源:

      • 如果只加载一次、全局共用,可以常驻
      • 如果是临时使用,卸载时可以用:Resources.UnloadAsset(obj);
    2. Resources.UnloadUnusedAssets();

      会扫描没有引用的资源并卸载,比较重,可能造成卡顿,一般放在:切场景、读条时调用。

    3. Addressables 和 AB 包:

      加载后,在不需要使用时记得释放,否则内存一直不降

  2. 运行时创建的贴图、网格、材质等:

    new Texture2D()​、new Mesh()​、new Material()​ 这些都是占内存的原生资源对象,用完后要显式 Destroy 销毁掉

  3. 避免隐式实例化材质

    renderer.material​ 每访问一次,都可能从 sharedMaterial 克隆出一个实例

    建议:公共材质用 sharedMaterial​、sharedMaterials
    只有确实要对这个对象单独改材质时,才访问 material 并缓存下来

大数组与缓冲区复用

  1. 大数组 (>85K bytes) 在 .NET、Mono 中会进入 LOH (大对象堆),GC 成本更高

    比如:new byte[1000000]​ 每次都会分配一个大块内存,高频 new 大数组会导致:

    • 内存碎片
    • GC 停顿变长
  2. 优化方式:

    1. 复用大数组:把 byte[]​、float[] 做成缓冲池(类似对象池)。

    2. 对频繁使用的临时 buffer​,使用静态缓存 + Clear​,而不是反复 new

    3. .NET 标准库中有 ArrayPool<T>.Shared(Unity 较新版本 / IL2CPP 可用时可考虑)

      它是 .NET 里全局共享、线程安全、可重复利用的数组仓库(池子)的单例实例

Native、非托管资源的释放

在 DOTS、Jobs、ComputeShader 等场景中,很多内存不在托管堆上,GC 管不着,必须手动释放

常见例子:

  1. NativeArray<T>​、NativeList<T>​、NativeHashMap<,>

    用完必须调用 Dispose(),否则会产生原生内存泄漏

  2. ComputeBuffer​、GraphicsBuffer​、RenderTexture

    用完必须 Release()​、Destroy(),否则显存、内存持续上涨

  3. GCHandle.Alloc 固定托管对象

    记得 Free(),否则影响 GC 压缩与内存回收

建议:把这些 “需要手动释放” 的资源,封装成可管理的对象,在 OnDestroy​、Dispose​ 中统一释放
对于 Job、Native 容器,可以用 using​ 或 try​、finally​ 保证异常时也会 Dispose

GC 模式与增量 GC 设置

  1. Unity 支持增量 GC (Incremental GC)

    可以在 Player Settings -> Other Settings -> Configuration(配置) -> Use incremental GC 中开启

    image

    将一次性长时间的 GC 拆成多帧执行,降低单帧卡顿的峰值,
    虽然总CPU时间略增,但体验更稳定,适合大型项目、移动端

  2. 脚本里手动 GC.Collect 的使用原则:

    只在玩家不在意卡顿的时候调用(切场景/读条/暂停)
    在启用增量 GC 时,手动 Collect 也会按照增量模式慢慢做完,仍要注意时机

  3. 避免与内存优化互相打架

    比如在频繁加载、卸载资源时,不要频繁手动 GC.Collect,否则 CPU 会被榨干。
    正确做法是:一段集中释放资源后,在安全节点触发一次 GC,而不是每次小释放都 GC

诊断与工具

  1. 所有理论上的内存优化最后都应该回到数据上验证:

    • Unity Profiler:查看 GC Alloc、Total Used Memory、Texture、Mesh、Audio 占用
    • Memory Profiler Package:拍快照比较两次快照之间的差异,定位泄漏对象
  2. 常用检查思路:

    切场景前后各拍一张快照,看哪些对象、资源没有被释放
    跑一段时间 (比如 5 分钟) 后拍快照,看内存是否持续上涨 (疑似泄漏或未释放资源)

  3. 把用 Profiler 确认是否GC、是否泄漏当成规范:

    不要只凭经验这个 API 可能有 GC,而是去 Profiler 里确定,避免过度优化或优化错方向

DOTS 系统

Unity 中的 DOTS(数据导向的技术栈:Data-Oriented Tech Stack)系统是一套专门为了把 CPU、内存效率压榨到极致的开发方式 + 工具集合
是用来做高性能、大规模对象、可并行游戏逻辑的,它的三大核心是:ECS + Job System + Burst

它可以帮助我们:

  1. 极大提升 CPU 性能和可拓展性
  2. 更高效的内存布局和更少 GC 压力
  3. 更好的多线程利用
  4. 让数据驱动设计成为主要开发方式

当我们有以下需求时,可以考虑使用它来进行开发

  1. 海量对象

    RTS、城市模拟、集群式 AI 等等

  2. 大量重复计算

    自定义物理、布料实现、软体、绳子物理表现等

  3. CPU 是性能瓶颈时

    比如主线程压力非常大、GC 非常频繁,但是 GPU 还非常轻松的情况下