第八章《Unity游戏优化》内存管理

  • 1.Mono类库平台
  • 1.脚本语言
  • 2.内存域
  • 1. 垃圾回收
  • 2. 内存碎片
  • 3.多线程的垃圾回收
  • 2.代码编译
  • 1.Mono虚拟机
  • 2.IL2CPP
  • 3. 分析内存
  • 1.分析内存消耗
  • 2. 分析内存效率
  • 4.内存管理性能增强技术
  • 1. 垃圾回收策略
  • 2.手动JIT编译
  • 3.值类型和引用类型
  • 5.Unity关于Mono和IL2CPP的未来



内存效率是优化的重要元素。失控的管理会出现内存泄漏导致崩溃闪退或者过高的GC浪费宝贵的CPU时间导致卡顿。

1.Mono类库平台

mono项目的目标是通过框架提供跨平台开发。可以编译为.NET通用中间语音CIL的任何语言都能与Mono平台集成。
常见错误观念:Unity引擎是构建在Mono平台上的。这是错误的,因为很多任务比如:音频、渲染、物理、动画等等是底层C++运行的。用户编写的脚本是运行在Mono上的。Mono只是底层Unity引擎的一个组成部分。

1.脚本语言

脚本语言通常通过自动垃圾回收抽象并分离了复杂的内存管理,并且提供了很多安全管理内存的API特性,这些都是以牺牲运行时开销为代价,简化了编程的行为。

托管语言,其特点是托管代码。指:必须在公共语言(Common Language Runtime,CLR)运行时运行的源码,和通过目标操作系统编译与运行的代码不同。

unity 内核 unity内存管理机制_垃圾回收


托管语言的主要争论点:它们的自动内存管理。手动管理内存是一项复杂的任务,需要多年经验才能精通,但很多开发者认为托管语言解决该问题的方式不可预测,质量风险大。认为用托管代码构建高性能应用程序是不靠谱的行为。

2.内存域

Unity引擎中的内存空间本质上可以划分为3个不同的内存域。每个域存储不同的数据类型,关注不同的任务集。

  1. 第一个内存域托管域。它是Mono平台工作的地方,我们编写的任何MonoBehaviour脚本和自定义的C#类在运行时都会在这里实例化对象。它成为托管域,因为内存空间自动被垃圾回收管理。
  2. 第二个内存域本地域。只是间接的交互。Unity一些底层的C++本地代码,根据目标平台编译。该域关心内部内存空间的分配,如为各种子系统(渲染管线、物理系统、用户输入系统)分配资源数据(纹理、音频、网格)和内存空间。这也是大多内建Unity类(Transform和Rigidbody等等)保存数据的地方。
    托管域也包含存储在本地域的对象描述的包装器。因此,当和Transform等组件进行交互时,大多数指令会请求Unity进入它的本地代码,在那里生成结果,接着降结果赋值回托管域。 这正是托管域和本地域之间本地-托管桥的由来
    当两个域对相同实体有自己的描述时,跨越它们之间的桥需要内存进行上下文切换,这会为游戏带来很多很严重的潜在性能问题。
  3. 第三个内存域外部库。例如DirectX和OpenGL库,也包括项目内包含的很多自定义库和插件。在C#代码中引用这些类库将导致类似的内存上下文切换和后续成本。

