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。

相关推荐
DKPT3 小时前
JVM如何管理直接内存?
java·笔记·学习
前路不黑暗@4 小时前
Java:代码块
java·开发语言·经验分享·笔记·python·学习·学习方法
序属秋秋秋4 小时前
《C++进阶之C++11》【可变参数模板 + emplace接口 + 新的类功能】
c++·笔记·学习·c++11·可变参数模板·emplace系列接口
Dovis(誓平步青云)5 小时前
《Linux 构建工具核心:make 命令、进度条、Gitee》
linux·运维·学习
XH1.8 小时前
学习HAL库STM32F103C8T6(SPI、门禁密码实验)
stm32·嵌入式硬件·学习
Da Da 泓11 小时前
LinkedList模拟实现
java·开发语言·数据结构·学习·算法
Larry_Yanan12 小时前
QML学习笔记(十五)QML的信号处理器(MouseArea)
c++·笔记·qt·学习·ui
Larry_Yanan14 小时前
QML学习笔记(十七)QML的属性变更信号
javascript·c++·笔记·qt·学习·ui
eqwaak014 小时前
Flask实战指南:从基础到高阶的完整开发流程
开发语言·后端·python·学习·flask