Unity内存管理

本文是对官方视频 浅谈Unity内存管理 (一个干货满满的视频)的一个整理。

什么是内存?

操作系统有物理内存和虚拟内存两个概念:

物理内存

物理内存也就是我们真是的硬件设备,例如内存条。

访问过程缓慢

我们需要知道,CPU访问内存是一个慢速过程,访问过程具体为:先访问Cache(L1,L2,L3),若全没找到再去访问内存,接着把找到的数据存放到Cache中,完成一次操作。在Cache中没有找到数据,我们称之为Cache Miss。因此过多的Cache Miss就会导致大量的内存和Cache的IO交换,浪费大量时间。

因此我们需要尽量减少Cache Miss,来提高访问速度,Unity为此提出了ECS和DOTS方案,有兴趣的小伙伴可以看看之前有关ECS介绍的文章,它们可以将存储在内存中的不连续数据,变为连续的数据。

台式设备和移动设备内存架构的差异

1.首先移动设备没有独立显卡。

2.移动设备没有独立显存(显存的作用是用来存储显卡芯片处理过或者即将提取的渲染数据),所有在移动端数据内存和显存是同一块内存。所以有可能我们游戏占用的内存并不大,但是依旧爆内存了,其实是因为显存分配不出来了。这种情况,我们可以去查看一下Log,例如Android会有一个 OpenGL Error:Out Of Memory。

3.移动设备的CPU面积更小,因此会导致缓存级数更少,大小也更小,例如一般的台式机三级缓存可能有8-16M,而移动设备则只有2M左右。

虚拟内存

虚拟内存是利用磁盘空间虚拟出的一块逻辑内存,用作虚拟内存的磁盘空间被称为交换空间(Swap Space)。

内存交换

操作系统在使用内存不够的情况下,会尝试把一些不用的内存(Dead Memory)交换到硬盘上,从而节省出更多的物理内存。这个操作我们称之为内存交换,它会占用大量的硬盘空间。

然而移动设备不做该操作,因为移动设备的IO速度很慢,而且移动设备的可存储物(例如sd卡,内存芯片等)的可擦写次数也比硬盘少很多,会影响使用寿命。

内存压缩

在IOS中(Android没有)会将不活跃的内存压缩起来存储到一个特定空间里,来节省出物理内存空间,来给活跃的app使用,这个操作称之为内存压缩。(可以查看XCode的Virtual Memory)

内存寻址范围

内存寻址范围也称寻址空间,指的是CPU对于内存寻址的能力(最大能查找多大范围的地址)。数据在内存中存放是有规律的,CPU在运算的时候需要把数据提取出来就需要知道数据在那里,这时候就需要挨家挨户的找,这就叫做寻址,但如果地址太多超出了CPU的能力范围,CPU就无法找到数据了。

内存寻址范围和Memory Controller(内存控制器)有关,和运算位数(32位或64位)无直接关系。当然一般情况下,64位的CPU寻址范围更大。

Android内存管理

基本单位Page

Android是基于Linux操作系统,其内存基本单位称为:Page,默认4K为一个page。因此内存回收和分配的时候一般已4k进行处理,但是并不意味着所有的数据都是4k对齐的。

用户态和内核态

Android内存分用户态和内核态:

用户态:只能受限的访问内,所有app都是运行在用户态上的。

内核态:cpu可以访问内存的所有数据。

内核态的内存,用户态是严格不许访问的,例如一些Error Access,可能是指针飘到内核态上了。

内存杀手

Android有一个内存管理工具:Low Memory Killer,当内存不足时,会清理内存,在Android上常见的一些后台app消失,一些手机服务消失,手机重启或者是app崩溃闪退等都和它有关。

Android应用分层

首先我们来了解下Android的应用分层,这也是杀手的追杀路线(会从最底层往上杀)

Native 系统内核,例如adbd
System 系统级应用,例如system server
Persistent 用户级应用,例如电话,蓝牙,wifi
Foreground 前台应用,当前正在使用的Activity
Perceptible 辅助类应用,例如搜索,音乐,键盘
Service 一些驻后台的线程服务,例如云服务,垃圾回收
Home 桌面
Previous 上一个使用的应用
Cached 后台应用

现象

若此时我们的手机内存不足,杀手会一层层的从下往上杀,直到内存足够为止。同时每杀一层都会造成一定的现象,例如Cached或Previous被杀,会导致再次使用之前应用的时候,应用重启。Home被杀导致桌面图标重建,或者壁纸不见了。Perceptible被杀会导致音乐停止等。Foreground被杀导致当前应用闪退。System被杀,就会导致手机重启。Native属于系统本身,因此是无法杀到的。

因此通过这些现象,我们就可以了解自己的app到底对内存的使用到了一个什么程度。例如使用自己app时,再返回上个app时导致上个app重启,说明杀手已经杀到了Previous层。

内存指标

首先我们要了解在计算app使用了多少内存时,系统需要统计共享页面(shared pages)。App在访问同一个service或者library的时候会共享内存页面。比如,Google地图和一个游戏app可能会共享一个定位服务。

常见的内存指标有如下三个

Resident Set Size

