浅谈Unity内存管理

本文详细解读了Unity中的内存管理,包括虚拟内存、原生内存与托管内存的区别,Unity的内存分配策略,以及如何避免内存碎片和内存泄露。还介绍了Profiler工具在内存分析中的作用和常见内存优化技巧。
摘要由CSDN通过智能技术生成

浅谈Unity内存管理


前言

很早之前记录的Unity内存相关的知识点,在此补充到博客上来。有什么不对的地方欢迎指正探讨。

内存概念

虚拟内存(Virtual Memory)

众所周知,物理内存就是插在计算机主板内存槽上的实际物理内存。

虚拟内存其实就是计算机系统内存管理的一种技术。虚拟内存使得应用程序认为它拥有一个连续完整的地址空间,而实际上,虚拟内存通常是被分隔成多个物理内存碎片,在需要时进行数据交换。

Unity在运行时的内存占用可以参考下图:

在这里插入图片描述

原生内存(Native Memory)

Unity本质上是一个C++写的引擎,使用的.NET脚本虚拟机。Unity会给向操作系统请求维持虚拟机所需要的内存和我们开发所需要的内存。

而我们开发所需要的内存就包括原生内存和托管内存。

一些托管对象就会被分配到托管内存中,如引用类型数据等;而一些非托管对象和Unity自己的管理器对象就会分配到原生内存中,如AssetBundle、Assets、Scenes、GameObjects等。

在大多数情况下,使用者不能直接访问或修改原生内存,Unity也不建议这么操作。但是可以通过Unity 提供的C# API 间接访问原生内存。

不同于托管内存,当原生内存不在使用时,Unity会返还给操作系统。

托管内存(Mono堆、Mono Heap、托管堆)

托管内存是Unity项目的选定脚本运行时(Mono或IL2CPP)自动管理的内存部分。

托管堆里面的内存包含了所有C#托管对象,开发者无需显式地调用内存释放接口或进行内存释放操作,只需要考虑引用关系,引擎底层通过垃圾回收机制来释放堆内存。与显式分配/释放相比,自动内存管理编码更少,并减少了内存泄漏的可能性。

什么是Mono?

Mono是一套用于实现跨平台运行的框架。Unity通过Mono运行时的编译器将IL编译成各个平台的原生代码。

需要注意的是,托管内存即使它的大部分是空的,仍不会释放回操作系统。这是为了防止在发生更大的分配时需要重新扩展堆。

(在大多数平台上,Unity最终还是会将托管堆空白部分内存释放回操作系统。但是发生这种情况的时间间隔无法保证并且不可靠。所以总得来说可以看作是不还。)

已分配/申请内存(Reserved Memory)

内存页(Page)通常来说是内存管理最小的单位,Unity每次申请内存会按照内存块(Block,若干个Page)的方式申请。所有申请到的内存就被称为Reserved Memory。

已使用内存(Used Memory)

在所有Reserved Memory中,真正在被使用的内存,叫做Used Memory。

内存分配

在简单了解了上述内存概念之后,我们可以思考一下,游戏运行时内存是如何分配及管理的呢?

引擎在分配托管内存时并不是向操作系统 “即拿即用”,而是首先申请一定量的连续内存,然后供自己内部使用,待空余内存不够时,引擎才会向系统再次申请一定量的连续内存进行使用。

在这里插入图片描述

需要申请托管堆内存的时候,托管堆会首先检查当前堆内的空间内是否存在足够的连续空间。

在这里插入图片描述

如果不能找到足够的连续空间,就会进行一次GC。

如果在GC运行之后,仍然没有足够的连续空间来容纳所请求的内存量,托管堆就会执行内存扩展操作,向操作系统要更多的内存(Reserved Memory)。堆扩展的具体大小取决于平台,在大多数平台上,扩展量是先前扩展量的两倍。

这些空出来,却又不能被重复利用的内存就会成为内存碎片。

它们既不能被利用,又不会被销毁。

那么为什么会产生这样的内存碎片呢?

Unity GC

可以看到,Unity这里采取的GC策略,即不分代,也不压缩。

Unity的GC本质上就是Mono的GC,Unity早期版本的Mono,采用的是Boehm垃圾回收机制,一种非分代、非压缩、标记清除的保守式GC。托管内存只增不减。

Boehm垃圾回收机制使用的是Mark-Sweep,也就是先通过一个Root指针来遍历所有的被引用的对象,并标记。直到遍历完所有的指针。再次遍历整个,将未标记的内存释放。

