目录
第8章 掌握内存管理
不必要的内存分配由于过高的垃圾回收(消耗宝贵的CPU时间)将导致糟糕的用户体验,而内存泄漏将导致崩溃。这些情况在现代游戏的版本中是不能接受的。
如果长期脱离底层问题,可能导致潜在的灾难。
理解内存分配和c#语言特性在做什么,它们如何与Mono平台交互以及Mono如何与底层的Unity引擎交互,绝对是编写高质量、高效脚本代码的关键。
本章学习:Mono平台、C#语言、IL2CPP以及.NET Framework。
8.1 Mono平台
Mono是一个开业项目,它基于API、规范和微软的.NET Framework的工具构建了自己的类库平台。本质上,它是.NET类库的开源重制,在几乎不访问原始代码的情况下完成,和微软的原始类库完全兼容。使Unity具有很多跨平台功能。
Unity Technologies处于速度的原因构建了本地c++后端,允许它的用户将Mono作为脚本开发界面,控制该游戏引擎。因此,Mono只是底层Unity引擎的一个组成部分。
-----------------------------------------
Unity引擎需求也是需要跨平台,支持多语言(C#,Js,Boo)。就参考微软开发.Net Core的概念,于是,推出了Mono.
--------------------------------------------
脚本语言、托管语言、本地代码、性能差异
至少多余游戏开发而言,使用托管语言通常只意味着,相对本地代码,开发者有着一些独特的关注点。偏好、开发速度控制的妥协。
内存域
Unity引擎中的内存空间本质上可以划分为3个不同的内存域:托管域、本地域、外部库。
托管域:该域是Mono平台工作的地方,是脚本和C#类运行时的地方,该域的内存空间自动被垃圾回收管理。
本地域:我们仅仅间接地与之交互。Unity有一些底层的本地代码功能,它由c++编写,并根据目标平台编译到不同的应用程序中。该域关心内部内存空间的分配,如为各种子系统(如渲染管线、物理系统、用户输入系统)分配资源数据(例如纹理、音频文件和网格等)和内存空间。最好,它包括GameObject和Component等重要游戏对象的部分本地描述,以便和这些内部系统交互。这也是大多数内主Unity类(如Transform和Rigidbody)保存其数据的地方。
托管域也包括存储在本地域中的对象描述的包装器。因此,当和Transform等组件交互时,大多数指令会请求Unity进入它的本地代码,在那里生成结果,接着将结果复制回托管语。这正是托管域和本地域之间本地-托管桥的由来。
外部域:包括例如DirectX和OpenGL库,也包括项目中包含的很多自定义库和插件。在C#代码中引用这些类库将导致类似的内存上下文切换和后续成本。
操作系统内存空间分为堆和栈。
在物理上堆和栈没有什么不同,它们都只是内存空间,包含在于RAM中的数据字节。操作系统会请求并保存这些数据字节。不同之处在于使用它们的时机、场合和方式。
在托管语言总,内存释放通过垃圾回收器自动处理。在Unity程序的初始化期间,Mono平台向操作系统申请一串内存,用于生成堆内存空间(托管堆),供c#代码使用。
1.垃圾回收
垃圾回收器(GC)确保不使用比所需要的更多的托管堆内存,而不再需要的内存会自动回收。
Unity使用的Mono版本的GC是一种追踪式GC,它使用标记和清除策略。
可以安全回收的对象列表是GC的列表和程序列表之间的区别。
2.内存碎片
当以交替的顺序分配和释放不同大小的对象时,以及当释放大量小对象,随后分配大量大对象时,就会出现内存碎片。
新的内存分配在内存上必须始终是连续的,无法跨越不同的内存部分切分对象。
内存碎片导致两个问题:1.显著地减少新对象的总可用内存空间。2.使新的分配花费的处理时间更长。
3.运行时的垃圾回收
智能控制堆空间至关重要。内存使用策略越懒惰,GC的运行就越糟糕,且变糟糕的速度几乎是指数级别的,因为越由可能触发这种最糟糕的情况。
讽刺的是,尽管托管语言努力让内存管理问题更简单,托管语言的开发者依然发现自己对内存消耗的关心不亚于本地程序的开发者。主要不同之处在于它们要解决的问题的类型。
4.多线程的垃圾回收
GC运行在两个独立线程上:主线程和所谓的Finalizer Thread。当调用GC时,它运行在主线程上,并标记堆内存块为后续回收。这不会立刻发生。由Mono控制的Finalizer Thread在内存最终释放并可用于重新分配前,可能会延迟几秒。
唯一的安全假设是一旦内存分配给Mono,它就会被保存,不再可用于本地域或相同系统上运行的任何其他程序。
8.2 代码编译
当修改了c#代码,并且从喜欢的IDE切换到Unity编辑器时,代码会自动编译。------------这里有个问题,随着项目的增大,这个自动编译的速度越来越慢了,严重影响开发效率,改一点,编译2分钟,改一点,编译两分钟,有时候我只是想看看是否由语法错误或者看看场景查查数据而已。
c#代码没有直接转化未机器码,而是转换为通用中间语言(CIL),它是本地代码之上的一种抽象。CIL类似Java字节码,基于Java字节码,CIL本身是没用的,因为CPU不知道如何运行该语言中定义的指令。
在运行时,中间代码通过Mono虚拟机(VM)运行,VM是一种基础架构元素,允许相同的代码运行在不同的平台,而不需要修改代码本身。Mono虚拟机是.NET公共语言运行时(CLR)的一个实现。
在CLR中,中间CIL代码实际上根据需要编译为本地代码。这种及时的本地编译通过AOT(Ahead-Of-Time)或JIT(Just-In-Time)编译器完成,选择一种取决于目标平台。
AOT编译是代码编译的典型行为,它发生在构建流程之前,在一些情况下则在程序初始化之前。代码都提前编译。
JIT编译在运行时的独立线程中动态执行,且在指令执行之前。
格言:90%的工作只由10%的代码完成。这通常意味着JIT编译对性能的优势比简单直接解释CIL代码强。然后,由于JIT编译器必须快速编译代码,它不能使用很多静态AOT编译器可以使用的优化技术。---但是,AOT的优化技术好像会导致一些代码问题。
不是所有平台都支持JIT编译,当使用AOT时一些脚本功能不可用。
ExecutionEngineException: Attempting to call method 'AOTProblemExample::OnMessage<AOTProblemExample+AnyEnum>' for which no ahead of time (AOT) code was generated.
at Manager.SendMessage[T] (IReceiver target, .T value) [0x00000] in <filename unknown>:0
at AOTProblemExample.Start () [0x00000] in <filename unknown>:0
IL2CPP
几年前,Unity Technologies面临一个选择,要么继续支持Unity越来越难跟上的Mono平台,要么实现自己的脚本后端。Unity Technologies选择了后者,而现在有很多平台支持IL2CPP,它是中间语言到C++的简称。----好像现在很多都是两种都支持的。
AOT需要IL2CPP的支持,Mono是脚本,用JIT吗?
------
IL2CPP是一个脚本后端,用于将Mono的CIL输出直接转换为本地C++代码。
IL2CPP自动在iOS和WebGL项目中启用。
https://docs.unity3d.com/Manual/IL2CPP.html
8.3 分析内存
两个问题:消耗了多少内存,以及分配新内存块的频繁程度。
8.3.1 分析内存消耗
无法直接控制本地域中发生的事情,都隐藏在本地-托管桥后面,可以通过各种脚本级别的函数间接控制它。
可以通过Profiler的MemoryArea观察已经分配了多少内存,以及该内存域预留了多少内存。
在细分视图下的SceneMemory部分,可以观察MonoBehaviour对象不管成员数据如何,总是消耗恒定数量的内存。这是对象的本地描述所消耗的内存。
Edit Mode下的内存消耗通常和独立版本大不相同,因为应用了各种调试以及编辑器挂接数据。
可以使用Profiler.GetRuntimeMemorySize()方法获取特定对象的本地内存分配(测试)
8.3.2 分析内存效率
可以用于度量内存管理的最佳指标是简单观察GC的行为。它做的工作越多,所产生的浪费就越多,而程序的性能可能就越差。
可以同时使用Profiler窗口的CPU和Memory以观察GC的工作量和时间。
内存效率问题的根源分析是一件具有挑战且耗时的操作。
如果想要确定没有产生内存泄漏,必须谨慎并严格测试程序。
8.4 内存管理性能增强
本地插件通常用于与不是针对c#构建的系统和库交互。
此时,应该对Unity引擎的内部原理以及内存空间有足够了解,以检测、分析和理解内存性能问题,并实现对它们的优化。
8.4.1 垃圾回收策略
最小化垃圾回收问题的一种策略是在合适的时间手动触发垃圾回收,当确定玩家不会注意到这种行为时就可以偷偷触发垃圾回收。垃圾回收可以通过System.GC.Collect()手动调用。
可以在运行时使用Profiler.GetMonoUsedSize()和Profiler.GetMonoHeapSize()方法决定最近是否需要调用垃圾回收。
通过调用脚本代码的Dispose()方法,可以确保在需要时及时释放内存缓冲区。
其他所有资源对象提供某种类型的卸载方法以清除任何未使用的资源数据,例如Resources.UnloadUnusedAssets()。实际的资源数据存储在本地域里,因此该方法不涉及垃圾回收技术,但思想基本相同。
首选的方法时使用Rsources.UnloadAsset(),一次卸载一个指定资源。
最好的垃圾回收策略是避免垃圾回收。
8.4.2 手动JIT编译
如果JIT编译导致运行时性能下降,请注意实际上有可能在任何时刻通过反射强制进行方法的JIT编译。使用反射通常是一个非常昂贵的过程。
8.4.3 值类型和引用类型
引用类型通常在堆上分配,而值类型可以分配在栈和堆上。
1.按值传递和按引用传递
2.结构体是值类型
3.数组和引用类型
4.字符串是不可变的引用类型
懒惰的字符串编程将导致很多不必要的堆分配和垃圾回收。
8.4.4 字符串连接
基于字符串的内存浪费中最大的问题是使用+操作符和+=操作符连接字符串,因为它们将导致分配链效应。每次执行+或+=操作符时,将进行新的堆分配;一次只合并一对字符串,每次都会为新字符串分配堆内存。-----为什么不能智能判断,一次分配并合并呢?
生成字符串的更好方法时使用StringBuilder类或者字符串类的各种用于字符串格式化的方法。
1.StringBuilder
StringBuilder实际上是一个基于可变字符串的对象,工作方式类似于动态数组。
应该尽可能预判需要的最大大小,并提前分配足够大小的缓冲区,以避免拓展缓冲区。
2.字符串格式化
字符串类有3个生成字符串的方法:string.Format(),string.Join(),string.Concat()。这些都是一步完成,没有多余的字符串分配。
8.4.5 装箱(Boxing)
装箱仅在通过转化或强制转化将值类型视为引用类型时发生。
可以通过装箱基本类型将其当成对象,转换它们的类型,随后将它们拆箱回不同的类型,但每次这么做将导致堆内存分配。
可以通过多个System.Convert.To...()方法转换装箱对象的类型。
8.4.6 数据布局的重要性
数据在内存中组织方式的重要性很容易被遗忘,但如果处理得当,会带来相当大的性能提升。
本质上,我们希望将大量引用类型和大量值类型分开。
8.4.7 UnityAPI中是数组
UnityAPI中有很多指令会导致堆内存分配,这些需要注意。本质上包括了所有返回数组数据的指令。例如:
GetComponents<T>();// (T[])
Mesh.vertices;//(Vector3[])
Camera.allCameras;//(Camera[])
每次调用Unity返回数据的API方法时,将导致分配该数据的全新版本。这些方法应该尽可能避免,或者仅调用很少次数并缓存结果,避免比实际所需更频繁的内存分配。
8.4.8 对字典键使用InstanceID
常用的做法是将MonoBehaviour或ScriptableObject引用作为字典的键,这比较慢。可以使用Object.GetInstanceID()作为键。但是,应该以某种方式缓存整数值,避免频繁调用Object.GetInstanceID()。
8.4.9 foreach循环
在Unity的c#代码中,很多foreach循环最终会在调用期间导致不必要的堆内存分配。
可以让VisualStudio执行代码的编译,并将结果程序集DLL复制到Asserts文件夹中,这将修复泛型集合的错误。
应该尽量避免foreach风格的代码(使用for)。
8.4.10 协程
应该尝试避免产生太多短时间的协程,并避免在运行时调用太多StartCoroutine()。
8.4.11 闭包
闭包是很有用但很危险的工具。匿名方法和lambda表达式可以是闭包,但不总是闭包。这取决于方法是否使用了它的作用域和参数列表之外的数据。
8.4.12 .NET库函数
LINQ和正则表达式在使用时会造成重大的性能问题,这往往是因为它们只作为对给定问题的应急解决方案,而没有花太多精力进行优化。
LINQ的简单的代码风格和复杂的底层系统(通过使用闭包)暗示着,它有相当大的性能消耗。
相对于Regex类使用的正则表达式,直接的字符串替换可能更高效
8.4.13 临时工作缓冲区
如果习惯于为某个任务使用大型临时工作缓冲区,就应该寻找重用它们的机会。
8.4.14 对象池
对象池通过避免释放和重新分配,来最小化和建立对内存使用的控制的一种极好方法。
代码实现一个对象池:...
public interface IPoolableObject
{
void New();
void Respawn();
}
using System.Collections.Generic;
public class ObjectPool<T> where T : IPoolableObject,new()
{
private Stack<T> _pool;
private int _currentIndex=0;
public ObjectPool(int initialCapacity)
{
_pool=new Stack<T>(initialCapacity);
for(int i=0;i<initialCapacity;++i)
{
Spawn();
}
Reset();
}
public int Count{
get{
return _pool.Count;
}
}
public void Reset()
{
_currentIndex=0;
}
public T Spawn(){
if(_currentIndex<Count){
T obj=_pool.Peek();
_currentIndex++;
IPoolableObject po=obj as IPoolableObject;
po.Respawn();
return obj;
}
else{
T obj=new T();
_pool.Push(obj);
_currentIndex++;
IPoolableObject po=obj as IPoolableObject;
po.New();
return obj;
}
}
}
using UnityEngine;
public class TestObject : IPoolableObject
{
public void New()
{
Debug.Log("TestObject.New");
}
public void Respawn()
{
Debug.Log("TestObject.Respawn");
}
}
使用:
private ObjectPool<TestObject> _objectPool=new ObjectPool<TestObject>(100);
包装Vector3
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PoolableVector3 : IPoolableObject
{
public Vector3 vector=new Vector3();
public void New()
{
Reset();
}
public void Respawn()
{
Reset();
}
public void Reset()
{
vector.x=vector.y=vector.z=0f;
}
}