top of page
作家相片Lingheng Tao

Unity Engine #1 Memory


本篇笔记主要关于 Unity 引擎的内存管理的相关知识。



基础概念


Unity 的托管内存系统是基于 Mono 或 IL2CPP 虚拟机 (VM) 的 C# 脚本环境。托管内存系统的好处是它管理内存的释放,因此您无需通过代码手动请求释放内存


托管内存 (Managed Memory):这是由 .NET 的垃圾回收器(通常简称为 GC)来管理的,主要用于存储 C# 对象。


非托管内存 (Unmanaged Memory): Unity 引擎和插件使用的内存。用于存储 Texture2D, Mesh 等原生资源。


托管堆(Managed Heap):是.NET运行时环境中用于存储所有C#对象的内存区域。在Unity中,这包括了几乎所有通过脚本创建的对象,例如类的 instance、数组、字符串等。注意,这里的东西是由 GC 自动管理的。与计算机系统一致,在占用托管堆时,不同的数据类型会占用不同的大小。



自动内存管理


在 Unity 中,不同于常见的 C,我们并不需要为内存堆占用使用 malloc 函数,也不需要对一个不再使用的内存块做 free 函数。Unity 的脚本后端使用垃圾回收器自动管理应用程序的内存,与显式分配/释放相比,自动内存管理需要更少的编码工作,并减少了内存泄漏的可能性。


内存碎片


由于不同的数据类型有不同的大小,如果我们需要占用一个很大的内存块 M,即使在释放一些被占用的内存后当前可用的内存的总量比 M 大,却依然没法将 M 塞进内存。这是因为这些被释放的内存“碎片化”了。

如果分配了一个大型对象,并且没有足够的连续可用空间来容纳它,如上图所示,Unity 内存管理器将执行两个操作:

  • 首先,GC 运行(如果尚未运行),尝试释放足够的空间来满足分配请求。

  • 如果在 GC 运行后,仍然没有足够的连续空间来容纳请求的内存量,则必须扩展堆。堆扩展的具体量取决于平台。但是,在大多数平台上,当堆扩展时,它会扩展为先前扩展量的两倍。

但是由于堆扩展是一个有风险的操作,Unity 的垃圾回收策略倾向于更频繁地对内存进行碎片化。而且频繁的 GC 操作可能会导致游戏卡顿,所以我们应该尽量减少内存分配,避免在 Update 等频繁调用的方法中创建新对象。



对象池


对象池(Object Pooling)是一种设计模式,用于重用管理一组预先实例化的对象集合,而不是在需要和创建时销毁它们。在 Unity 中频繁创建和销毁会导致性能下降和内存分配的问题,也因为减少了 GC 的调用次数,能够提高游戏性能。


使用场景:适合于那些频繁创建和销毁的对象,如子弹、粒子效果、敌人等。在需要高性能和流畅体验的游戏中尤为重要,例如实时战略游戏、射击游戏。


实现步骤

  1. 创建对象池单例;

  2. 在游戏开始 (Start 或 Awake中)根据需要预先实例化一定数量的对象;

  3. 提供方法以池中获取对象和返回对象;

注意:

虽然确实减少了 GC 的调用,但是也增加了持续占用的内存。



非托管资源


非托管资源是由Unity引擎直接管理的资源,不受.NET垃圾回收器(GC)控制。这包括了纹理、音频文件、3D模型、动画等。我们通过 Asset Bundles 或 Resources 类加载资源,要通过 Destroy 函数来释放资源,以免造成下面提到的内存泄漏。


Resources 类的使用


在 Assets 中建立一个名为 Resources 的文件夹。然后,在 C# 脚本中,就可以通过

var prefab = Resources.Load<GameObject>("MyPrefab");

来调用所需要的非托管资源了。


我们也可以通过 Resources.UnloadAsset(AssetName) 来释放我们的资源;使用 Resources.UnloadUnusedAssets() 可以释放目前所有未使用的资源。


内存泄漏


不再使用的内存没有被 GC 回收,就会导致内存泄漏。以下是一些可能发生的内存泄漏事故:


非托管资源未释放


创建了大量的非托管资源(如 Texture、Audio Clip、Mesh),但在不再需要他们的时候没有释放。


解决方法:用 Destroy 方法释放。或者在使用完毕后调用 .Dispose();


静态变量和单例


静态变量或单例类引用了场景中的对象,即使切换场景时这些对象也不会被销毁。


解决方法:确保在不再需要静态变量时将其设置为 null,或者小心使用单例模式。


事件和委托


对象订阅了事件,但在销毁前未取消订阅,导致事件持续保持对这些对象的引用。


解决方法:在 OnDestroy() 方法中,确保取消所有事件的订阅。


资源动态加载


使用 Resources.Load 动态加载资源,但没有正确卸载。导致这些资源始终保留在内存中。


解决方法:适当使用 Resources.UnloadUnusedAssets 来卸载不再使用的资源。


代码优化以减少内存分配


字符串操作优化


对于 string 类型,我们尽可能减少 “+” 操作;因为这会创建很多的临时字符串对象;


解决方法:通过使用 “StringBuilder” 来构建字符串,特别是在循环中进行字符串拼接的场景。


避免装箱和拆箱


在 Unity 中,我们有值类型(例如 int, float, bool)和引用类型(例如 Object)的区别。

值类型和引用类型之间的转换被称为装箱和拆箱,可能会导致额外的内存分配。我们应该尽量避免不必要的装箱和拆箱的操作,明确数据类型的使用。


优化数据结构


首先,需要明确很多数据结构的优势和劣势。

​Data Structure 数据结构

​优势

劣势

关键操作时间复杂度

List<T>

动态数组,可以通过索引快速访问元素

在非末尾处的插入和删除操作较慢

访问:O(1)

插入/删除:O(n)

​LinkedList<T>

插入和删除很快

无法通过直接索引访问其中的一个 entry

访问:O(n)

插入/删除:O(1)

Dictionary<TKey, TValue>

快速查找,键值对存储,适合快速访问、更新数据

内存使用很高,键必须唯一

查找/插入/删除: O(1)

​HashSet<T>

唯一值的集合,快速查找(存在判定)和插入

元素无序,元素也不可以重复

查找/插入/删除:O(1)

Queue<T>

First In First Out(先进先出,FIFO)

随机访问较慢

入队/出队:O(1)

Stack<T>

Last In First Out (后进先出,LIFO)

随机访问较慢

入栈/出栈:O(1)

Array

固定大小,快速访问

大小固定,不够灵活

访问:O(1)

在熟记每一个数据结构的优劣势和复杂度的前提下,在保证我们需要的操作的前提下,我们应该尽量选择内存占用较小、操作速度较快的数据类型。

 

参考资料:

20 次查看0 則留言

Comments


bottom of page