Unity内存

在游戏开发的世界中,程序的优劣与内存的使用有着密不可分的关系。从很多方面来说Unity对于内存的使用并不是最优解,正因为如此,Unity的内存管理与优化就显得尤为重要。

内存域

Unity引擎中的内存空间本质上可以分为3个不同的内存域,分别为托管域本地域以及外部库,每个域存储不同的数据类型,关注不同的任务集。
在这里插入图片描述

- 托管域

在使用Mono编译的情况下,托管域就是Mono VM使用的运行时内存。“托管”的本意是Mono可以自动地改变堆的大小来适应所需要的内存,并且适时地调用垃圾回收(Garbage Collection)操作来释放已经不需要的内存。我们编写的任何MonoBehaviour脚本以及自定义C#类都会在此域中实例化对象,因此我们编写的任何C#代码都会很明确的与此域交互。目前绝大部分Unity游戏逻辑代码所使用的语言为C#,C#代码所占用的内存又称为mono内存,这是因为Unity是通过mono来跨平台解析并运行C#代码的,在Android系统上,游戏的lib目录下存在的libmono.so文件,就是mono在Android系统上的实现。

- 本地域

Unity有一些底层的本地代码功能是由C++编写的,并根据目标平台编译到不同的应用程序中。该域关心内部内存空间的分配,比如为各种子系统(渲染管线、物理系统、用户输入系统等)分配资源数据(纹理、音频、网格等)和内存空间。它还包括GameObject、Component等重要游戏对象的本地描述,以便和这些内部系统交互。这也是大多数内建Unity类(如Transform、Rigidbody组件)保存其数据的地方。

- 外部库

外部库是DirectXOpenGL以及项目中包含的自定义库和插件所使用的内存域,在C#代码中引用这些库将导致类似的内存上下文切换和后续成本

- 跨桥操作

托管域也包含存储在本地域中的对象描述包装器,当和Transform等组件交互时,大多数指令会请求Unity进入本地代码,在本地域生成结果,然后将结果复制回托管域。这就是托管域和本地域之间的本地-托管桥。当两个域对相同实体有自己的描述时,跨域他们之间的桥需要进行上下文切换,而这会为游戏带来很多相当严重的潜在性能问题。所以,我们在开发的过程中要尽量避免跨桥操作。具体如何避免跨桥操作请参考另一篇文章:【Unity】Unity开发进阶(一)减少跨桥(上下文切换)

堆和栈

在大多数现代操作系统中,运行时的内存空间分为两种类型:栈和堆。

- 栈

栈是内存中预留的特殊空间,专门用于存储小的、短期的数据值,这些值一旦超出作用就会自动释放,因此成为栈。与数据结构中的栈一样,内存栈也有入栈和出栈。
所有声明过的本地变量(比如int a;)都会放入栈内,当调用函数时处理他们的加载和卸载。也就是说在程序运行前变量已经定义到栈中了。这些函数调用通过所谓的调用栈进行拓展与收缩,当对当前函数完成调用栈处理时,它跳回调用栈之前的调用点,并从之前离开的位置继续执行剩余内容。之前内存分配的位置总是已知的,不需要执行内存清理操作,因为新的内存分配指挥覆盖旧数据,因此栈是相对高效的
栈的总大小通常很小,大约为兆字节(MB)。当分配超过栈可支持的空间时,可能会导致栈溢出,这会出现在执行大量调用栈时(例如无限循环)或有大量本地变量时,但大多数时候,尽管栈的大小相对较小,但很少会引起栈溢出

- 堆

堆表示所有其他的内存空间,并用于大多数内存分配**。由于我们想让大多数内存分配的持有时间比当前函数调用更长,因此不能栈上分配他们,因为栈会在方法执行结束后覆盖执行前后产生的结果。因为数据类型有时往往过大,或者需要保留到函数之外,这时就有了堆。在物理上堆和栈并没有什么区别,它们都只是内存空间,包含存在于RAM中的数据字节。操作系统会请求并保存这些数据字节。不同之处在于使用它们的时机、场合和方式。
在本地代码中,例如用C++编写的语言,这些内存分配通过手动处理,我们有责任确保正确地分配所有内存块,并在不需要时显式地释放内存。不然容易造成内存泄漏,直到内存不够,程序崩溃为止。
在托管语言中,内存释放通过垃圾回收器自动处理,在Unity程序的初始化期间,Mono平台向操作系统申请一串内存,用于生成堆内存空间(通常称为托管堆),供C#代码使用。这个堆空间开始相对较小,不到1MB,但是随着脚本代码需要新的内存块而增长。如果Unity不再需要它,那么该空间可以通过释放回操作系统来缩小。

- 堆栈的使用

下面通过图的方式展现在一个方法执行时堆栈的大致情况
堆栈的使用概况
Mono VM根据数据类型的不同决定哪些需要存在于栈中,哪些要存在于堆中。通常值类型的数据都直接存在栈中,而引用型数据则会在堆内开辟一段连续的空间,并将该段空间的堆内存地址放到栈中。具体在堆中开辟多大的空间,取决于实例化变量的实际类型,而不是取决于变量的声明类型。

垃圾回收

垃圾回收器(Garbage Collector,GC)有一个重要的工作,该工作确保不使用比所需要的更多的托管内存,而不再需要的内存会自动回收。也就是说,对象的创建以及销毁,GC都会参与其中。例如:如果创建一个GameObject,接着销毁它,那么GC将标记该对象使用的内存空间,以便在后续的时间回收这段内存。注意,内存回收不是实时的,而是只在不要的时候才会回收内存。