在这里插入图片描述

Boehm
GC是开源项目,感兴趣的话可以去学习源码:Boehm垃圾回收机制开源网址

后来IL2CPP由Unity自己重写垃圾回收机制,是升级版的Boehm垃圾回收机制。托管内存可以降低,但是内存返还的条件就是,如果某一个Block被GC了6次都是闲置时,就会将这个Block返还给系统。条件很难触发。

Unity2019.1版本引入Incremental GC,在使用Boehm垃圾回收机制的基础上以增量模式运行,将垃圾收集拆分到多帧进行。解决了GC峰值问题。

内存管理

Unity内存管理主要注重两大内存:原生内存和托管堆内存

原生内存
图片

图片是大多数游戏内存开销最大的一块。

咱们秉承两个原则:

一是能压缩即压缩,压缩过的图片虽然美术效果会没有那么好,但在内存上可以剩下很多。RGBA的格式的图越少越好。减少图片的色差范围,可以让图片在压缩过后,效果表现得没那么差。

二是能拆分即拆分,一张完整的背景图大小一定会大于所有背景内容拆分下来拼接的大小。

除此之外,Unity中图片的一些细节可以注意一下。

对于Read/Write选项,如果不需要进行像素基本修改的话,最好不要开启,因为在内存中会多一份图片的拷贝。

至于Mipmap功能,一般来说,对于UI图片是不需要的。开启的话会生成多张1/2、1/4、1/8…长的图片,用于减小渲染压力。整体则会增加1/21/2 + 1/41/4 + 1/8*1/8 + … 约1/3原图大小的内存占用。

在这里插入图片描述

音频

如果没有必要使用多声道,请一定要勾选强制转换为单声道的选项,这会使得音频大小减少很多。

如果对音效质量要求没有那么高,那么采样率可以适当降低一点,压缩格式可以选择压缩率高的选项。

Decompress On Load:整个音频文件加载到内存中后对其立即进行解压,最终内存占用为解压后的音频大小,即未压缩的音频大小。播放的时候没有延迟,但是一般来说,大音频文件由于未压缩版本占用内存过大,所以尽可能地减少对大音频文件应用此机制。

Compressed In Memory:整个音频文件加载到内存中后不立即进行解压,当音频播放的时候进行解压,最终内存占用的是压缩后的音频大小。但是相比上面一种机制来说,这种机制在播放时会有一丢丢延迟,解压速度不同,播放的延迟也就不同。所以说这种机制应用于大音频文件是比较合适的,既不会占用过多内存,又能接受解压带来的CPU开销和细微播放延迟。

Streaming:直读直放的模式。这种模式使用尽可能小的内存来缓存从磁盘中逐渐读取的数据,并且立刻解码播放音频,播放完后及时释放。但是这种模式的CPU开销是最高的,音频的播放延迟取决于频繁I/O的开销,所以说算是CPU换内存的一种方式。所以说,对内存压力比较大的项目,首选这种模式是不错的选择。除此之外,这种模式也比较适合大音频,或者只需要播放一次的音频。

还有一种情况,当玩家点开静音的时候,与其把音量大小变为0,不如采用合理的机制卸载音频资源。

AssetBundle
压缩格式:LZ4、LZMA、LZ4Runtime

LZMA是流压缩方式(stream-based)。流压缩再处理整个数据块时使用同一个字典,它提供了最大可能的压缩率,但是只支持顺序读取。所以加载AB包时,需要将整个包解压,会造成卡顿和额外内存占用。

LZ4是块压缩方式(chunk-based)。块压缩的数据被分为大小相同的块,并被分别压缩。如果需要实时解压随机读取,块压缩是比较好的选择。解压时可以一块一块解压,重复利用内存,减少内存峰值。

除此之外,Unity在每次运行第一次加载LZMA的ab包时,会把LZMA的ab包解压,再压缩成LZ4Runtime格式的ab包,存储在内存中。这也会导致内存增大非常多。

资源冗余:

同一张texture被不同的prefab引用,同时每个prefab又被打成不同的AB包,那么在没有针对texture进行依赖打包的前提下,该texture就会同时出现在两个的AB包中。

资源卸载:

及时卸载资源、AB包。一个优秀的资源管理策略非常关键。

在这里插入图片描述

场景

一个非常常见的原生内存增长的情况就是场景中GameObject过多。

对于像Scene、GameObject这类的实例,最终还是会在反映在原生内存中,而不是托管堆中,托管堆只是存放和维护了一个该Scene、GameObject的信息。