运行时的内存空间分为两种类型:
栈: 栈是内存中预留的特殊空间,专门用于储存小的,短期的数据值,这些值一旦超出作用域就会自动释放,因此称之为栈。它就像栈数据结构一样,从顶部压入与弹出数据。栈包含了已经声明的任何本地变量,并在调用函数时处理它们的加载和卸载。这些函数调用通过所谓的调用栈进行拓展与收缩。当对当前函数完成调用栈的处理时,它跳回调用栈中之前的调用点,并从之前离开的位置继续执行剩余内容。之前内存分配的开始位置总是已知的,没有理由执行内存清理操作,因为新的内存分配只会覆盖旧数据。因此栈相对快速高效
栈溢出:栈的总大小很小,大约兆字节MB。当分配超过栈可支持的大小时,可能会导致栈溢出。比如:无限循环或者大量本地变量时。
堆: 表示所有其他的内存空间。当数据类型太大放不下栈内或者必须保持在声明的函数外时,可以在堆上分配它。在物理上栈和堆没什么不同都只是内存空间。不同之处在于使用它们的时机场合和方式。
本地代码中,比如C++编写的语言,这些内存分配需要手动处理,我们要正确的分配所有内存块,并手动显式释放。而在托管语言中,内存释放通过GC自动处理。在初始化时,Mono向操作系统申请一串内存,用于生成堆内存空间(托管堆),供C#代码使用,开始时不到1MB。

1. 垃圾回收

垃圾回收器GC:确保不使用比所需要的更多的托管堆内存,而不再需要的内存会自动回收。GC会标记已经销毁的GameObject,在统一时间回收,而不是立刻回收。GC只会在需要的时候回收内存。System.GC.Collect();手动调用也行。
当请求新的内存空间,而托管的堆内存中有足够的空闲空间以满足改请求时,GC只简单地分配新的空间。如果没有足够的空间,那么GC会扫描上述已标记的不再使用的内存空间并自动调起GC一次,以腾出空间。如果还不行,只能拓展当前堆空间。Mono申请的空间不会被系统回收,IL2CPP不清楚…

可以看出:堆内存空间不足、系统定时自动回收垃圾以及强制GC都会触发垃圾回收操作。频繁的进行堆内存分配和释放将会导致频繁的GC。在Unity中只有值类型局部变量被分配在栈中,它们不会引起GC,其他所有类型的数据都分配在堆中,由GC系统进行回收。
GC引起的性能问题表现:帧率低、性能时好时坏以及断断续续的卡顿。

GC算法:
Unity使用的Mono版本中的GC是一种追踪式GC,使用标记与清除策略。该算法分为两个阶段:
第一阶段:每个分配的对象通过一个额外的数据位进行追踪。该数据位标识对象是否被标记。这些标记设置为false,标识它尚未被标记。
当收集过程开始时,它通过设置对象的标识为true,标记所有依然对程序可访问的对象。对程序而言,任何没有引用的对象本质上都是不可见的,可以被GC回收。
第二阶段:涉及迭代这类引用(GC会在程序的整个生命周期中跟踪这些引用),并且基于它的标记状态决定它是否应该回收。如果被标记,意味着有人依然引用它,GC会忽略。然而,如果没有被标记,那么它是回收的候选者。在这个阶段,所有标记的对象都被跳过,但在下次垃圾回收扫描之前会将它们设置回false。
一旦第二个阶段结束,所有没被标记的都被回收释放空间,然后重新访问创建对象的初始化请求。
本质上GC在内存中维护所有对象的列表,而应用程序维护一个独立的列表,其中只包含它们中的一部分。程序用完对象,就从列表中删除。因此,可以安全回收的对象列表时GC列表和程序列表之间的区别

2. 内存碎片

程序中的所有对象很少以它们分配的顺序被回收,而且它们占用的内存大小很少一样。这导致了内存碎片的产生。

unity 内核 unity内存管理机制_unity 内核_02


问题:1.显著减少新对象的总可用内存空间。导致GC扩展堆空间。2.使新的分配花费的处理时间更长,因为需要额外时间检查足以容纳对象的内存空间。

运行时的垃圾回收步骤:
请求新的内存空间,CPU需要完成的任务:
①.验证是否有足够的连续内存空间。
②.如果没有,迭代所有已知的引用,标记它们是否可达。
③.再次迭代所有这些引用,标识未标记的对象用于回收。
④.迭代所有标识对象,检查回收一些对象是否能为新对象创建足够的连续内存空间。
⑤.如果没有,从操作系统申请新的内存块拓展堆内存。
⑥.在新分配的内存块前面分配新对象。