Unity使用的Mono版本中的GC是一种追踪式GC,它使用标记与清除策略。该算法分为两个阶段:

  1. 每个分配的对象通过一个额外的数据位追踪。该数据位表示对象是否被标记。这些标记设置为false,标识它尚未被标记。当收集过程开始时,它通过设置对象的标识位Alive为true,标记所有依然对程序可访问的对象。可访问对象要么是直接引用(例如栈上的静态或本地变量),要么是通过其他直接或间接可访问对象的字段(成员变量)来间接引用。理论上所有没有被引用的对象都应该被回收。
  2. 第二阶段涉及迭代这类引用(GC将在程序的整个生命周期中跟踪这些引用),并基于它的标记状态决定是否应该回收。如果对象未被标记,就会被视为回收的候选者。这个阶段会直接跳过已被标记过的对象,但在下次垃圾回收扫描之前会将它们重新设置为false,以完成新一轮的标记。

当第二个阶段执行结束,所有没被标记的对象会被正式回收以释放空间,然后重新访问创建对象的出事请求,如果GC已经释放了足够的空间,就在新释放的空间内分配内存并返回给调用者。如果释放后的空间不够,就只能再向系统申请更多的托管堆。

事实上,GC在内存中维护所有对象的列表,而应用程序维护了另一个独立的列表,其中仅包含它们中的一部分。只要程序用完对象,就简单的忘记它的存在,将其从列表中移除,而不去考虑对象是否需要被回收。也就是说,垃圾回收的工作是GC自己独立完成的,程序只需要维护自己的对象列表即可。

游戏对于性能的要求较高,为提高效率,可在场景切换时或资源使用不频繁时调用GC。

- Mono内存分配过程

Mono内存分为两部分:已用内存(Used)和堆内存(Heap),已用内存指的是Mono实际使用的内存,堆内存是Mono向系统申请的内存。当程序需要申请内存时,垃圾回收器(GC)会先在堆中查找是否有连续的且空间足够的内存位置,如果有这样的空位,则直接分配内存,如果没有则会先执行垃圾回收再尝试分配,如果仍然不够则会向系统申请更多的内存空间以供使用。
在这里插入图片描述

- 内存泄漏

通常情况下,GC所标记的对象都是可访问对象,但在某些特定情况下(互相引用、静态引用等),GC会产生错误的标记,将一些永远无法访问的对象标记为Alive,这将导致这部分内存一直未被回收,这部分内容就被称为内存泄漏。如果程序中定期的产生此类代码,程序会在运行一段时间后耗尽系统内存,导致程序强退、闪退,甚至系统重启。

腾讯的WeTest平台提供了一系列测试工具,其中就有测试内存泄漏的工具,详见文章:Unity游戏Mono内存管理及泄漏

- 内存碎片

在理想状态下,持续的分配和回收对象能够让堆保持恒定大小。然而,程序中的所有对象很少以他们分配的顺序被回收,而且他们占用的内存大小也很少一样,这就导致了内存碎片。

因为对象在堆中的存储方式必须是连续的,那么大的对象就无法完美的分段安插在已经被回收过的小空间内,这些回收后没有被重新利用起来的小空间就被称为内存碎片。这样的碎片多了以后,GC就经常需要申请新的堆,而新的分配也将花费更多的时间来寻找空间和申请空间。

- 运行时垃圾回收

在最坏的情况下,当游戏请求新的内存分配时,CPU在完成分配之前需要花费CPU周期来完成下面的任务:

  1. 验证是否有足够的连续空间用于分配新对象。
  2. 如果没有足够空间,迭代所有已知的直接和间接引用,标记他们是否可达。
  3. 再次迭代所有这些引用,标识未标记的对象用于回收。
  4. 迭代所有标识对象,以检查回收一些对象是否能为新对象创建足够大的连续空间。
  5. 如果没有,从操作系统请求新的内存块,以便拓展堆。
  6. 在新分配的块前面分配新对象,并返回给调用者。

此时CPU需要处理很多工作,特别当该新内存分配用于重要的对象,如粒子特效,新进入场景的角色,火切换场景过渡等。用户极有可能注意到此时GC冻结了游戏以处理极端情况。更糟的是,GC工作负载随着已分配的堆空间的增长而变差,因为擦除几兆字节的空间比扫描几千兆字节快得多。这也是很多游戏越玩越卡的原因所在,轻则掉帧,重则卡顿,甚至闪退。

- 垃圾回收

GC运行在两个独立线程上:主线程和所谓的Finalizer Thread。当调用GC时,它运行在主线程上,并标志堆内存块为后续回收。这不会立刻发生,由Mono控制的Finalizer Thread在内存最终释放并可用于重新分配之前,可能会延迟几秒。

可以通过Projiler窗口中Memory Area的Total Allocated块观察此行为(绿线)。垃圾回收后可能需要几秒钟总分配值才会下降。由于这种延迟,不应该依赖内存一旦回收就可以使用这一观念,而且因此不应该浪费时间尝试消耗可用内存的最后一个字节,必须确保有某种类型的缓冲区用于未来的分配
在这里插入图片描述
GC释放的块有时会在一段时间后返回到操作系统,这将减少堆消耗的保留空间,并允许内存分配给其他应用程序。然而这是不可预测的,它取决于目标平台,因此不应该依赖它。唯一的安全假设是一旦内存分配给Mono,它就会被保留,不再可用于本地域或相同系统上运行的任何其他程序。


更多内容请查看总目录【Unity】Unity学习笔记目录整理

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