(RSS)

当前app所占用的所有内存,如果你的app通过Google Play Services分配了内存,那这部分内存也归你所有。(例如上面的例子中定位服务所占的内存就归自己app所有)

Proportional  Set Size

(PSS)

与RSS不同,通过Google Play Services分配的内存会平摊到所有呼叫这个服务的app上。(例如上面例子中定位服务所占的内存就会平摊到所有使用到的app上)

Unique Set Size

(USS)

只有app自己占得内存,不算Google Play Services分配的内存(例如上面例子中,就不算算上定位服务所占的内存)

一般来说内存占用大小有如下规律:RSS >= PSS >= USS

注:可能你的USS很低,但是由于调用了Google Play Services,导致PSS很高。

procrank指令

我们可以通过procrank指令来查看各种内存指标,例如

可以帮助我们分析应用内存使用,一般我们要做USS的优化,以及避免在PSS上造成更大的压力。

Unity内存管理

Unity是一个C++引擎

Unity是一个C++引擎,并不是C#引擎,底层代码全部是由c++写的,除了一些Editor里面的Services可能会用到NodeJS这些网络的语言,Runtime里面用到的每一行Unity底层代码全是C++的。

Unity实际上分为三层:最底层是我们的Runtime,全是Native C++代码。上面一层是我们的C#,Unity自己有一些C#,例如我们的Editor是用C#写的,还有些Package也是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内存按照分配方式分为:Native Memory(原生内存)和Managed Memory(托管内存)。

Unity在Editor和Runtime下,内存的管理方式是不同的,除了内存大小不同,内存的分配时机以及分配方式也可能不同。例如Asset,在Runtime时,只有我们Load的时候才会进内存。而Editor模式下,只要打开Unity就会进内存(所以打开很慢)。因此后续有推出Asset Pipeline 2.0,它会一开始导入一些基本的Asset,剩下的Asset只有你使用的时候才会导入。

Unity按照内存管理方式分为:引擎管理内存用户管理内存。引擎管理内存即引擎运行的时候自己要分配一些内存,例如很多的Manager和Singleton,这些内存开发者一般是碰触不到的。用户管理内存也就是我们开发者开发时使用到的内存,需要我们重点注意。

Untiy检测不到的内存

即Unity Profilter无法检查到的内存,例如用户分配的Native内存。比如自己写的Native插件(C++插件)导入Unity,这部分Unity是检测不到的,因为Unity没法分析已编译的C++是如何分配和使用内存的。还有就是Lua,它完全自己管理的,Unity也没法统计到它内部的情况。

Native Memory介绍

Allocator与Memory Lable

Unity在里面重载了C++的所有分配内存的操作符,例如alloc,new等。每个操作符在被使用的时候要求有一个额外的参数就是Memory Lable,Profilter中查看Memory Detailed里的Name很多就是Memory Label。它指的就是当前的这一块内存内存要分配到哪个类型池里。

GetRuntimeMemory

Unity在底层会用Allocator,使用重载过的分配符分配内存的时候,会根据Memory Lable分配到不同的Allocator池里面。每个Allocator池,单独做自己的跟踪。当我们要在Runtime去Get一个Memory Lable下面池的时候,可以从对应的Allocator中取,可以从中知道有什么东西,有多少兆。

NewAsRoot

前面提到的Allocator的生成是使用NewAsRoot,生成一个所谓的Memory Island,它下面会有很多的子内存。例如一个Shader,当我们加载一个shader进内存的时候,首先会生成一个shader的Root,也就是Memory Island。然后Shader底下的数据,例如Subshader,Pass,Properties等,会作为该Root底下的成员,依次的分配。所以我们最后统计Runtime的内存时,统计这些Root即可。

会及时返还给系统

因为是C++的,所以当我们去delete或free一个内存的时候,会立刻返回给系统。这和托管内存堆不一样,需要GC后才返回。

与Native Memory相关的东西

以下东西都是和我们Native Memory相关的,使用不当可能导致Native Memory的增长。

以下很多内容和之前的文章 Unity性能优化技巧 的内容相似,建议结合看下。

Scene

导致Native Memory增长的原因,最常见的就是Scene。因为是c++引擎,所有的实体最终都会反映在c++上,而不会反映在托管堆上。所以当我们构建一个GameObject的时候,实际上在Unity的底层会构建一个或多个object来存储这一个GameObject的信息(Component信息等)。所以当一个Scene里面有过多的GameObject存在的时候,Native Memory就会显著的上升。

注:当我们发现Native Memory大量上升时,可以先着重检查我们的Scene。

Audio

DSP Buffer

设置如上图,是指一个声音的缓冲,当一个声音要播放的时候,需要向CPU去发送指令,如果声音的数据量非常的小,会造成频繁的向CPU发指令,造成IO压力。在Unity的FMOD声音引擎里面,一般会有一个Buffer,当Buffer填充满了才会去向CPU发送一次播放声音的指令。

DSP Buffer大小的设置一般会导致两种问题:

过大会导致声音的延迟,因为填充满需要很多的声音数据,当我们声音数据不大的时候,就会产生延时。