3.多线程的垃圾回收

GC运行在两个独立的线程上,主线程和Finalizer Thread。当调用GC时,运行在主线程,并标志堆内存块为后续回收。这不会立刻发生,由Mono控制的Finalizer Thread在内存最终释放并可用于重新分配前,可能会延迟几秒。
(可以在Profiler窗口中的Memory Area的Total Allocated块观察此行为(绿线)。垃圾回收后几秒总分配值才会降低。)
由于这个特性,不能依赖内存一旦回收就可用这个观念。必须确保有某种类型的缓冲区用于未来的分配。

2.代码编译

1.Mono虚拟机

C#编写保存后,代码会自动编译。转换为中间语言CIL,它是本地代码之上的一种抽象。这正是.NET所支持的多种语言的原理,无论各种语言最后都会编译为CIL,它类似Java字节码。
CIL本身CPU不知道如何运行该语言中定义的指令。运行时通过Mono虚拟机(VM)运行。虚拟机VM是一种基础架构元素,允许相同代码运行在不同平台,而不需要改代码本身。Mono虚拟机是.NET公共语言CLR运行时的一种实现。 栗子:如果在IOS上运行,就是运行在基于IOS的虚拟机上,Linux上运行的是基于Linux的虚拟机,这正是Unity允许编写一次代码就能在多平台上运行的原理。
中间CIL代码根据需要编译为本地代码。这种及时的编译可以通过AOT(Ahead - Of - Time)或者JIT(Just-In-Time)编译器完成,选择哪个取决于目标平台。这两种编译器主要区别在代码编译的时间。
AOT:是代码编译的典型。发生于构建流程之前,或者是程序初始化前。总之是代码已经提前编译好了,没有后续运行时由于动态编译产生的消耗。当CPU需要机器码时指令时,总是存在可用指令。
JIT:在运行时的独立线程中动态执行,且在指令执行之前。通常,改动态编译导致代码在首次调用时,运行慢一点,因为必须在执行前完成编译。比如:luajit,它是lua的一个Just-In-Time也就是运行时编译器。

2.IL2CPP

IL2CPP用于将Mono的CIL输出直接转换为本地C++代码。由于应用程序现在运行本地代码,因此性能会带来提升。且可以更好的控制运行时行为,因为IL2CPP提供了自己的AOT编译器和VM,允许定制对GC等子系统和编译过程的改进。

3. 分析内存

我们主要关心两个内存管理的问题:消耗了多少内存,以及分配新内存块的频繁程度。

1.分析内存消耗

可以通过Profiler窗口的Memory Area观察已经分配了多少内存,以及该内存域预留了多少内存。本地内存分配显示在标记为Unity的值中,甚至可以使用Detailed Mode和采样当前帧,获取更多详细信息。

unity 内核 unity内存管理机制_垃圾回收_03


使用 Profiler.GetRuntimeMemorySize() 方法获取特定对象的本地内存分配。

unity 内核 unity内存管理机制_值类型_04


Mono标签的值为托管堆内存

也可以在运行时分别使用 Profiler.GetMonoUsedSize()和Profiler.GetMonoHeapSize() 方法确定当前使用和预留的堆空间。

2. 分析内存效率

内存效率:内存管理健康度的最佳指标是检查GC,它做的越多,产生的浪费就越多,而程序的性能可能就越差。
(可以同时使用Profiler窗口的CPU Usage Area(GarbageCollector复选框)和Memory Area(GC Allocated复选框)以观察GC的工作量和执行时间。)
当观察GC行为的峰值时,它可能只是请求GC扫描许多碎片化的内存,确定是否有足够的空间,并决定是否能分配新的内存块。它清理的内存可能在很长一段时间之前就分配好了,只有应用程序运行了很长时间,才能观察到这些影响,甚至在场景相对空闲时也会发生,并没有突然触发GC的明显原因。更糟的是,Profiler只能指出最后几秒发生了什么,而不能直接显示正在清除什么数据。

