kirin77 发表于 2021-12-14 17:23

【性能优化】内存管理和GC优化

前言

在游戏运行的时候,数据主要存储在内存中,当游戏的数据在不需要的时候,存储当前数据的内存就可以被回收以再次使用。内存垃圾是指当前废弃数据所占用的内存。
GC即(Gabarage Collector,垃圾回收器)是指将废弃的内存重新回收再次使用的过程。本文我们主要学习目标:


[*]托管堆基础知识学习
[*]垃圾回收何时触发
[*]GC如何释放堆内存的?
[*]垃圾回收算法简单介绍
[*]GC对性能产生的影响?
[*]如何提高GC效率来提高游戏的性能?
<hr/>

一、托管堆基础知识学习

1.1. Unity游戏运行时内存占用分以下几部分:

[*]Mono堆:C# 代码
[*]Native堆:资源,Unity引擎逻辑,第三方逻辑。
[*]库代码:Unity库,第三方库。
作为游戏程序员,我们要了解的GC有3个:

[*]Unity的GC(即Mono Runtime GC)
[*]C#的GC(CLR GC)
[*]Lua GC
1.1.1. Mono堆:
代码分配的内存,是通过Mono虚拟机,分配在Mono堆内存上的,其内存占用量一般较小,主要目的是程序猿在处理程序逻辑时使用;
比如:通过System命名空间中的接口分配的内存,将会通过Mono Runtime分配在Mono堆。1.1.2 Native堆:
而Unity的资源,是通过Unity的C++层,分配在Native堆内存上的那部分内存。
比如通过UnityEngine命名空间中的接口分配的内存,将会通过Unity分配在Native堆;
1.2 GC和堆内存联系
对于目前绝大多数基于Unity引擎开发的项目而言,其托管堆内存是由Mono分配和管理的。“托管” 的本意是Mono可以自动地改变堆的大小来适应你所需要的内存,并且适时地调用GC操作来释放已经不需要的内存,
但是GC并不是实时管理的,是需要通过程序员手动或系统定时触发的。因为GC是一个耗时的操作,可能在有些系统中触发的不合时宜(明显卡顿)。所以,GC也需要优化,需要控制在合事宜的情况触发。比如游戏中我们需要在切换Loading时触发GC,而在游戏战斗中控制不能被触发。
因此优化GC,就是优化堆内存,就是尽量减少堆内存,及时回收堆内存。二、垃圾回收何时触发

2.1. 被动触发
1. GC会不时地自动运行(频率因平台而异)
2. 堆分配时堆上的可用内存不足时触发GC。2.2. 主动触发
程序员主动调用:

[*]Mono GC主动调用的接口是Resources.UnloadUnusedAssets()。
[*]C# GC也提供了同样的接口GC.Collect()
[*]LuaGC主动调用collectgarbage("collect"):做一次完整的垃圾收集循环。
[*]有一点需要说明的是,Resources.UnloadUnusedAssets()内部本身就会调用GC.Collect()。
[*]Unity还提供了另外一个更加暴力的方式——Resources.UnloadAssets()来卸载资源,但是这个接口无论资源是不是“垃圾”,都会直接删除,是一个很危险的接口,建议确定资源不使用的情况下,再调用该接口。

三、GC如何释放堆内存的?

如果把程序员手工的对一系列对象进行创建释放内存当做一次垃圾回收 那么Mono平台提供的垃圾回收器,无非是一种自动操作,他代替人工去书写代码判断创建释放内存。无非就是自动在如何判断上。
如何判断一个对象是否需要被回收就是GC的策略算法了一般有以下几种:
1.引用计数
这个算法在每个对象上维护着一个字段来统计多个对象正在使用自己。当引用计数为0的时候,对象就可以从内存中删除了。
但是引用计数设计存在着一个致命的问题,那就是处理不好循环引用。
比如:在一个界面里,父窗口持有了对子对象的引用,子对象又持有了父窗口的引用。这样的循环引用就会导致内存泄漏,即使不需要这2个对象了,也不会被删除。2. 根搜索
根搜索就是从一个根开始遍历所有的叶子,然后把所有不需要的叶子清除。四、垃圾回收算法简单介绍

