oynix

于无声处听惊雷,于无色处见繁花

Unity的内存优化

Unity公开课中,有些内容谈到了这方面的内容,相对于查找网上的资源,学习官方提供的教程算是比较直接的方式。基于这些,同时结合一些其他资料,在这里整理一下有关的知识点。

一、 内存

进程是系统分配资源的最小单位,这其中就包括内存,内存是一种有限的资源。

1. 物理内存

关于物理内存,一般指的是内存条,或者是一些其他的硬件设备。如果按照硬件设备的实际分配,那么系统可以支持的最大内存容量便是物理内存的大小,但实际上通过一些技术手段,使得设备可以支持超过其值的总内存。

2. 虚拟内存

虚拟内存,这是介于进程和物理内存之间的一层抽象。进程只需要关心自己所用的虚拟内存,至于这个内存是来自于物理内存,还是来自于其他,则无需关心。

3. 内存交换

内存交换,指的是将物理内存中暂时不用的数据,交换到硬盘空间,从而将物理内存空出来供系统分配给需要内存的进程,上面刚提到的虚拟内存中,可能一部分内存交换到了硬盘,等到需要的时候,比如从后台切换到了前台,这时再从硬盘交换到物理内存,同时可能还会将后台进程暂时不用的内存交换到硬盘空间。

这里就涉及到一个硬盘擦写的问题,如果常常发生内存交换,那么硬盘的读写数据量就会增加的很快,要知道硬盘的可擦写次数是有限的,也就是硬盘的寿命,不像是物理内存,可擦写次数高的离谱。

Mac上可以通过smartctl -a disk0命令查看硬盘的具体使用情况。我的1TB的硬盘,存了500GB的数据,在经常关掉不用的进程应用的前提下,一年多的时间读写数据已经有几十TB了。

所以,基于这个前提,内存交换这一策略目前也只应用在了PC端,移动设备没有内存交换,一个是其IO速度更慢,其次是存储空间的可擦写次数更少。在有限的资源前提下,移动端对内存的限制更加苛刻。

3. 内存压缩

此外,iOS和Mac上还存在一种内存压缩技术,将内存中暂时用不到的数据压缩,存储到物理内存的特殊区域,从空出更多的内存空间供系统分配,注意,这里还是存储在物理内存设备上,而不是交换到磁盘空间。

4. RSS/PSS/USS

这三个指标,是Android中衡量一个应用对系统造成的内存压力的

  • Resident Set Size,RSS:包括使用到服务所用的内存,以及自己独占的内存
  • Proportion Set Size,PSS:将服务所用的内存公摊,有几个应用在用,就一起分摊
  • Unique Set Size,USS:应用独占的内存

二、 Unity中的内存

按照分配方式,Unity中的内存分为Native Memory原生内存和Managed Memory托管内存,前者主要是写的原生代码,比如C++的第三方依赖库,这里面需要通过内存操作符malloc/free/delete申请和释放内存。而托管的内存主要是我们自己写的C#代码中对内存的申请,这种内存由GC释放。

原生内存在释放后会立即还给系统,而托管的内存需要GC之后才会还给系统,而GC不是实时运行的。

对于分配给进程的内存空间,按照使用方式分为两个部分,一个是栈空间,一个是堆空间。其中,栈空间存储着函数中的局部变量(值类型)以及调用栈,是一些简单数据,可通过简单的编译指令自动清理,不需要GC来回收。堆空间存储着引用类型数据,GC主要发生在这里。

宏观来看,GC要做的事很简单,找到内存中不再使用的数据,将其占用的内存空间释放还给系统,供再次分配使用。当下GC主要分为两个大类型,SGen GC(分代GC)和Boehm GC(贝姆GC)。

1. 分代GC

这种方式将内存空间分为了几个部分,初生代,年轻代,老年代,等。首次分配的对象都出现在初生代内存空间,每次GC后,不再使用的空间将被释放,而还有引用的数据对象则会按照一些规则复制到年轻代、老年代等。

2. 不分代GC

这种方式不需要像上面那样分区处理,简单直接,非常暴力,每次触发GC时,还在使用的内存空间不做处理,不再使用的空间则释放还给系统。

这两种方式各有利弊,不分代GC非常快速,因为不涉及内存复制的问题,但带来的就是内存碎片的问题,在不当的一些代码写法中,这个问题尤为突出。

3. 垃圾收集算法

上面多次提到不再使用的空间则回收,那么如何判定内存中的数据对象是否还在被使用呢?常见的算法分为两种:引用计数法和可达性分析算法。

前者的思路是,在每一个对象上记录其被引用的次数,增加引用时次数加1,减少引用时次数减1,每次GC时,引用次数为0的则是垃圾。这种方式很直观,但是存在一个问题,当两个垃圾对象互相引用时,则二者都无法被回收。

可达性分析法的思路是,从某些可以当作GC Root的对象向下搜索,走过的路径称为引用链,如果一个对象到GC Root没有任何引用链时,则为不可达,也就是垃圾。

4. Unity的GC

Unity使用的不分代GC,由于每次GC就要stop the world,如果某次GC要做的事情多,那么掉帧就会非常明显,为了解决这个问题,又在此基础上,设计了一种Incremental GC,渐进式GC,主要思路是,将每次GC需要做的事分到多个帧操作,从而降低这种潜在影响,注意这里只是将消耗平分到多个帧,总消耗不会降低。