太小会导致CPU负担上升,因为会频繁的发送。

Force To Mono

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

Compression Format

不同的平台有不同的声音格式的支持,IOS对MP3有硬件支持,Android暂时没有硬件支持。建议IOS适合使用ADPCMMP3格式,Android适合使用Vorbis格式。

Load Type

决定声音在内存中的存在形态:

Decompress On Load 当audio clip被加载时,解压声音数据 适用于小型音频文件(< 200kb)

Compressed In Memory

声音数据将以压缩的形式保存在内存当中 适用于中型音频文件(>= 200kb)
Streaming 从磁盘读取声音数据 适用于大型音频文件,例如背景音

Code Size

代码也是占内存的,需要加载进内存执行。模板泛型的滥用,会影响到Code Size以及打包速度(IL2CPP编译速度,单一一个cpp文件编译的话没办法并行的)。例如一个模板函数有四五个不同的泛型参数(float,int,double等),最后展开一个cpp文件可能会很大。因为实际上c++编译的时候我们用的所有的Class,所有的Template最终都会被展开成静态类型。因此当模板函数有很多排列组合时,最后编译会得到所有的排列组合代码,导致文件很大。

AssetBundle

TypeTree

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

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

BuildAssetBundleOptions.DisableWriteTypeTree

当我们当前AssetBundle的使用,和Build它的Unity的版本是一模一样的时候,就可以关闭。这样,一可以减少内存,二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,但是现在进入5g时代的话,可以适当加大,因为网络带宽更大了。

Resource

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

Texture

Upload Buffer

在游戏里设置(Quality)如图,和声音的Buffer类似,填满后向GPU push 一次。

Read/Write

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

Mip Maps

同样不需要的情况下关闭,例如UI。

Mesh

Read/Write

同Texture。

Compression

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

需要注意的是有些版本开了,实际解压之后内存占用大小会更严重。

Assets

和整个的Asset管理有关系,在unity官网上有个关于资源管理的文章Best Practices

https://docs.unity3d.com/Manual/BestPracticeGuides.html

Managed Memory介绍

VM内存池

即Mono虚拟机的内存池,我们的内存以Block的形式管理,当一个Block连续6次GC没有被访问到,这块内存会被返回给系统。条件苛刻,比较难触发。

GC

GC的机制考量

Throughput(回收能力) 一次GC能收回多少内存
Pause times(暂停时长) GC时对主线程的影响会多大(卡顿)
Fragmentation(碎片化) 对整体内存池的碎片化影响多少
Mutator overhead(额外消耗) GC时的消耗,GC时需要做很多的统计会产生消耗
Scalability(可拓展性) 拓展到多核多线程会不会有什么bug
Portability(可移植性) 在不同的平台上是否可以使用

Boehm

Unity用的Boehm GC,简单粗暴,不分代。

Non-generational(非分代式),即全都堆在一起,因为这样会很快。分代的话就是例如大内存,小内存,超小内存分在不同的内存区域来进行管理(SGen GC的设计思想)。

Non-Compacting(非压缩式),即当有内存被释放的时候,这块区域就空着。而压缩式的会重新排布,填充空白区域,使内存紧密排布。

上面的形式就会导致我们的内存碎片化,可能我们当前的内存并不大的时候,添加一块较大内存时,却没有任何的一个空间放得下(即使整体的空间足够),导致内存扩充很多。因此建议先操作大内存,然后操作小内存。

碎片化内存之间空出的内存可能就成为僵尸内存。这种情况实际上并不是内存泄露,因为这些内存并没有被泄露,泄露指这块内存没有任何人可以访问和管理,但实际上这块内存一直在内存池里。

下一代GC

Incremental GC(渐进式GC):https://blogs.unity3d.com/2018/11/26/feature-preview-incremental-garbage-collection/

主要解决主线程卡顿的问题,现在进行一次GC主线程被迫要停下来,遍历所有的Memory Island,决定哪些要被GC掉,会造成一定时间的主线程卡顿。Incremental GC把前面暂停主线程的事分帧做了,这样主线程不会出现峰值。

IL2CPP GC机制是Unity重新写的,属于一种升级版的Boehm。

Managed Memory建议

1.用Destroy,别用null,显示的调用Destroy才能真正的销毁掉

2.根据情况选择使用Class或Struct

3.池中池(pool in pool):虽然VM自己有内存池,但是我们还是需要自己使用内存池来管理

4.闭包和匿名函数:所有的匿名函数和闭包在c#编IL代码时都会被new成一个Class(匿名class),所以在里面所有函数,变量以及new的东西,都是要占内存的。

5.协程的使用:协程属于闭包和匿名函数的特例,游戏开始启动一个协程直到游戏结束才释放,错误的做法。因为协程只要没被释放,里面的所有变量,即使是局部变量(包括值类型),也都会在内存里。建议用的时候才生产一个协程,不用的时候就丢掉。

6.配置表,最好不要一下子全丢到内存里,建议分关加载等。

7.单例,慎用,不要什么都往里放,因为里面的变量会一直占用内存。

猜你喜欢

转载自blog.csdn.net/wangjiangrong/article/details/108539554
今日推荐