4.内存管理性能增强技术

在大多数游戏引擎中,如果遇到性能问题,可以将低效的托管代码移植到更快的本地代码中。但是Unity是不开源的,除非投入大量资金获得Unity源代码,否则这不是一个有用的选项。

1. 垃圾回收策略

一种策略是在合适的时间手动触发垃圾回收。垃圾回收可以通过 System.GC.Collect() 手动调用。比如:加载场景时。当游戏暂停时。在切换场景时。或任何玩家观察不到或不关心突然的性能下降而打断游戏的行为时。
甚至可以在运行时使用 Profiler.GetMonoUsedSize()和Profiler.GetMonoHeapSize() 方法决定最近是否需要调用垃圾回收。

垃圾回收GC可以引发一些指定对象的释放。比如讨论的对象是Unity对象包装器之一,例如GameObject或MonoBehaviour组件,那么终结器(finalizer)将在本地域中首次调用Dispose()方法。此时,本地域和托管域里消耗的内存都将被释放。在一些特殊情况下,如果Mono包装器实现了IDisposable接口类(即它在脚本代码中提供Dispose()方法),那么可以真正控制该行为,并强制内存立刻释放。
实现了IDisposable接口类,例如:NetworkConnection, WWW, UnityWebRequest, UploadHandler, DownloadHandler, VertexHelper, CullingGroup, PhotoCapture, VideoCapture, PhraseRecognizer, GestureRecognizer, DictationRecognizer, SurfaceObserver等都是用于拉取大数据集的工具类。通过调用脚本代码的Dispose()方法,可以确保在需要时及时释放内存缓冲区。

Resources.UnloadUnusedAssets():实际的资源数据存储在本地域里,因此该方法不涉及垃圾回收技术,但思想基本相同。它是一个异步处理,不能保证什么时候释放。该方法在加载场景之后由内部自动调用,但这依然不能保证立刻释放内存。首选的方法是使用 Resources.UnloadAsset(),一次卸载一个指定资源。该方法通常更快,因为不需要迭代整个资源数据集合,来确定哪个资源是未使用的。

2.手动JIT编译

如果JIT编译导致运行时性能下降,请注意实际上有可能在任何时刻通过反射强制进行方法的JIT编译。反射是C#语言一项有用的特性,它允许代码库探查自身的类型信息、方法、值和元数据。使用反射通常是一个非常昂贵的过程,应该避免在运行时,或者甚至仅在初始化或其他加载时间使用。不这样做容易导致严重的CPU峰值和游戏卡顿。
可以使用反射手动强制JIT编译一个方法,以获得函数指针:

var method=typeof(MyComponent).GetMethod("MethodName");
    if (method != null) {
        method.MethodHandle.GetFunctionPointer();
        Debug.Log("JIT compilation complete!");
    }

这类方法应该仅运行在确定JIT编译会导致CPU峰值的地方。这可以通过重启应用并分析方法的首次调用与随后所有后续调用来验证。调用差异会指出JIT编译的消耗。

.NET类库中强制JIT编译的官方方法是RuntimeHelpers.PrepareMethod(),但在当前Unity使用的Mono版本(Mono 2.6.5)中没有正确实现该方法。在Unity引入更新版本的Mono工程之前,应该使用前面的方法。

3.值类型和引用类型

引用类型:当GC在执行标记-清除算法时,只有引用类型需要被GC标记。由于引用类型的复杂性、大小和使用方式,它们会在内存中存在一段时间。大的数据集和从类实例化的任何类型的对象都是引用类型。这也包括数组(不管它是值类型的数组或是引用类型的数组)、委托、所有的类。
引用类型通常在堆上分配,而值类型可以分配在栈或堆上。诸如bool、int和float这些基础数据类型都是值类型的示例。这些值通常分配在栈上,但一旦值类型包括在引用类型中,例如类或数组,那么暗示该值对于栈而言太大,或存在的时间需要比当前的作用域更长,因而必须分配在堆上,与包含它的引用类型绑定在一起。