但是这种方式会导致内存分析的问题,毕竟一个操作的消耗分到多个帧,不利于分析。

5. 内存碎片

Unity执行一次GC后,不会将内存中依然存活的对象重新排列,这样会导致内存存中存在内存空档,也就是内存碎片,memory fragmentation,如果后续程序在申请内存时,这块内存空档不符合大小条件,那么这个碎片就会成为僵尸内存,zombie memory,不会被使用,但是一直就在那闲置。僵尸内存过多时,某些申请内存的操作可能就会出发内存溢出异常,因为可用内存不足以满足申请操作。

三、 内存优化

针对以上堆Unity内存的了解,有一些常用的内存优化,提升性能需要注意的地方:

1. Scene

减少场景中的GameObject,Unity底层会创建一个或多个object来存储一个GameObject的信息,比如Component等,GameObject过多时,Native Memory就会明显上升

2. Audio

合理设置DSP Buffer大小,以至于不会因为太大导致声音延迟,或是太小导致的频繁向CPU发送播放指令。
同时,如果特殊情况,音效文件均可设置成Mono单声道,减小对内存的压力。
此外,不同平台应选择不同的压缩格式,iOS使用MP3/ADPCM,Android使用Vorbis。
至于Load Type则是取决于音频文件的大小,200KB以下的使用Decompress On Load,这种形式主要压力在内存,超过200KB的使用Compressed In Memory,这种形式的压力在CPU,因为内存中存放的时压缩过的,所以在播放时需要花性能解压文件;对于背景乐之类的大文件,使用Streaming流的形式,边读取边播放。
Bitrate是对文件大小影响幅度最大的选项,降低文件比特率是收益最高的选项,前提是音效品质可接受。
静音时,不要只是将音量调为0,这样还是会有内存和CPU的占用,而是卸载内存中的文件,同时禁用AudioSource。

3. Code Size

Unity底层是个C++引擎,用到的模版范型在编译时,都会被展开成静态类型,当范型有很多排列组合时,编译后就会得到所有的排列组合代码,增加文件大小,所以,减少范型滥用。

4. Type Tree

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

1
BuildAssetBundleOptions.DisableWriteTypeTree

5. 压缩方式

采用Lz4,ChunkBased,不用Lzma。Lzma是Stream,每次需要全部解压出来,ChunkBasd可以一块一块解压,速度快,还可以重用之前的内存。

6. AssetBundle大小

每个bundle文件分为两部分,一个是头,一个是数据。如果过小,那么头数据占比就会过高,甚至超过数据本身,建议1-2M,也可以适当增大。

7. Resource

其中的资源在启动时就会加载到内存,且不可以卸载,所以避免使用,使用AssetBundle代替。

8. Texture

小图打成图集SpriteAtlas,减少DrawCall次数,至于压缩方式,选用ASTC可是配绝大多数Android和iOS设备,这种压缩方式效果较好,且OpenGL ES 3.0少量设备不支持,到了3.1便全部支持。4x4压缩率是8bit,5x4压缩率是6.4bit,5x5压缩率是5.12,到5x5无明显失真,如有特殊需求,甚至可以使用6x6,压缩率为3.56bit

9. 销毁

使用Destroy,而不是null,前者才能销毁对象

10. class和struct

strut是值类型,存储在栈空间,class是引用类型,存储在堆空间,频繁创建和销毁时,可用struct替换class,减少触发GC的频率,避免内存抖动。

11. 拆箱/装箱

LINQ和常量表达式以装箱的方式实现,以及字符串相关操作也尝尝产生装箱操作,比如string.Format中int参数,调用ToString可解决。
调用Struct的Equals方法时,默认传入的是class,所以需要重写该方法。

12. 对象池

频繁创建的class,使用对象池复用内存,减少内存压力,注意控制内存池的大小,过小容易频繁添加,过大可能造成浪费。

13. 闭包和匿名函数

这些在c#编译到IL代码时,都会被new成一个class或是匿名class,这里面的函数、变量以及new的东西,都会占用内存。

14. 协程

属于闭包和匿名函数的特例,在协程释放前,其中持有的变量会一直占用内存。需要时再创建,不用了就销毁释放。

15. 配置表

按需加载,而不是一下加载所有,降低对内存的压力。

16. 单例

需谨慎,生命周期极长,其中的变量会一直占用内存。

17. 属性

如果特殊需求,使用变量替代。调用属性,本质上是调用get/set方法,调用方法对产生入栈出栈的操作,而获取变量则不会。

四、 拆箱/装箱

拆箱,即为将引用类型转换为值类型的过程,反之则为装箱。这两个操作都会消耗CPU时间和内存空间,列举一些容易出现拆箱/装箱的操作:

  • ArrayList,非范型集合,存储值类型时会装箱,取值时拆箱,可使用List替代,此为范性集合
  • 定义strut时,需要重写Equals方法,缺省传入参数类型为object
  • 格式化字符串时,参数为值类型可能会导致装箱,可以手动调用toString方法转换为字符串对象

参考链接

  • Memory Management in Unity
  • Unity的内存管理与性能优化
  • Unity活动 浅谈Unity内存管理(镇店宝)
  • Unity活动 Unit Now 性能优化技巧(上)
  • Unity活动 Unit Now 性能优化技巧(下)
  • Unity内存与资源管理
  • Unity内存优化总结
------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2024/03/8d7af1606e67/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道