4.1. Mono在2.10版本前都是使用了贝姆垃圾回收(Boehm conservative collector)。
而我们的Unity中Mono的版本一直停留在2.10版本之前,所以一直集成的是BOEHM的GC。BOEHM GC是一个开源的项目,感兴趣可以阅读一下源码 Lohanry Le:Mono中的BOEHM GC 原理学习(1)
贝姆垃圾回收(Boehm conservative collector):无分代\并行,执行时所有线程阻塞;每次标记都会访问所有可达的对象(穷举搜索垃圾)。这种方式极有可能在短时间造成帧率下降,影响玩家体验。这边对贝姆垃圾回收不做多的介绍。
4.2. 而在2.10后的版本中使用了一个叫“Simple Generational GC”(SGen-GC)的垃圾回收器。
下面主要介绍一下这个分代垃圾回收。
GC清理堆时,GC收集器会通过一定的算法清理堆中的对象,并且版本不同算法也不同。
标记-压缩算法:
通过一个图的数据结构来收集对象的根,这个根就是引用地址。可以理解为指向托管堆的关系线。 当触发这个算法时,会检查图中的每个根是否可达,如果可达,则对其标记,然后在堆上找到剩余没有标记的对象进行删除,这样,那些不再使用的堆中对象就删除了。为了优化内存结构,减少在图中搜索的成本,GC机制又为每个托管堆对象定义了一个属性,将每个对象分为三个等级,0代,1代,2代。

[*]每当new一个对象的时候,该对象会被定义为第0代,
[*]当GC开始回收的时候,先从第0代开始,
[*]在这样一次回收动作之后,0代没有被回收的对象则被定义为第1代,
[*]当回收第1代的时候,第1代中没有被清理的对象会被定义为第2代。
CLR会为0/1/2代选择一个预算的容量,

[*]0代通常为256k-4mb预算,
[*]1代为512-4m,
[*]2代不受限制,最大可扩充至操作系统的整个内存空间。
代数越长说明这个对象经历了回收的次数越多,那就意味着该对象是最不容易被清除的。这种分代的思想将对象分割成新老对象,进而配对不同的清除条件,这种巧妙的思想避免了直接清理整个堆(卡顿后果)。
todo 图片示意图
4.3 Lua GC简介

Lua 5.0 采用的是一个折中的方案:每当内存分配总量超过上次 GC 后的两倍,就跑一遍新的 GC 流程。 但 Lua 5.0 这种会把整个虚拟机都停下来的 (Stop the World )的简单粗暴的 GC 实现 Lua 5.1 开始,Lua 实现了一个步进式垃圾收集器。 Lua 5.1 采用了一种三色标记的算法。每个对象都有三个状态:无法被访问到的对象是白色,可访问到,但没有递归扫描完全的对象是灰色,完全扫描过的对象是黑色。 随着收集器的运作,通过充分遍历灰色对象,就可以把它们转变为黑色对象,从而扩大黑色集。一旦所有灰色对象消失,收集过程也就完成了。 Lua 5.2 中 Lua 引入了分代 GC 。又在 Lua 5.3 中移除。 在还没有发布的 Lua 5.4 中,分代 GC 被重新设计实现。
五、GC对性能产生的影响?


[*]如果堆上有很多对象和大量的对象引用要检查,则检查所有这些对象的过程可能很慢。这可能导致游戏卡顿或缓慢运行。
[*]GC在不合时宜的场合被触发。如果CPU在我们游戏的性能关键部分已经满负荷了,那此时即使是少量的GC额外开销也可能导致我们的游戏卡顿或运行缓慢。
[*]堆碎片问题。当从堆中分配内存时,会根据必须存储的数据大小从不同大小的块中获取内存。当这些内存返回堆时候,堆可能分成很多分隔的小空闲快。这会导致我们可用内存总量可能很高,但由于碎片化太过严重而无法分配一块连续的大内存块,导致GC被触发或不得不扩大堆大小。
六、如何提高GC效率来提高游戏的性能?

6. 1 优化GC(减少GC的次数)

[*]①尝试在合适时机(loading时),手动触发GC和扩展堆大小以便GC可控。
[*]②缓存,将局部函数中的局部引用变量写成公共。
[*]③对象池。
[*]④清理容器。
[*]⑤字符串的创建之类。
[*]⑥Debug.Log的引用。
[*]⑦注意装箱
装箱会产生垃圾源于底层,当一个值类型变量被装箱时,Unity在堆上创建一个临时的System.Object来包装值类型变量。一个System.Object是一个引用类型的变量,所以当这个临时对象被处理时会产生垃圾。