以下代码创建一个整型作为值类型,该值类型暂时存在于栈中:

public class TestComponent {
        void TestFunction() {
            int data=5; // 在栈上分配
            DoSomething(data);
        } // 此时整数从栈中释放
    }

一旦Start()方法结束,整数就从栈上释放。这基本是一个无消耗操作,如前所述,它不需要做任何清理工作,仅将栈指针移回调用栈中的前一个内存位置(返回到TestComponent对象上的TestFunction()函数)。任何后续的栈分配会简单地覆盖旧数据。更重要的是,创建数据没有进行堆分配,因此GC不需要跟踪这些值是否存在。
然而,如果将一个整数创建为MonoBehaviour类定义的成员变量,那么它现在包含在一个引用类型(类)中,必须与它的容器一起分配在堆上:

public class TestComponent : MonoBehaviour {
    private int _data=5;
    void TestFunction() {
        DoSomething(_data);
    }
}

整型_data现在是一个额外的数据块,它消耗了包含它的TestComponent对象旁边的堆空间。如果TestComponent被销毁,那么整数也随之释放,不会在此之前释放。
类似地,如果将整数放到普通的C#类中,那么适用于引用类型的规则依然生效,对象会分配在堆上:

public class TestData {
         public int data=5;
    }
 
    public class TestComponent {
        void TestFunction() {
            TestData dataObj=new TestData(); // allocated on the heap
            DoSomething(dataObj.data);
        } // dataObj is not immediately deallocated here, but it will
        // become a candidate during the next GC sweep
    }

因此,在类方法中创建临时值类型与将长期值类型存储为类的成员字段有很大的区别。在前一种情况下,将其保存在栈中,但后一种情况中,将其保存为引用类型,这意味着它可以在其他地方引用。例如,假定DoSomething()使用成员变量保存dataObject的引用:

public class TestComponent {
    private TestData _testDataObj;
 
    void TestFunction() {
        TestData dataObj=new TestData(); // allocated on the heap
        DoSomething(dataObj.data);
    }
 
    void DoSomething (TestData dataObj) {
        _testDataObj=dataObj; // a new reference created! The referenced
   // object will now be marked during Mark-and-Sweep
    }
}

本例中,不能在TestFunction()方法结束时释放dataObj的指针,因为对该对象的总引用数量从2变为1。由于不是0,因此GC依然会在标记-清除期间标记它。需要在对象无法再访问之前设置_testDataObj为null,或让它引用别的对象。
注意,值类型必须有一个值且不能为null。如果栈分配的类型被赋予引用类型,那么数据会简单地复制。即使对于值类型的数组,也是如此。

public class TestClass {
    private int[] _intArray=new int[1000]; // 充满值类型的引用类型
 
    void StoreANumber(int num) {
       _intArray[0]=num; // 在数组中存储值
    }
}

当创建初始数组时(对象初始化期间),会把1000个整数分配在堆上,并设置值为0。当调用StoreANumber()方法时,num的值只是复制到数组的第0个元素,而不是保存指向它的引用。
引用功能的细微变化最终决定了某个对象是引用类型还是值类型,应该在有机会时尝试使用值类型,这样它们会在栈上分配而不是在堆上分配。任何情况下,只要发送的数据块的寿命不比当前作用域更长时,就是使用值类型而不是引用类型的好机会。表面上,数据是传递给相同类的另一个方法还是传递给另一个类的方法都不重要,它依然是一个值类型,该值类型存在于栈上,直到创建它的方法退出作用域。

5.Unity关于Mono和IL2CPP的未来