113.运行时高频操作内存优化

113.内存优化-内存管理-运行时高频操作内存优化


113.1 知识点

InstanceID 的妙用

对于继承自 MonoBehaviourScriptableObject 的对象,都可以调用 GetInstanceID() 方法来获取对象的唯一 ID。

  • 该 ID 在 Unity 中用于表示某一个对象的唯一标识值
  • 在整个生命周期中不会发生变化,也不会出现两个对象重复的情况
  • 因此我们可以用它来做字典的键、对象的唯一 ID 等

但是 GetInstanceID() 方法会有开销,建议在初始化时获取一次并缓存下来。

id = this.GetInstanceID();

foreach 的使用

在早期的 Unity 版本中(2018 以前),使用 foreach 遍历某些集合(如 Transform)会导致每次循环分配堆内存,产生内存垃圾。在现今版本的 Unity 中,该问题已得到修复。

使用 foreach 时遵循以下规则即可:

  1. 可以放心使用 foreach 遍历标准集合:
    • 数组
    • List<T>
    • Dictionary<TKey, TValue>
  2. 遇到以下情况需提高警惕(特别是老版本 Unity):
    • foreach (Transform child in transform)
    • foreach (var x in 某个 Unity 自己暴露的集合)
    • 这些最好在 Profiler 里查看 GC Alloc,如果有分配,就换成 for 循环或改用迭代器实现。

总之,不要再用“一律不用 foreach”的老规矩。现在版本的 C# 和 Unity 都已优化很多,应用 Profiler 判断哪里有 GC 分配,再进行精确优化。

协程的合理使用

启动一个协程会消耗少量内存。如果内存消耗和 GC 是严重问题,可以尝试:

  • 避免产生太多短生命周期的协程
  • 避免在运行时大量调用 StartCoroutine

因为一个协程在底层本质上是一个状态机对象。

举例说明:

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

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

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 对象的操作(例如 yield return new WaitForSeconds(1f)),这也可能产生堆内存分配。

谨慎使用闭包

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

示例:

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

编译器会将其转换为类似以下代码:

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

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

Linq 的问题

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

  1. 大量隐式堆内存分配
    Linq 使用了迭代器、委托、闭包、延迟执行等机制。
    例如:var aliveEnemies = enemies.Where(e => e.hp > 0);
    这个过程会生成迭代器对象(class)、委托、可能的中间 IEnumerable 对象,最终遍历时还会 new Enumerator 对象。每一步都可能分配堆对象,产生垃圾,造成 GC 频繁触发。

  2. 性能不透明(每帧执行成本难预测)
    Linq 的内部实现抽象程度高,每个操作都包含方法调用链、委托调用,对 JIT / IL2CPP 来说优化难度大(IL2CPP 转 C++ 之后反而可能更慢)。

  3. 链式调用会产生多个临时对象
    例如:var result = list.Where(...).Select(...).ToList();
    这一条语句就可能创建:一个捕获闭包对象、一个 WhereIterator、一个 SelectIterator,以及最终的 List<T>ToList() 必然 new)。对大量数据来说成本十分昂贵。

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

正则表达式的问题

  1. 运行成本极高:包含解释器 + 状态机的开销。
  2. 执行速度慢:是 Unity 性能敏感逻辑中的大忌。
  3. 复杂度高,容易隐藏性能问题

因此,在性能关键代码中应尽量避免使用 Linq 和正则表达式,或尽量减少其使用。

对象池

对象池(缓存池)是内存与性能优化中必不可少的手段,是所有项目都应具备的功能。其具体知识在相关选修内容中已有讲解。

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

List/Dictionary 频繁扩容问题

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

建议:

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

Dictionary<K,V> 的注意点

  1. 同样尽量在初始化时指定合适的 capacity,避免扩容时的大规模拷贝操作。
  2. 作为 Key 的结构体应保证是小而稳定的值类型,避免因哈希不稳定产生隐藏 Bug。
  3. 不要在 foreach 遍历 Dictionary 的同时移除元素,容易导致异常或额外分配。

