Unity内存详解

整理自2019.12.15 Unity UUG北京的演讲 讲师:高川(Unity大中华区企业技术支持经理)

什么是内存

  • 物理内存

    1.CPU访问内存是一个慢速过程
    因此会使用cache来加速访问,大家可以留意CPU的板载示意图,占据最大面积的是Cache模块,如下图
    在这里插入图片描述
    CPU如果在Cache中没有找到数据,称为一次Cache Missing,如果内存数据 指令是不连续的,会导致大量的Cache Missing
    在这里插入图片描述
    2.Unity的ECS和DOTS的目的之一就是提高内存的连续性,减少Cache Missing
    3.台式设备与移动设备的内存架构差异很大
    4.目前很多厂商提出手游端游化,但是需要注意的是,在技术上是不可能的(注:应该可以理解为使用的优化策略不同)
    5.移动设备没有独立显卡以及显存,有的时候,你的内存使用并不大,但还是会内存溢出,例如在Android上,可以看一下有没有OpenGL的out of memory的log,这意味这显存使用太大了
    6.CPU板上面积更小,缓存级数更少,大小也更小,例如一台台式机,L3的大小为8-16M,而移动的CPU,例如骁龙845,只有2M,Cache Missing的概率更大

  • 虚拟内存

    1.内存交换
    移动设备不支持内存交换(注:就是把部分物理内存中的数据保存在硬盘上),谷歌的解释是移动设备和台式设备的IO速度是不一样的,因此移动设备在做内存交换的时候会耗费大量的时间在IO上,并且移动设备的存储介质的可擦写次数和台式设备差距也很大
    2.iOS可以进行内存压缩
    把不活跃的内存压缩起来,放在特定的内存空间中,以节约物理内存,所以在iOS上可以看到有的时候实际使用的内存比物理内存大很多

  • 内存寻址范围

    1.32位CPU和64位CPU
    严格来说,它们的寻址范围是无法确定谁高谁低的,CPU的位数指的是运算位数,不是MCU位数,只是目前大部分是对应的,即32位CPU对应32位的寻址范围,64位CPU对应64位的寻址范围

Android的内存管理

Android的内存大,但是管理不太好

  • 内存的基本单位 Page

    1.默认4K一个page(与linux相同),但并不意味是4K对齐的
    2.回收和分配以page为单位
    3.分为用户态和内核态,常见的一个问题是,用户态中的一个指针越界到内核态,该程序就会挂掉

  • 内存管理工具-low memory killer(AKA lmk)

    一些现象都和它有关,例如闪退 服务消失 手机重启等
    Android内存分为9层,如下图
    在这里插入图片描述当内存不足时,killer会从下往上杀应用,当杀到前台(Foreground)时,你的应用就会闪退,如果继续使用内存,会一致杀到System层,这时设备会重启

  • 内存指标 *SS

    1.Unique Set Size (USS)
    当前应用分配的所有内存,这项指标是完全由开发者控制的内存指标
    2.Proportional Set Size (PSS)
    除了应用分配的内存,即USS外,还会加上应用调用的公共库或公共服务的公摊内存,例如应用本身分配了100M内存,调用了一个Google Service,该Service分配了10M内存,当前调用该Service的应用共10个,即每个应用均摊1M内存,因此PSS就是100+1,即101M,如果此时又有一个应用调用了该Service,均摊大小还会继续减少,值得注意的是,PSS较为准确的反应了应用对系统造成的压力,所以在很多系统中都是用PSS作为指标,如果PSS很大,USS很小,应该检查是不是调用了某个内存占用很大的公共服务
    3.Resident Set Size (RSS)
    当前应用使用到的所有内存,例如自身分配了100M,调用了Google Service,该Service分配了10M,那么RSS就是100+10,即110M,由于Service可以被很多应用使用,因此该指标的意义不大

Unity内存管理

  • Unity是C++引擎

    主要由三层构成
    1.底层Runtime全部有C++构成
    2.中层为bonding层(注:不确定是不是这个单词,就是一个粘合层),以前使用Unity自定义的语言,但是因为开发不方便,现在主要使用C#了,作用是将C#和C++连接在一起,大家用到的Unity的API都是在这一层提供的,底层运行的还是C++,这只是一个warpper封装
    3.上层就是C#构成的用户代码

  • 用户代码在il2cpp模式下会转换为cpp代码

  • VM依然存在
    il2cpp本身也是一个VM,目的是跨平台(注:il2cpp使用到了基于LLVM的技术实现跨平台编译,感兴趣的可以自行百度)

