113.内存优化-内存管理-运行时高频操作内存优化
113.1 知识点
InstanceID 的妙用
对于继承自 MonoBehaviour 或 ScriptableObject 的对象,都可以调用 GetInstanceID() 方法来获取对象的唯一 ID。
- 该 ID 在 Unity 中用于表示某一个对象的唯一标识值
- 在整个生命周期中不会发生变化,也不会出现两个对象重复的情况
- 因此我们可以用它来做字典的键、对象的唯一 ID 等
但是 GetInstanceID() 方法会有开销,建议在初始化时获取一次并缓存下来。
id = this.GetInstanceID();
foreach 的使用
在早期的 Unity 版本中(2018 以前),使用 foreach 遍历某些集合(如 Transform)会导致每次循环分配堆内存,产生内存垃圾。在现今版本的 Unity 中,该问题已得到修复。
使用 foreach 时遵循以下规则即可:
- 可以放心使用
foreach遍历标准集合:- 数组
List<T>Dictionary<TKey, TValue>
- 遇到以下情况需提高警惕(特别是老版本 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() 等)主要带来三个问题:
大量隐式堆内存分配
Linq 使用了迭代器、委托、闭包、延迟执行等机制。
例如:var aliveEnemies = enemies.Where(e => e.hp > 0);
这个过程会生成迭代器对象(class)、委托、可能的中间IEnumerable对象,最终遍历时还会new Enumerator对象。每一步都可能分配堆对象,产生垃圾,造成 GC 频繁触发。性能不透明(每帧执行成本难预测)
Linq 的内部实现抽象程度高,每个操作都包含方法调用链、委托调用,对 JIT / IL2CPP 来说优化难度大(IL2CPP 转 C++ 之后反而可能更慢)。链式调用会产生多个临时对象
例如:var result = list.Where(...).Select(...).ToList();
这一条语句就可能创建:一个捕获闭包对象、一个WhereIterator、一个SelectIterator,以及最终的List<T>(ToList()必然new)。对大量数据来说成本十分昂贵。
所以,虽然 Linq 写法优雅,但 CPU 和 GC 成本极高,性能敏感逻辑(Update/FixedUpdate/AI/物理)中不要使用。
正则表达式的问题
- 运行成本极高:包含解释器 + 状态机的开销。
- 执行速度慢:是 Unity 性能敏感逻辑中的大忌。
- 复杂度高,容易隐藏性能问题。
因此,在性能关键代码中应尽量避免使用 Linq 和正则表达式,或尽量减少其使用。
对象池
对象池(缓存池)是内存与性能优化中必不可少的手段,是所有项目都应具备的功能。其具体知识在相关选修内容中已有讲解。
集合(List、Dictionary)的容量与临时分配
List/Dictionary 频繁扩容问题
new List<int>() 默认容量很小,Add 操作多了就会不断扩容、拷贝,并产生垃圾旧数组。
建议:
- 已知大概元素数量时,提前指定容量:
new List<int>(预计数量) - 对于长期存在的容器,用
Clear()方法重用,而不是每次都new一个新的List - 对于只在函数内部使用一次的超大
List,不要泄露到静态字段,避免长期占用内存
Dictionary<K,V> 的注意点
- 同样尽量在初始化时指定合适的
capacity,避免扩容时的大规模拷贝操作。 - 作为 Key 的结构体应保证是小而稳定的值类型,避免因哈希不稳定产生隐藏 Bug。
- 不要在
foreach遍历Dictionary的同时移除元素,容易导致异常或额外分配。
避免频繁创建和丢弃容器
例如:每帧 new 一个 List 或 Dictionary 来收集数据,用完后立即丢弃。这种场景适合使用:
- 对象池配合
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