对这一部分来说,可行的方案有减少实例创建、活用对象池等。

托管堆内存

如果刚启动托管堆内存就达到了60MB,那么极有可能在一开始加载了一个非常大的配置表。

内存碎片

既然Unity的GC没有做内存压缩,那么内存碎片肯定是无法避免的。

如果我们能做到按照一定顺序分配内存,比如按照所需内存从大到小、内存释放时间从长到短等顺序,那么内存碎片会产生得更少。

但是在实际开发中肯定是很难做到这一点的,所以我们就要尽可能最大化利用内存,减少频繁的内存分配,也就减少了产生内存碎片的可能,从而减少了堆内存扩容的可能。

常用的策略:

  1. 减少new对象、类
  2. 减少装箱拆箱
  3. 减少闭包和匿名函数:所有的匿名函数和闭包在C#转IL时都会被new成一个匿名Class,里面所有函数、变量以及new的东西,都是要占内存的。
  4. 减少协程调用
  5. 减少Log输出
  6. 减少配置表加载
  7. 减少无效分配
内存泄露

托管内存可能不会下降,但并不应该一直上升。因为上面也提到了Unity的GC方式,GC只是把托管内存中分配的内存置为空闲状态,托管内存总量并不会减少。如果出现托管内存一直持续上升,大多情况下是出现了内存泄露。

如果重复打开关闭某个界面,或者停留在某个界面的时候,堆内存还在不断上涨,那这就妥妥的内存泄露了。

对于托管内存泄漏,一般都是代码中引用没有及时清除导致的问题。

举个简单的例子:

a = {}
b = {}

b.list = new List()
a.list = b.list

---------写了一堆代码之后----------

b = nil

上述类似的代码就很有可能出现在项目中。

b在一顿操作之后,需要清除置空,但是却忘记了a还引用着这个list。那么此时这个list就会成为内存泄露的一点。

一般来说,会出现在生命周期不对应、静态变量等情景下。

定位工具

Unity是无法检测到用户自行分配的原生内存的,比如Lua、cpp的插件等。

所以在Unity视角下的内存分析,更多的是注重Unity本身所管理的内存。

Profiler

下面是Profiler内存部分数据的概况界面:

在这里插入图片描述

第一行是已使用内存,第二行是已分配内存。里面具体内容如下:

Unity:所有Unity分配的内存,包括托管堆。(不包括后面的GfxDriver、FMOD、Video和Profiler)

Mono:托管堆。

GfxDriver:GPU显存占用,主要包括Texture、Vertex Buffer和Index Buffer。(后两者是在渲染管线流程中CPU传给GPU用来存储网格点信息的数据)

FMOD:音频引擎占用内存。

Video:视频播放器占用内存。

Profiler:Profiler本身占用内存。

注意:这里的Reserved
Memory不是完全精确的数值。因为Unity视角下的工具都只看到由Unity代码完成的分配,看不到第三方native插件和操作系统的分配。

下面是Profiler内存部分数据的详细界面:

在这里插入图片描述

它展示了虚拟内存的详细分配情况。

Assets:当前从场景、Resources和AB包中加载的总资源。一般来说,这里可以看到很多内存问题,比如出现没有引用的资源往往代表着资源泄露。

Built-in Resources:Unity Editor资源或者Unity默认自带资源。

Not Saved:项目中通过代码生成的各种资源。

Scene Memory:场景中的GameObject和它附属的Component。

Other:其他不在上面几条分类中的。

Memory Profiler Extension

下面为Memory Profiler Extension的某截图:

在这里插入图片描述

上图中可以看到:

Reserved Memory:256KB + 256KB + 128KB = 640KB

Used Memory:88562B = 86.48KB

已分配内存有640KB,但实际使用内存只有86.48KB。

其他

LuaProfiler、MemoryProfiler、PrefDog、WeTest的性能分析工具、UWA的性能分析工具…

小结

总结来说,内存问题都是一点一点积累起来的。

要解决内存问题,首先是要所有开发者知道“怎样会产生内存问题”。有了意识,才能在后续的开发中注意细节。

再者,就是一定合理加载,合理释放。勿以善小而不为,勿以恶小而为之。

资料参考

https://learn.unity.com/tutorial/memory-management-in-unity?uv=2018.1#

https://zhuanlan.zhihu.com/p/362941227

https://blog.uwa4d.com/archives/optimzation_memory_1.html

https://zhuanlan.zhihu.com/p/370467923

  • 15
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值