4.Unity内存分配方式

  • Native Memory
  • Managed Memory
  • Editor
    Editor和Runtime是完全不同的,不仅是内存大小不同,分配时机,方式都不同,例如一个asset,在Runtime下,不主动load,不会进内存,Editor下,只要打开Unity,就可能会加载进内存,这种策略是为了保证编辑时的流畅,这种策略也导致首次打开Unity项目耗时特别长,会转换资源,再load相关资源,在2019.3中有了新的策略,按需导入和加载

5.Unity内存管理方式

  • 引擎管理内存
  • 用户管理内存
    即profiler中C#分配的托管内存

6.Unity监测不到的内存

  • 用户分配的native内存
    例如自己写的一个C++插件分配的内存
  • lua分配的内存

Unity Native Memory

Unity重载了C++所有内存分配的操作符

  • Allocator与memory lable
    memory lable是内存操作符需要的一个参数,就是在profiler中的各种lable,作用是将这块内存分配到哪一个类型的Allocator池中,每一个Allocator池单独做自己的跟踪
  • NewAsRoot
    所有的Allocator的生成都是在执行NewAsRoot的操作符的前提下生成的,NewAsRoot会生成memory island作为一个root,在这个root下面会有很多子内存,例如一个shader,加载一个shader的时候,会生成该shader的root,每个shader会有很多的子数据,例如subshader pass等,会作为该root的子内存,在统计Runtime的内存时,只会统计root
  • 会及时返还给系统

Native内存最佳实践

  • Scene
    注意scene中GameObject的数量是否过多,数量过多会导致native内存显著增涨,在创建一个GameObject的时候,Unity会在C++中构建一个或者多个的Object来保存相关信息,因此,当发现Native内存过大时,优先检查Scene中的GameObject数量

  • Audio
    1.DSP buffer
    Unity中对应多档的设置,当需要播放声音时,会向CPU发送对应指令,如果指令发送太频繁,会导致CPU和IO压力 ,因此很多的音频插件,例如Unity中使用的,会使用DSP buffer,当这个buffer被填满之后,再发送指令,所以当这个buffer越大,CPU压力越小,内存占用越多,声音延迟也越大,因为每次都要等这个buffer被填满后才能播放,在一些Android设备上常常出现声音延迟过大,可以优先看看这个选项
    2.Force to mono
    很多的音频都是双声道的,但是左右声道完全一致,这就会导致内存和包体空间的浪费,在这种情况下,开启这个选项,会强制为单声道,减少内存和包体大小,对音质要求不高的项目可以使用(注:测试发现,只是把音频文件变为单声道,实际播放的时候,例如是双声道播放,还是双声道的,只是播放的是一样的)

  • code size
    很多人忽略的问题,代码本身也占内存(也会导致cache missing),其中一个主要的问题是模板泛型的滥用,编译C++时,会把所有的泛型展开为静态类,如果一个类使用了四个泛型,编译出来的cpp文件可能高达25M,这对il2cpp的编译速度造成很大影响,因为一个单一的cpp文件,是无法并行编译的

  • AssetBundle
    1.TypeTree
    用于不同版本构建的AssetBundle可以在不同版本的Unity上保持兼容,防止序列化出错,如果Build AssetBundle的Unity版本和运行时的版本一致,可以关闭这个功能,关闭之后有三个好处
    a.减少内存占用
    b.减小包体大小
    c.build和运行时会变快,因为当需要序列化有TypeTree的AssetBundle时,会序列化两次,先序列化TypeTree信息,再序列化数据,反序列化也需要两次
    2.LZ4&Lzma
    LZ4是一种trunk-base的压缩技术,速度几乎是Lzma的10倍,但是压缩的体积会高出30%,trunk-base的压缩方式,在解压时可以减少内存占用,因为不需要解压整个文件,解压每个trunk的时候,可以复用buffer(在中国增强版中会推出一个基于LZ4的AssetBundle加密功能)
    3.Size&Count
    就是AssetBundle的颗粒度控制,尽量减少AssetBundle的数量,可以减少AssetBundle头文件的内存和包体大小占用,有的资源的头文件甚至比数据还大,官方建议一个AssetBundle的大小在1-2M之间,不过这个建议是考虑网络带宽的影响,实际使用可以根据自身的环境设置

  • Resources文件夹
    能不用就不用,在打包的时候,Unity也会为所有的Resources下面的资源构建一个头文件,一棵红黑树(R-B Tree),在游戏启动的时候就会加载进内存,并且不会卸载,因此也会拖慢启动速度,因为红黑树没有加载分析完,是不会进入游戏的,目前这种方式主要用于Debug,甚至一些公司在Debug也不会使用Resources,而使用AssetBundle了

  • Texture
    1.upload buffer
    和DSP buffer类似,就是填满多少Texture数据时,向GPU push一次
    2.r/w
    如果没有必要就不要开启,一个Texture正常的加载流程为
    加载进内存 -> 解析 -> upload buffer -> 从内存中delete
    开启选项后,不会从内存delete,导致内存和显存中都存在一份(注:貌似iOS不会存在两份,而是使用一个虚拟指针,指向同一块数据,具体细节可以查证一下)
    3.mip maps
    例如UI这些就别开启了,也能减少内存占用

  • Mesh
    1.r/w
    和Texture r/w类似,能不开就不开
    2.compression
    需要注意的是,在某些版本中,开了还不如不开,需要自己测试一下

  • Assets
    可以看看Unity官方的最佳实践
    Unity最佳实践

