Unity学习之垃圾回收GC
理解 GC 是 Unity 性能优化的基石,因为不恰当的内存管理会导致游戏在关键时刻卡顿(GC Spike),严重影响玩家体验。
C#内部有两个内存管理池:堆内存和栈内存。栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。C#中的变量只会在堆栈或者堆内存上进行内存分配,变量要么存储在栈内存上,要么处于堆内存上。
垃圾回收主要是指堆上的内存分配和回收,C#中会定时对堆内存进行GC操作。
1. 什么是垃圾回收GC
在 C# 中,我们使用 new
关键字来创建对象。这些对象被分配在 ** 托管堆(Managed Heap)** 上。
简单来说,GC 就是一个后台 “清洁工”。它的工作是自动找出那些不再被任何变量引用的对象,并回收它们占用的内存,以便为新对象腾出空间。
为什么需要自动 GC? 手动管理内存(如 C++ 中的 new
和 delete
)非常容易出错。忘记释放内存会导致内存泄漏(Memory Leak),而重复释放或释放正在使用的内存会导致程序崩溃。GC 的出现就是为了让开发者从繁琐且危险的内存管理中解放出来。
2. GC工作流程(简化版描述)
(1) 标记(Mark):GC 会暂停应用程序的所有线程(这就是导致卡顿的原因),然后从 “根” 对象(如静态变量、当前栈上的局部变量)开始遍历,标记所有仍然存活的对象。
(2)清除(Sweep):GC 遍历整个堆,将所有未被标记的对象(即垃圾)占用的内存空间标记为 “空闲”,并将这些空闲空间加入一个 “空闲列表”。
(3)压缩(Compact)(可选):为了减少内存碎片,GC 可能会移动所有存活的对象,将它们紧凑地排列在堆的一端,从而形成一整块连续的空闲内存。这个过程开销很大。
关键概念:GC 暂停(GC Pause)
GC 在执行 “标记” 和 “压缩” 阶段时,必须暂停主线程。这个暂停的时间就是我们常说的GC 卡顿(GC Spike)。游戏逻辑越复杂,堆上的对象越多,GC 暂停的时间就越长。
3. Unity 中的 GC:你需要知道的关键点
Unity 使用的是 Mono 或 IL2CPP 后端,它们的 GC 有一些特定的行为和注意事项。
分代回收(Generational GC)
nity 的 GC 是分代的,它将对象分为三代:
(1)第 0 代(Generation 0):新创建的对象。GC 最频繁地检查这一代。回收成本最低。
(2)第 1 代(Generation 1):在第 0 代 GC 中存活下来的对象。GC 检查频率较低。
(3)第 2 代(Generation 2):在第 1 代 GC 中存活下来的对象。GC 检查频率最低,但回收成本最高。
工作原理:假设:新创建的对象很快会变成垃圾。因此,GC 专注于频繁清理年轻的对象(第 0 代),而很少去打扰那些长期存活的对象(第 1、2 代)。一次完整的 GC(回收所有代)会产生非常长的停顿。
4. Unity GC触发条件
(1)周期性触发,平台不同周期时间不同
(2)当你 new
一个对象时,如果托管堆的剩余空间不足,GC 会被触发来尝试回收内存。
(3)通过调用 System.GC.Collect()
。在游戏的正常逻辑中,你几乎永远不应该手动调用它,除非在一些特定的、可控的场景(如场景切换的加载界面)。
5. Unity的GC执行的问题:
(1)每次执行GC,都会暂停Unity主线程,因此游戏画面会停止渲染,造成卡顿与掉帧
(2)执行GC若内存仍然不足,会申请更大内存空间,造成更长时间卡顿
(3)Unity的GC采取不压缩(内存块不会从离散变连续)的机制,可能导致内存碎片化
6. 优化如何尽量避免不必要的GC
(1) 尽量减少不必要的new对象的次数。谨慎使用new(),并且避免在Update等周期函数中使用,array,class等分配在堆上
(2) 减少临时变量的使用,多使用公共对象,多利用缓存机制。(将容器定义到函数外,用到容器的时候进行修改即可)。
(3) 对于大量字符串拼接时,将StringBuilder代替String。(string不可修改性,修改即创建一个新的string对象,旧的直接抛弃等待GC,但少量字符串拼接用string,性能优于stringbuilder)
// Bad
string message = "";
for (int i = 0; i < 10; i++)
{message += "Item " + i + ", "; // 循环10次,产生10个垃圾字符串!
}// Good
private StringBuilder _stringBuilder = new StringBuilder();
void BuildMessage()
{_stringBuilder.Clear();for (int i = 0; i < 10; i++){_stringBuilder.Append("Item ").Append(i).Append(", "); // 无GC Alloc}string finalMessage = _stringBuilder.ToString(); // 只在最后产生一次分配
}
(4) 使用扩容的容器时,例如:List,StringBuilder等,定义时尽量根据存储变量的内存大小定义储存空间,减少扩容的操作。(扩容后,旧的容器直接抛弃等待GC)
(5) 闭包:通过匿名函数实现,可以捕获外部变量,不会因为函数执行完就销毁。
void Start(){int externalVar = 10; // 外部变量(Start函数的局部变量)// 定义一个匿名函数(闭包),它捕获了externalVarSystem.Action closureFunc = () => {externalVar += 5;Debug.Log("闭包内的变量值:" + externalVar); // 输出:15};closureFunc();Debug.Log("外部的变量值:" + externalVar); // 输出:15}
(6) 使用struct代替class,管理轻量数据,因为struct分配在栈上
(7) 多使用对象池与Cache(缓存),实例化对象产生大量垃圾 Instantiate(new GameObject());
解决手段是 使用对象池技术来解决这个问题
(8) 在Update中使用射线检测时,会疯狂创建数组,会产生GC
void Update() {Physics.RaycastAll(new Ray());
}
可以使用 Physics.RaycastNonAlloc
避免,下面只创建了一次数组
private RaycastHit[] hits = new RaycastHit[10];
void Update() {Physics.RaycastNonAlloc(new Ray(), hits);
}
(9) 当使用yield return
的时候也会产生一个新的对象
IEnumerator Wait() {// 协程返回会创建一个引用类型数据yield return new WaitForSeconds(5f);
}
可以用 多个协程使用同一个对象来解决问题
private WaitForSeconds delay = new WaitForSeconds(5f);
IEnumerator Wait() {// 协程返回会创建一个引用类型数据yield return delay;
}
可以写一个具体的 YieldHelper 做为一个工具类,来具体处理协程提前应该创建好的变量,进而消除协程的GC
// yield 的意思是产出
IEnumerator WaitToDo(float setTime) {// 这里的堵塞逻辑其实是 yield return YieldHelper.WaitForSeconds(setTime); // do something
}public static class YieldHelper {public static IEnumerator WaitForSeconds(float totalTime) {float time = 0;while (time < totalTime) {time += Time.deltaTime;yield return null; // 等待一帧}}
}
(10) 装箱(变量转化为object类型)与拆箱(object类型转为基本类型),可以使用.ToString()使得代码编译减少装箱操作,从而减少堆上内存分配。
避免不必要的装箱
使用泛型集合:List<int>
代替 ArrayList
。ArrayList
会将所有元素都装箱为 object
。
(11) List<T>
, StringBuilder
等:在Start
或Awake
中初始化,而不是在Update
或LateUpdate
中。
// Bad
string message = "";
for (int i = 0; i < 10; i++)
{message += "Item " + i + ", "; // 循环10次,产生10个垃圾字符串!
}// Good
private StringBuilder _stringBuilder = new StringBuilder();
void BuildMessage()
{_stringBuilder.Clear();for (int i = 0; i < 10; i++){_stringBuilder.Append("Item ").Append(i).Append(", "); // 无GC Alloc}string finalMessage = _stringBuilder.ToString(); // 只在最后产生一次分配
}
(12) 谨慎使用 LINQ 和 Lambda 表达式
LINQ 非常方便,但在性能敏感的Update
循环中应避免使用。
注意闭包:Lambda 表达式(() => { ... }
)可能会捕获外部变量,导致创建闭包对象,产生 GC。