Unity内存管理的原理

【前言】 

当我们谈及Unity内存管理时,我们更多的是在说手游项目上如何更好的去管理内存,如果是在端游项目上,没有那么多讲究,内存随便用。

【为什么手机上内存不够用】

CPU读写速度远快于内存的速度,大多数时候CPU都在等内存给数据,为了缓解主存速度慢、跟不上CPU读写速度要求的矛盾,进而提高程序运行效率,CPU设计时引入了高速缓冲存储器。在PC中,CPU一般有三级缓存 ,大小共8~16M。而手机没有独立显卡、独立显存,CPU和GPU共用一个缓存,其内存更小,缓存级数更少、大小仅为2M。

PC有内存交换机制,在物理内存不够用时,操作系统会将不用的数据(DeadMemory)交换到硬盘上。而手机不做内存交换,一是因为移动设备的硬盘IO速度比PC慢很多,二是因为移动设备的硬盘可擦写次数更少,频繁擦写会减少设备使用寿命。Android机上没有做内存交换,IOS可以把不活跃的内存进行压缩,使得实际可用的内存更多,这就是为什么同样大的物理内存,IOS的虚拟内存会标的更大。

【Android的内存管理】

Android的内存管理基本单位是Page(页),一般是4k 一个Page。内存的回收和分配都是以 Page为单位进行操作。Android内存分用户态和内核态两个部分,内核态的内存是用户严格不能访问。的。

内存杀手(low memory killer,LMK):

Unity内存管理的原理_第1张图片

 当手机内存不够用时,就会出现LMK,按照优先级,杀掉手机内的各种应用和服务,直到内存够使用为止。其优先级如下:

0、Native:系统内核

1、System:系统服务

2、Persistent:用户服务,比如电话、蓝牙、Wifi等。这一层被杀掉,系统重启。

3、Foreground:前台应用,当前正在使用的Activity。主要是前台应用不断使用内存,造成内存不够,这一层被杀掉,前台应用闪退。

4、Perceptible:辅助应用,音乐、搜索、键盘等;这一层被杀掉,音乐突然没了,键盘没响应。

5、Service:驻后台线程的服务,云同步、垃圾回收等;

6、Home键;这一层被杀掉,桌面重启,APP图标一个个重新出现,壁纸可能没了。

7、Previous:上一个使用的应用;这一层被杀掉,继续切换到上一个应用时,该应用会重启。

8、Cached:后台,之前使用过的各种应用。这一层被杀掉,会发现切换到后台的那个应用时,该应用重启了。

内存指标

RSS,resident set size:当前的APP所应用到的所有内存,包括自己的APP所使用的内存和调用的各种服务、共用库所产生的内存。

PSS,proportional set size:PSS会把公共库所使用的内存平摊到所有调用这个库的APP上,如果调用的某个公共库已经有了很大的内存分配,平摊下来就会导致自己的APP的PSS虚高。

USS,unique set size,指自己的APP使用的内存,实际工作中的优化指优化USS。

【Unity游戏运行时占用的内存】

游戏运行时使用的内存可以分为五类:一是程序代码内存(Code Memory)、二是本地内存(Native Memory)、三是托管内存(Managed Memory)、四是用户管理的内存、五是Lua管理的内存。

Unity的内存管理主要指二和三。注意,在Editor下和在Runtime下Unity的内存分配的时机、方式、大小不同的,有时查看内存使用情况需要连真机查看。

Code Memory

包括了所有的Unity引擎代码,使用的库,第三方插件代码、自己写的代码等。这些代码在打包后会被编译成特定的程序代码文件,这些文件在游戏运行前需要一次性加载到内存中,在退出游戏后再从内存中卸载。在整个游戏运行过程中,程序代码一直占用一定的内存,无法在运行时管理或优化,只能在打包前减少程序代码的大小。

Native Memory