避免频繁创建和丢弃容器

例如:每帧 new 一个 ListDictionary 来收集数据,用完后立即丢弃。这种场景适合使用:

  • 对象池配合 Clear() 重用容器
  • 共享的静态缓存

113.2 知识点代码

Lesson113_内存优化_内存管理_运行时高频操作内存优化.cs

using UnityEngine;

public class Lesson113_内存优化_内存管理_运行时高频操作内存优化 : MonoBehaviour
{
    public int id;

    void Start()
    {
        #region 知识点一 InstanceID的妙用

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

        #endregion

        #region 知识点二 foreach的使用

        //在早期的版本中,Unity2018以前
        //比如用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,再精确优化哪里

        #endregion

        #region 知识点三 协程的合理使用

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

        //举例:
        //IEnumerator MyCoroutine()
        //{
        //    yield return new WaitForSeconds(1f);
        //    Debug.Log("Done");
        //}

        //编译器实际上会把它翻译成一个隐藏的类
        //类似:
        //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对象的操作
        //比如
        //yield return new WaitForSeconds(1f);
        //这时也可能会new堆对象产生内存分配

        #endregion

        #region 知识点四 谨慎使用闭包

        //闭包会捕获外部变量生成隐藏类
        //容易无意中延长对象生命周期、引发内存泄漏、增加 GC、造成 bug
        //因此在 Unity 中性能敏感场景需谨慎使用
        //举例:
        //int hp = 100;
        //button.onClick.AddListener(() => Debug.Log(hp));
        //编译器变成:
        //class DisplayClass {
        //    public int hp;
        //}
        //var dc = new DisplayClass { hp = 100 };
        //button.onClick.AddListener(dc.Method);

        #endregion

        #region 知识点五 减少Linq和正则表达式的使用

        //Linq(Where(), Select(), ToList(), OrderBy() 等)带来的问题主要有三个
        //1.大量隐式对内存分配
        //  Linq 使用了迭代器、委托、闭包、延迟执行等机制
        //  比如:var aliveEnemies = enemies.Where(e => e.hp > 0);
        //  这里会:
        //  生成一个迭代器对象(class)
        //  生成一个委托
        //  可能生成中间 IEnumerable 对象
        //  终遍历时还会 new Enumerator 对象
        //  每一步都可能分配堆对象,产生垃圾,造成GC频繁触发

        //2.性能不透明(每帧执行成本难预测)
        //  Linq 的内部写法是高抽象的
        //  每个操作都包含方法调用链、delegate 调用
        //  对 JIT / IL2CPP 来说优化难度大(IL2CPP 转 C++ 之后反而更慢)

        //3.链式调用会产生多个临时 Enumerator / 集合对象
        //  比如:var result = list.Where(...).Select(...).ToList();
        //  这一条就可能创建:
        //  一个捕获闭包对象
        //  一个 WhereIterator(位置迭代器)
        //  一个 SelectIterator(选择迭代器)
        //  最终一个 List<T>(ToList 必然 new)
        //  对大量数据来说十分昂贵

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

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

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

        #endregion

        #region 知识点六 对象池

        //对象池(缓存池)的知识在之前的选修内容中已经讲了
        //它是内存性能优化中必不可少的手段,是所有项目中都应该有的功能

        #endregion

        #region 知识点七 集合(List、Dictionary)的容量与临时分配

        //1.List/Dictionary频繁扩容会产生很多小垃圾
        //  new List<int>(); 默认容量很小,Add 多了就不断扩容、拷贝、产生垃圾旧数组
        //  建议:
        //  1-1.已知大概元素数量时,提前指定容量:new List<int>(预计数量);
        //  1-2.对于长期存在的容器,用Clear()重用,而不是每次都new一个新的List
        //  1-3.对于只在函数内部用一次的超大List,不要泄露到静态字段,避免长期占内存

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

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

        #endregion
    }
}


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