[*]⑧StartCortine会产生少量垃圾。
yield return 0;//会产生垃圾,int变量0被装箱。
=>>> yield return null
yield return new WairforSeconds(1f);//如果多次被调用,会产生很多。
==>>>WaitForSeconds delay =new WaitForSeconds(1f);//可以事先缓存起来
yield return delay;

[*]⑨Linq和正则表达式在后台有装箱操作而产生垃圾,最好少使用。
[*]⑩构建代码以最小化GC的影响

内存优化实战


[*]缓存:对于能缓存的数据,尽量缓存
[*]只在满足特定条件时候GCAlloc
[*]使用一个定时器来减少GC分配
[*]List用Clear代替new
[*]对象池:对象池不能解决实例化卡顿,只是把卡顿提前了
[*]字符串:能不拼接就不拼接,拼接的话用StringBuilder
[*]Unity函数调用:gameObject.tag ==》 CompareTag
[*]装箱:String.Format("Price:{0} gold",cost)
[*]协程:yield return 0(装箱)==》yield return null
[*]LINQ和正则表达式
[*]降低GC的时间开销,比如要存一个树的下一个节点,可以用id来代表对应节点,减少GC要管理的对象。
[*]手动强制GC
<hr/>拓展:

C#代码通过mono解析执行,所需要的内存自然也是由mono来进行分配管理
下面就介绍一下mono的内存管理策略以及内存泄漏分析。
Mono内存管理策略

Mono通过垃圾回收机制(Garbage Collect,简称GC)对内存进行管理。
Mono内存分为两部分,

[*]已用内存(used):已用内存指的是mono实际需要使用的内存
[*]堆内存(heap):堆内存指的是mono向操作系统申请的内存
[*]两者的差值就是mono的空闲内存。
当mono需要分配内存时,会先查看空闲内存是否足够,如果足够的话,直接在空闲内存中分配,否则mono会进行一次GC以释放更多的空闲内存,如果GC之后仍然没有足够的空闲内存,则mono会向操作系统申请内存,并扩充堆内存,具体如下图所示。


通过上文可知,GC的主要作用在于从已用内存中找出那些不再需要使用的内存,并进行释放。
Mono中的GC主要有以下几个步骤:

[*]停止所有需要mono内存分配的线程。
[*]遍历所有已用内存,找到那些不再需要使用的内存,并进行标记。
[*]释放被标记的内存到空闲内存。
[*]重新开始被停止的线程。
除了空闲内存不足时mono会自动调用GC外,也可以在代码中调用GC.Collect()手动进行GC,但是,GC本身是比较耗时的操作,而且由于GC会暂停那些需要mono内存分配的线程(C#代码创建的线程和主线程),因此无论是否在主线程中调用,GC都会导致游戏一定程度的卡顿,需要谨慎处理。
另外,GC释放的内存只会留给mono使用,并不会交还给操作系统,因此mono堆内存是只增不减的。
UnityGC简单介绍
Mono内存是通过GC来回收的,而Unity也提供了一种类似的方式来回收内存。不同的是,Unity的内存回收是需要主动触发的。
就好比说,我们把垃圾扔在门口的垃圾桶里,GC是每天来看一次,有垃圾就收走;而Unity则需要你打个电话给它,通知它有垃圾要回收,它才会来。



赶上了资源回收



错过了资源回收

Mono内存泄漏分析

基于上述基础知识,我们再来看一下为什么会有资源的泄漏。首先和代码侧的泄漏一样,由于“存在该释放却没有释放的错误引用”,导致回收机制认为目标对象不是“垃圾”,以至于不能被回收,这也是最常见的一种情况。
针对资源,还有一种典型的泄漏情况。由于资源卸载是主动触发的,那么清除对资源引用的时机就显得尤为重要。现在游戏的逻辑趋于复杂化,同时如果有新成员加入项目组,也未必能够清楚地了解所有资源管理的细节,如果“在触发了资源卸载之后,才清除对资源引用”,同样也会出现内存泄漏了。
Mono是如何判断已用内存中哪些是不再需要使用的呢?是通过引用关系的方式来进行的。Mono会跟踪每次内存分配的动作,并维护一个分配对象表,当GC的时候,以全局数据区和当前寄存器中的对象为根节点,按照引用关系进行遍历,对于遍历到的每一个对象,将其标记为活的(alive)。


如上图所示,假设A是处于全局数据区的一个对象,那么在GC的时候将作为根节点进行遍历,由于B、C、D对象都可以由A遍历到,因此被标记为活的,E、F对象则没有被标记。注意,由于引用关系是单向的,A引用了B并不代表B也引用了A,所以遍历也只能单向进行。
由于GC以全局数据区和当前寄存器中的对象为根节点进行遍历,所以对象的被标记意味着该对象可以通过全局对象或者当前上下文访问到,而没有被标记的对象则意味着该对象无法通过任何途径访问到,即该对象“失联”了,GC最终会将所有“失联”的对象内存进行回收,上图中的E和F将会在GC过程中被回收。
既然mono已经有了完善的GC机制,那是否还会存在内存泄漏呢?答案是肯定的,只是此处的内存泄漏需要重新定义一下,我们把对象已经不再需要使用却没有被GC回收的情况称为mono内存泄漏。Mono内存泄漏会使空闲内存减少,GC频繁,mono堆不断扩充,最终导致游戏内存占用的升高。下图就是一个mono内存泄漏的例子。


面临的问题


[*]托管堆在GC后内存仍不足时,会继续申请新的内存,但GC所释放的内存会留给Mono使用并不会还给操作系统,这会导致游戏内存占用越来越高。
[*]GC时会暂停那些需要Mono内存分配的线程,无论是否在主线程调用都会造成一定程度的卡顿。
[*]GC过程会进行遍历标记的耗时操作,虽然优化了内存,但无疑大大加重了CPU的负担。
因此高效使用内存,尽量减少GC的调用次数就是我们要做的。
解决办法

对于mono内存泄漏,一般只能通过猜测+不断修改代码测试的方法来修复问题,效率很低,腾讯Wetest平台的Cube工具提供了mono内存快照对比的功能,并包括对象分配堆栈,对象引用关系等详细信息,是定位mono内存泄漏问题的一大利器。下面结合具体的代码尝试使用Cube定位mono内存泄漏问题。

[*]代码优化,主要思想是减少新的内存分配,重复利用,尽量寻找可替代方案。
[*]常用profiler对内存使用进行监测,及时发现异常。
[*]游戏中大部分mono内存泄漏的情况都是由于静态对象的引用引起的,因此对于静态对象的使用需要特别注意,尽量少用静态对象,对于不再需要的对象将其引用设置为null,使其可以被GC及时回收,但是由于游戏代码过于复杂,对象间的引用关系层层嵌套,真正操作起来难度很大。可以首先使用Cube工具进行分析,根据mono内存趋势找出泄漏的具体场景,然后再使用快照对比功能进行详细分析。
todo 解决方法太泛了,需要自己实践,写点例子 Unity优化之GC——合理优化Unity的GC
GC优化



参考:

CLR via C#(第4版) 第21章 托管堆和垃圾回收
推荐!可视化垃圾回收算法
【Unity/笔记】垃圾回收GC_游戏_iShow的博客-CSDN博客
Unity3D - Unity游戏Mono内存管理与泄漏
C# 通俗说 内存的理解 - 不三周助 - 博客园
Unity GC 优化要点 - 不三周助 - 博客园
Lohanry Le:Mono中的BOEHM GC 原理学习(1)
推荐最近在学习的视频:
清华大学美术学院与腾讯游戏学院联合制作【游戏程序设计】

JoshWindsor 发表于 2021-12-14 17:25

进行完GC之后如果仍然内存不够会向操作系统申请扩大堆内存么,我以为是会直接oom呢,就是说C#的堆的最大容量在运行的时候不是固定的么

jquave 发表于 2021-12-14 17:31

托管堆内存是会动态扩容的

TheLudGamer 发表于 2021-12-14 17:33

unity文档里面写的还是采用贝姆的GC方式。https://docs.unity3d.com/Manual/overview-of-dot-net-in-unity.html按照之前升级到unity5.5的说法,可以猜测是仅仅c#编译器的升级,但运行时的Mono并没有升级到高版本。https://blog.unity.com/technology/get-the-unity-5-5-beta-now当然只是猜测[思考]
页: [1]
查看完整版本: 【性能优化】内存管理和GC优化