Unity Managed Memory

  • VM内存池
    1.VM会返还内存给OS吗?
    会的,条件是同一个内存block,6次GC都没有访问到时,就会返还,所以概率很小,特别是mono,il2cpp几率还会高一点
    2.当VM内存池高于某个阈值时,会根据一些条件,乘出一块内存
    3.注意,有时候托管内存已经释放了,实际内存可能还会涨,因为内存碎片化的问题,导致一些内存块无法复用,建议操作内存时,先使用大内存,再使用小内存

  • GC机制考量
    1.Throughput(回收能力)
    一次回收,能够回收多少的内存
    2.Pause times(暂停时长)
    回收时,对主线程的影响有多大
    3.Fragmentation(碎片化)
    回收之后,回收的内存会对整体碎片化贡献多少
    4.Mutator overhead(额外消耗)
    回收行为本身的消耗
    5.Scalability(可扩展性)
    能否扩展到多线程
    6.Portability(可移植性)
    能否在不同平台使用

  • Boehm
    Unity当前使用的GC算法
    1.Non-generational(不分代式)
    分代的特征是指:例如会将大块内存 小内存以及超小内存 长久内存(例如一块长时间未访问的内存会移入长久内存)会放在不同的内存区域管理,Unity未采用的一个考量是,不分代式的速度很快
    2.Non-compaction(非压缩式)
    压缩是指:当一块内存被回收时,会移动其他内存,使之紧密连接,Unity目前不会压缩,会把它空着,如果下次分配的内存小于空着的内存,就会再次使用
    3.为什么选择这种听起来不合理的GC算法呢?
    a.历史原因(Unity和Mono的恩怨,导致一直使用老版本的Mono)
    b.目前Unity的重点转向了il2cpp,采用了Incremental GC(渐进式GC),解决主线程卡顿问题,原理是分帧进行,将一次卡顿峰值平摊到多帧里面,平摊卡顿时间
    c.未来考虑使用SGen算法或升级Boehm?
    SGen是一种分代的GC算法,可以减少碎片化,调用执行快,或者考虑升级Boehm算法
    d.目前il2cpp上面是Unity自己写的Boehm算法,在策略上会更激进

  • Zombie Memory(僵尸内存)
    1.无用内存
    代码设计不好,以为可以释放,但是没有释放的内存,所以大家要关注活跃度不高的内存
    2.通过代码管理和性能工具来分析(注:后面的Unity广告)

Managed内存最佳实践

  • Don’t Null it,but Destroy it
    不要置空就完事了,记得显式调用Destroy
  • Class VS Struct
    可以关注Unity的DOTS和ECS
  • Closures and anonymous methods(闭包和匿名函数)
  • Coroutines(协程)
    协程可以看作闭包和匿名函数的特例,在il2cpp中,每一个闭包和匿名函数,都会new一个对象出来,只是无法访问,里面的数据,即使是你认为用完就丢的局部变量,在你用完了之后,也不会立即释放,而是等到这个对象释放才释放,有的项目在游戏一开始就开启一个协程,一直到游戏结束,这样使用是错误的,会导致闭包中的数据一直占用内存,正确的做法是用到的时候生成一个协程,不用的时候就扔掉,协程的设计不是当作线程使用的
  • configurations(配置表)
    如果配置较大,不要一下全部加载进内存,有两个解决方案:
    1.通过网络流量获得相关的配置信息
    2.按需加载,例如进入一个关卡时,再加载这个关卡的配置
  • Singleton(单例)
    一定要慎用,在C++的年代,这就是万恶之源,不要什么都往这里面扔,会导致内存无法释放,注意单例的引用关系,当引用关系变得复杂时,很难确定哪些东西没有及时释放

Unity的广告

主要是中国增强版的广告,支持2017.4和2018.4,有几个工具(注:不知道加壳算不算)

  • 内存及性能工具-UPR
    就是可以在网站上看性能报告的
  • 商汤的AR解决方案
  • 一个内置的,可以个il2cpp和mono代码加密的工具,支持Android和iOS
  • 云构建工具,以及云导入 云编译 云烘焙方案
  • crash report,特点是服务器部署在中国,还有就是可以还原Unity的源代码

版权声明:本文为greedylin原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。