
内存域
Unity引擎中的内存空间本质上可以划分成3个不同的内存域
Unity引擎中的内存空间本质上可以划分成3个不同的内存域。每个域存储不同的数据类型,关注不同的任务集。
托管域
托管域大家应该都熟悉。该域是Mono平台工作的地方,我们编写的任何MonoBehaviour 脚本和自定义的C#类在运行时都会在此域实例化对象,因此我们编写的任何C#代码都会很明确的与此域交互。它称为托管域,是因为内存空间会自动的被垃圾回收管理。
本地域
本地域对于我们来说是非常微妙的一个域,因为我们仅仅只是间接的与之交互。Unity有一些底层的本地代码功能,C++编写,并根据目标平台编译到不同的应用程序中。该域关心内部存储空间的分配,例如为各种子系统(渲染管线、物理系统、用户输入系统等)分配资源数据(例如纹理、音频文件和网格等等)和内存空间。最后,他还包括 GameObject 和 Component 等重要游戏对象的部分本地描述,以便和前面说到的那些内部系统进行交互。这里也是大多数内建 Unity 类(例如 Transform 和 Rigidbody 组件)保存数据的地方。
外部库域
最后一个内存域是外部库,例如 DirectX 和 OpenGL 库,也包括项目中包含的很多自定义库和插件。在C#代码中引用这些类库将导致类似的内存上下文切换和后续成本。
什么是“本地-托管桥”
前面说到,大多数内建 Unity 类(例如 Transform 和 Rigidbody 组件)的数据是保存在本地域内的。然后,托管域也会包含存储在本地域中的对象描述的包装器。因此,当和 Transform 等组件交互式,大多数指令会请求 Unity 进入他的本地域,在那里通过本地代码生成结果,接着将结果复制回托管域。
运行时的内存空间分为两种:栈、堆
栈
在大多数的操作系统中,运行的内存空间分为两种类型:栈和堆。
栈是内存中预留的特殊空间,专门用于存储小的、短期的数据值,这些值一旦超出作用域就会自动释放,因此称为栈。栈包含了已经声明的任何本地变量,并在调用函数时处理它们的加载和卸载。这些函数调用通过调用栈进行拓展和收缩。当对当前函数完成调用栈的处理时,它跳回调用栈中之前的调用点,并从之前离开的位置继续执行剩余内容。之前内存分配的开始位置是已知的,不需要执行内存清理操作,因为新的内存分配会覆盖旧数据。所以,栈相对快速、高效。
堆
堆表示所有其他的内存空间,并用于大多数内存分配。由于我们想让大多数内存分配的持有时间比当前函数调用更长,因此不能在栈上分配他们,因为他们会在当前函数被调用的时候被覆盖。因此,当数据类型太大以至于在栈中放不下的时候,或必须保持在声明的函数外时,可以在堆上分配。
物理上,栈和堆没有区别
在物理上,栈和堆没有什么区别,他们都只是内存空间。
本地代码中,例如用C++编写的语言,这些内存分配通过手动处理,需要确保正确的分配和释放所有内存块。如果没有正确释放,很容发生内存泄漏,如果持续的泄漏,从不清理,会由于内存不足导致程序崩溃。
在托管语言中,内存释放通过垃圾回收器自动处理。在Unity游戏的初始化期间,Mono会向操作系统申请一串内存,用于生成堆内存空间(通常称为托管堆),供C#代码使用。这个堆空间开始很小,不到1M字节,但是随着脚本代码需要新的内存块而增长。也可以在不需要的时候,释放回给操作系统。
垃圾回收
垃圾回收的目标
垃圾回收器(Garbage Collector,GC)有一个重要目标,就是确保不使用比所需要的更多的托管堆内存分配,而不再需要的内存会自动回收。例如,如果创建一个 GameObject ,接着销毁它,那么GC将标记这个GameObject使用的内存空间,以便以后回收。这不是一个立刻的过程,GC只会在需要的时候回收内存。
当请求新的内存空间,而托管的堆内存中有足够空闲空间以满足该请求时,GC只简单的分配新空间。然而,如果托管堆内没有足够空间,GC会先扫描所有已存在且不再使用的内存分配,并清除它们。如果还不够用,才会拓展当前的堆空间。
标记、清除式垃圾回收
Unity使用的Mono版本中的GC是一种追踪式GC,它使用标记与清除策略。这个过程包括两个阶段:标记和清除。GC会给每个分配的对象一个额外的数据位,用来表示该对象对否被标记。初始为false。
当收集过程开始时,它通过从GCRoot出发的引用关系,设置对象的标识为true,标记所有可以访问的对象。可访问对象要么是直接引用(例如栈上的静态或本地变量),要么是通过其他直接或间接可访问对象的字段(成员变量)来间接应用。对程序而言,任何没有被引用对象的本质上都是不可见的,都可以被GC回收。
第二阶段涉及迭代这些引用的标价关系。如果没有被标记,那么它是回收的候选对象。如果被标记,GC将直接跳过它,在下次垃圾回收的扫描之前会重新置回false。
第二阶段一旦结束,所有没有被标记的对象会被回收以释放空间。然后重新尝试创建对象的请求,如果GC已经释放了足够的空间,那么在会直接分配。然而,如果空间不够,就只能用最后的手段,向操作系统申请拓展托管堆。
内存碎片
垃圾回收的理想情况
在理想情况下,我们仅持续的分配和回收对象,但一次只能处理有限数量的对象,托管堆将保存大致恒定的大小,因为通常有足够的空间处理需要的新对象。然而,程序中的所有对象很少以它们分配的顺序被回收,而且他们占用的内存大小很少一样。这就导致了内存碎片。
内存碎片的图示
图片是一块虚拟内存,可以看到托管堆的区域内。我们最开始申请了A、B、C、D、E五块空间,然后要申请一块新的空间,这时候B、C已经被GC释放了,但B、C空间的大小还不够,所以只能寻求新的内存空间。这种就是内存碎片。
内存碎片会导致非常多的小的空闲内存空间
经过一段时间后,随着不同大小的对象被回收,堆空间可能会充满更多、更小的空闲内存空间,接着系统会逐个尝试在最小的可用空间内分配新的内存。在没有自动清理碎片的后台技术的时候,这种情况会发生在任何内存空间中——RAM、堆空间,甚至硬盘。这就是为什么Windows之前的系统,推荐时不时的进行一下碎片整理。现在的Windows10和Mac都在后台自动会运行内存碎片整理了。
内存碎片会显著减少总可用空间
内存碎片,长期来看,会显著减少新对象的总可用内存空间,当然,这取决于分配和回收的频率。
内存碎片会使新的分配耗费更长的时间
内存碎片会使新的分配耗费更长的时间,因为需要花费更多的时间来查找足以容纳新对象的内存空间。
在堆中分配新的内存空间时,可用空间的位置和可用空闲空间的大小同样重要。无法跨越不同的内存部分切分对象,因此GC必须持续的查找,在花时间进行详细的查找后,甚至还要花费更多的时间,直到找到足够大的空间,再甚至最后,还是得向操作系统申请新的内存空间。
GC导致卡顿
在充满内存碎片的托管堆内分配新内存,CPU需要做太多的工作,特别是当分配的对象,是用于重要的对象,如爆炸的粒子特效、进入新场景的角色、或者场景切换过渡等等。玩家直接就可以注意到,此时发生了卡顿。这是因为GC会冻结游戏,以处理这些极端的情况。
更糟糕的是,GC工作负载随着已分配的堆空间的增长而变差,因为擦除几兆字节的空间,比扫描几兆字节的空间要快得多。
多线程的垃圾回收
GC运行在两个独立线程上:主线程和所谓的 Finalizer Thread。当调用GC的时候,运行在主线程上,标志堆内存对象为后续回收。回收不会立即发生。可能会延迟几秒,才由Mono控制的Finalizer Thread,进行内存最终释放。
内存不是一旦GC就可用
由于这种延迟,不应该产生“内存一旦GC就可用”的想法。因此不应该浪费时间来等到消耗到最后一个字节,必须确保有某种类型的缓冲区域用于未来的内存分配。