Unity底层是用C++写的,除了一些Editor里面的Services可能会用到NodeJS这些网络的语言,Runtime里面用到的每一行Unity底层代码全是C++的,其分为三层:

  • 最底层的Runtime,全是Native C++代码。
  • 最上层用C#,其中Unity的Editor、一些Package也是C#写的,游戏开发的绝大多数逻辑都是C#写的
  • 中间叫Binding,可以看见很多的.bindings.cs文件(基于C#的binding语言,一开始是Unity自定义的一种语言),这些文件的作用就是把C++和C#联系在一起,为C#层提供所有的API

我们平时使用Unity时看见的C# API,都是在Binding层中自定义的。这些文件底层运行的时候还是C++,只是进行了Wrapper(封装)。

早期用户代码是运行在C#上,是MonoRuntime。但是现在可以通过IL2CPP将其转成C++代码,所有现在几乎没有纯正的C#在运行了。

Unity的VM(虚拟机:Virtual Machine)依旧还是存在,主要用于跨平台,有了一层VM抽象后,跨平台的工作会容易很多,IL2CPP本身也是个VM。

Unity C++层重载了所有分配内存的操作符,分配内存的东西叫Allocator。重载后分配内存是需要传入一个额外的参数memory label(可以在Profiler-shaderlab-object-memory-detail-snapshot看到),Allocator 会根据memory label在不同类型池里分配内存。每个类型池里做单独的跟踪,可以根据memory label在runtime获取类型池的大小等信息。

Allocator 在 NewAsRoot 中生成,生成一个所谓的Memory island。它下面会有很多的子内存。例如一个Shader,当我们加载一个shader进内存的时候,首先会生成一个shader的Root,也就是Memory Island。Shader底下的数据,例如Subshader,Pass,Properties等,会作为该Root底下的成员,依次的分配。最后统计Runtime的内存时,统计这些Root即可,而不会统计成员,因为太多了没法统计。

和托管内存堆不同的是,当我们去delete或free一个内存的时候,会立刻返回给系统。

贴图、音效、Mesh、AssetBundle等资源的管理和加载都与Native Memory有关。

Managed Memory

这里的Managed Memory主要指Mono VM的内存池,其内存以Block的形式管理,当一个Block连续6次GC没有被访问到,这块内存会被返回给系统。条件苛刻,比较难触发。

Unity的GC机制是Boehm内存回收,是不分代的,非压缩式的。(C#的GC是分代、压缩的)

分代是指:大块内存、小内存、超小内存是分在不同内存区域来进行管理的。还有长久内存,当有一个内存很久没动的时候会移到长久内存区域中,从而省出内存给更频繁分配的内存。

压缩是指:当有内存被回收的时候,把已经用到的内存里的内容移动到空出来的内存上,已用到的内存紧密排列。非压缩就是空着不管,下次要用了下次要用了再填进去。

IL2CPP 的 GC 机制是 Unity 自己重新写的,是升级版的 Boehm

Unity内存管理的原理_第2张图片 GC不分代、不压缩会造成Memory fragmentation 内存碎片化的问题。如下图所示,当进行内存分配时发现的第一块空闲内存不够时,那就要找一块更大的内存,如果找不到更大的内存,那就要像操作系统申请内存了。如果存在大量的小内存,会造成统计出的空闲内存总量足够多,但仍需要像系统申请内存的现象,也即某一刻游戏占用的内存突然增大,但统计发现有很多内存没用到。

Unity内存管理的原理_第3张图片

为了防止内存碎片化(Memory Fragmentation),在做加载的时候,应先加载大内存的资源,再加载小内存的资源(因为Bohem没有内存压缩),这样可以保证最大限度地利用内存。 

另外,也可能造成僵尸内存Zombie Memory,其不会被用到,但也不会被释放。

 ---下一代GC——Incremental GC(渐进式 GC)

进行GC必须暂停主线程,如果GC时间过长,会造成主线程卡顿。Incremental GC把暂停主线程的事分帧做了,这样主线程不会单次暂停过长时间。用Incremental GC和不用,主线程总体暂停时间(GC回收时间)基本差不多。

User Memory

用户自己管理的内存主要包括Native插件内存、Unsafe代码内存等,这些代码用的是非托管内存靠用户自己管理,要像C/C++一样记得分配和释放内存。

Native 插件大多用C++编写, Unity 无法分析已经编译过的 C++ 是如何去分配和使用内存的Unity无法统计其内部的内存使用情况。因此,Unity的Profile工具无法检测用户自己管理的内存。

Lua Memory

Lua有自己的内存管理方式,需要针对Lua做优化,这个要求比较高。

【Code Memory优化】

整个游戏的代码可以分为四类:引擎代码、库代码、插件代码、业务代码,优化的优先级为库代码>业务代码>插件代码>引擎代码

---库代码:默认引用的一些基础库(例如.Net)包含的东西过于全面,有些在游戏中用不到,可以剥离,Unity中有如何剥离代码的说明。

---业务代码:

对Debug相关的代码要剥离,可以用宏定义Release和Debug,做个工具类统一管理。

如果打包使用IL2CPP,那么在写业务代码时要注意避免模板泛型的滥用。模板泛型的滥用,会影响到代码文件大小以及打包速度。例如一个模板函数有四五个不同的泛型参数(float,int,double等),c++编译的时候我们用的所有的Class,所有的Template最终都会被展开成静态类型(因为是AOT编译,需要事先把所有可能的结果编译出来)。因此当模板函数有很多排列组合时,最后编译会得到所有的排列组合代码,导致文件很大,影响使用IL2CPP时的打包速度。

---插件代码:同库代码一样,插件的有些功能在游戏中用不到,相关代码可以剥离。但剥离的前提是得看懂插件源码知道要剥离哪一部分,要不然剥离多了,很容易打包失败。基本上没谁有耐心把插件源码看懂,性价比低。所以插件代码的优化在于避免引入各种乱七八糟的插件,随意引入各种插件,不仅会使Code Memory增大,还会使得包体增大。(这里认为插件包括:接入的SDK、unity原生插件等)

---引擎代码:这个基本改不了,不用考虑

【 Native Memory优化】

Scene

Scene是导致Native Memory增长的原因最常见原因,在场景中new GameObject会导致Native Memory快速增长。因为是c++引擎,所有的实体最终都会反映在c++上,而不会反映在托管堆上。当我们new GameObject的时候,实际上在Unity的底层会构建一个或多个object来存储这一个GameObject的信息(GameObject可能有很多Component)

Audio

---DSP Buffer,是指一个声音的缓冲,当一个声音要播放的时候,需要向CPU去发送指令。如果声音的数据量非常的小,会造成频繁的向CPU发指令,造成IO压力。在Unity的FMOD声音引擎里面,一般会有一个Buffer,当Buffer填充满了才会去向CPU发送一次播放声音的指令。如果DSP Buffer的值过打,填充满需要很多的声音数据,当我们声音数据不大的时候,就会产生延时。如果DSP Buffer过小,仍可能会频繁向CPU发指令,没起到作用。可以在Project Setting ->Audio中设置DSP Buffer的大小。

---Force To Mono:​这个选项作用是强制单声道,很多音效师为了追求音质会设置成双声道,导致声音在包体和内存中,占用的空间加倍,但是95%以上的声音,两个声道是完全一样的数据。因此对声音不是很敏感的项目建议勾选此项,来降低内存的占用。

---Format:格式设置,不同平台对音频的格式支持不一样,这个纹理格式一个道理,具体在不同平台选择什么格式可以看Unity手册。

AssetBundle

---TypeTree:Unity前后有很多的版本,不同的版本中很多的类型可能会有数据结构的改变,为了做数据结构的兼容,会在对数据类型序列化的时候,生成一个叫TypeTree的东西,以记录当前这个版本用到了哪些变量,它们对应的数据类型是什么,当进行反序列化的时候,根据TypeTree去做反序列化。例如,如果上一个版本的类型在这个版本没有,那在当前版本的TypeTree里就没有它,所以上一个版本的类型不会被序列化。如果有新的类型,但是在当前版本不存在的话,那要用它的默认值来序列化。从而保证了在不同版本之间不会序列化出错。

在Build AssetBundle的时候,有开关可以关掉TypeTree。

BuildAssetBundleOptions.DisableWriteTypeTree

如果AssetBundle和APP都是从相同版本的Unity中Build出来的,就可以关闭TypeTree。这样,一可以减少内存,二AssetBundle包大小会减少,三build和运行时会变快,因为不会去序列化和反序列化TypeTree。所以,我们会经常要求版本一致。

---压缩方式(Lz4和Lzma):现在Unity主推Lz4(也就是ChunkBased,BuildAssetBundleOptions. ChunkBasedCompression),Lz4非常快,大概是Lzma的十倍左右,但是平均压缩比例会比Lzma差30%左右,即包体可能会更大些。Lz4的算法开源。

Lzma基本可以不用了,因为Lzma解压和读取速度都会非常慢,并且占大量的内存,因为不是ChunkBased,而是Stream,也就是一次全解压出来。而ChunkBased可以一块一块解压,每次解压可以重用之前的内存,减少内存的峰值。

---大小和数量:AssetBundle分两部分,一部分是头(用于索引,这部分可以重用),一部分是实际的打包的数据部分。如果每个Asset都打成一个AssetBundle,那么可能头的部分比数据还大。官方建议一个AssetBundle的大小在1-2M,可以根据网络带宽加大。

Resource

Resource文件夹里的内容被打进包的时候会做一个红黑树(R-B Tree)用做索引,即检索资源到底在什么位置。所以Resource越大,红黑树越大,它不可卸载,并在刚刚加载游戏的时候就会被一直加在内存里,极大的拖慢游戏的启动时间,因为红黑树没有分析和加载完,游戏是不会启动的,并造成持续的内存压力。所以建议不要使用Resource,使用AssetBundle。

Texture

---Upload Buffer:在Unity 的 Quality 里设置如图,和声音的Buffer类似,填满后向GPU push 一次。

---Read/Write:没必要的话就关闭,正常情况,Texture读进内存解析完了放到Upload Buffer里之后,内存里那部分就会delete掉。除非开了Read/Write,那就不会delete了,会在显存和内存里各一份。手机内存显存通用的,所以内存里会有两份。

---​Mip Maps:例如UI元素这类相对于相机Z轴的值不会有任何变化的纹理,关闭该选项

---Format:选择合适的Format,可减少占用的空间

​​---alpha:对于不透明纹理,关闭其alpha通道

---POT:纹理的大小尽量为2的幂次方(POT),因为有些压缩格式可能不支持非2的幂次方的

---压缩:[2018.1]Unity贴图压缩格式设置 - 知乎

---合并:打图集

Mesh 

---Read/Write:同Texture,若开启,Unity会存储两份Mesh,导致运行时的内存用量变成两倍

---Compression:Mesh Compression是使用压缩算法,将Mesh数据进行压缩,结果是会减少占用硬盘的空间,但是在Runtime的时候会被解压为原始精度的数据,因此内存占用并不会减少

​​--Rig:如果没有使用动画可以,例如房子,石头这些

--​​-Blendshapes:如果没有用到Blendshapes,也关闭

【Managed Memory优化】 

参考 :unity GC优化

【优化重点及方向】

Managed Memory>第三方库(主要是lua Memory)>Native Memory>User Memory>Code Mamory 

【参考】

Unity 3D中的内存管理 | OneV's Den

[Unity 活动]-浅谈Unity内存管理_哔哩哔哩_bilibili

 Unity的内存管理与性能优化 - 知乎

你可能感兴趣的:(Unity,unity,android,游戏引擎)