Unity学习之垃圾回收GC

理解 GC 是 Unity 性能优化的基石,因为不恰当的内存管理会导致游戏在关键时刻卡顿(GC Spike),严重影响玩家体验。

C#内部有两个内存管理池:堆内存和栈内存。栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。C#中的变量只会在堆栈或者堆内存上进行内存分配,变量要么存储在栈内存上,要么处于堆内存上。

垃圾回收主要是指堆上的内存分配和回收,C#中会定时对堆内存进行GC操作。

1. 什么是垃圾回收GC

在 C# 中,我们使用 new 关键字来创建对象。这些对象被分配在 ** 托管堆(Managed Heap)** 上。

简单来说,GC 就是一个后台 "清洁工"。它的工作是自动找出那些不再被任何变量引用的对象,并回收它们占用的内存,以便为新对象腾出空间。

为什么需要自动 GC? 手动管理内存(如 C++ 中的 newdelete)非常容易出错。忘记释放内存会导致内存泄漏(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)

cs 复制代码
// 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) 闭包:通过匿名函数实现,可以捕获外部变量,不会因为函数执行完就销毁。

cs 复制代码
    void Start()
    {
        int externalVar = 10; // 外部变量(Start函数的局部变量)
 
        // 定义一个匿名函数(闭包),它捕获了externalVar
        System.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

cs 复制代码
void Update() {
    Physics.RaycastAll(new Ray());
}

可以使用 Physics.RaycastNonAlloc避免,下面只创建了一次数组

cs 复制代码
private RaycastHit[] hits = new RaycastHit[10];
void Update() {
    Physics.RaycastNonAlloc(new Ray(), hits);
}

(9) 当使用yield return 的时候也会产生一个新的对象

cs 复制代码
IEnumerator Wait() {
    // 协程返回会创建一个引用类型数据
    yield return new WaitForSeconds(5f);
}

可以用 多个协程使用同一个对象来解决问题

cs 复制代码
private WaitForSeconds delay = new WaitForSeconds(5f);
IEnumerator Wait() {
    // 协程返回会创建一个引用类型数据
    yield return delay;
}

可以写一个具体的 YieldHelper 做为一个工具类,来具体处理协程提前应该创建好的变量,进而消除协程的GC

cs 复制代码
// 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> 代替 ArrayListArrayList 会将所有元素都装箱为 object

(11) List<T>, StringBuilder :在StartAwake中初始化,而不是在UpdateLateUpdate中。

cs 复制代码
// 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。

相关推荐
西岸行者6 天前
学习笔记:SKILLS 能帮助更好的vibe coding
笔记·学习
悠哉悠哉愿意6 天前
【单片机学习笔记】串口、超声波、NE555的同时使用
笔记·单片机·学习
别催小唐敲代码6 天前
嵌入式学习路线
学习
毛小茛6 天前
计算机系统概论——校验码
学习
babe小鑫6 天前
大专经济信息管理专业学习数据分析的必要性
学习·数据挖掘·数据分析
winfreedoms6 天前
ROS2知识大白话
笔记·学习·ros2
在这habit之下6 天前
Linux Virtual Server(LVS)学习总结
linux·学习·lvs
我想我不够好。6 天前
2026.2.25监控学习
学习
im_AMBER6 天前
Leetcode 127 删除有序数组中的重复项 | 删除有序数组中的重复项 II
数据结构·学习·算法·leetcode
CodeJourney_J6 天前
从“Hello World“ 开始 C++
c语言·c++·学习