面经-垃圾回收
本文最后更新于:2025年1月21日 凌晨
Unity
原理
也是采用标记清除的方式,会从根出发,标记所有
根节点是什么?
1. 静态字段
2. MonoBehaviour实例
3. ScriptableObject实例
4. 场景中的GameObject
5. 全局引用
6. 资源引用
堆内存分配和回收机制
堆内存上的内存分配和存储相对而言更加复杂,主要是堆内存上可以存储短期较小的数据,也可以存储各种类型和大小的数据。其上的内存分配和回收顺序并不可控,可能会要求分配不同大小的内存单元来存储数据。
堆上的变量在存储的时候,主要分为以下几步:
- 首先,unity检测是否有足够的闲置内存单元用来存储数据,如果有,则分配对应的内存单元;
- 如果没有足够的存储单元,unity会触发垃圾回收来释放不再被使用的堆内存。这步操作是一步缓慢的操作,如果垃圾回收后有足够的内存单元,则进行内存分配。
- 如果垃圾回收后并没有足够的内存单元,则unity会扩展堆内存的大小,这步操作会很缓慢,然后分配对应的内存单元给变量。
堆内存的分配有可能会变得十分缓慢,特别是需要垃圾回收和堆内存需要扩展的情况下。
垃圾回收时的操作
当一个变量不再处于激活状态的时候,其所占用的内存并不会立刻被回收,不再使用的内存只会在GC的时候才会被回收。
每次运行GC的时候,主要进行下面的操作:
- GC会检查堆内存上的每个存储变量;
- 对每个变量会检测其引用是否处于激活状态;
- 如果变量的引用不再处于激活状态,则会被标记为可回收;
- 被标记的变量会被移除,其所占有的内存会被回收到堆内存上。
GC操作是一个极其耗费的操作,堆内存上的变量或者引用越多则其运行的操作会更多,耗费的时间越长。
何时会触发垃圾回收
主要有三个操作会触发垃圾回收:
- 在堆内存上进行内存分配操作而内存不够的时候都会触发垃圾回收来利用闲置的内存;
- GC会自动的触发,不同平台运行频率不一样;
- GC可以被强制执行。
降低GC的影响的方法
大体上来说,我们可以通过三种方法来降低GC的影响:
- 减少GC的运行次数;
- 减少单次GC的运行时间;
- 将GC的运行时间延迟,避免在关键时候触发,比如可以在场景加载的时候调用GC
基于此,我们可以采用三种策略:
- 对游戏进行重构,减少堆内存的分配和引用的分配。更少的变量和引用会减少GC操作中的检测个数从而提高GC的运行效率。
- 降低堆内存分配和回收的频率,尤其是在关键时刻。也就是说更少的事件触发GC操作,同时也降低堆内存碎片。
- 我们可以试着测量GC和堆内存扩展的时间,使其按照可预测的顺序执行。当然这样操作的难度极大,但是这会大大降低GC的影响
降低GC的具体方法
减少内存垃圾
缓存
比如循环不要每次都创建新的数组,而是提前创建好
不要在频分调用的函数里面使用堆内存分配
比如tick和update里面不要分配内存,而是在之前就创建好,或者通过定时器或者脏标记的方法来改进
减少链表
对象池
减少内存的释放和创建次数
造成不必要内存的分配的情况
字符串
在c#里面字符串不是值类型而是引用,而且值无法改变,所以改变值会直接创建一个新的值
1)减少不必要的字符串的创建,如果一个字符串被多次利用,我们可以创建并缓存该字符串。
2)减少不必要的字符串操作,例如如果在Text组件中,有一部分字符串需要经常改变,但是其他部分不会,则我们可以将其分为两个部分的组件。
3)如果我们需要实时的创建字符串,我们可以采用StringBuilderClass来代替,StringBuilder专为不需要进行内存分配而设计,从而减少字符串产生的内存垃圾。
4)移除游戏中的Debug.Log()函数的代码,尽管该函数可能输出为空,对该函数的调用依然会执行,该函数会创建至少一个字符(空字符)的字符串。如果游戏中有大量的该函数的调用,这会造成内存垃圾的增加。
函数调用
比如说迭代器会产生新的数组(可以通过缓存来解决),调用GameObject.name 或者 GameObject.tag也会有内存垃圾,因为会犯乎一个字符串
装箱操作
装箱操作是指一个值类型变量被用作引用类型变量时候的内部变换过程,如果我们向带有对象类型参数的函数传入值类型,这就会触发装箱操作。比如String.Format()函数需要传入字符串和对象类型参数,如果传入字符串和int类型数据,就会触发装箱操作。最好避免。
比如这种
1 |
|
协程
函数引用
foreach
6.5之前的版本会因为迭代器有内存垃圾
重构
比如把对象里面的string拆出来,这样就不用频繁地在GC里面类型检查
定时执行GC
比如过场的时候主动调用GC操作
UE
也是标记清除
Lua
Lua GC机制分析与理解-上 - 小破孩不会编程序的文章 - 知乎
gc的思想
会遍历所有对象,标记颜色,那些不可达的就是需要gc的对象,又分为双色和三色,双色就是不能中断,三色可以中断,但是实现复杂,
何时会触发gc?
分为两种,一种是自动触发
在以下代码中,使用 luaC_checkGC 检查 gc 阈值 GCdebt ,当 GCdebt 大于0 时,执行 gc
1、创建新数据时 string, thread, userdata, table, closure
3、语法解析时
4、错误发生时
5、字符串拼接时 concat
6、栈增长时
一种是手动触发
使用 lua API:
collectgarbage “step”
collectgarbage “collect”
lua 怎么判定数据可达?
从 GC根集合(root set) 可访问的对象:
gc root set包含三部分:
1、主协程 g->mainthread,其栈记录了当前用到的所有对象
2、注册表 g->l_registry,包含了全局table(_G),记录了全局变量和全局模块,还包括已加载的模块表 package.loaded
3、全局元表 g->mt,每种数据类型各一个,预留9个,暂时只有table和string的实现,效果如io模块的f:read()和 string模块的s:len()
弱引用表
程序是无法推断哪些东西是需要回收的,比如说在数组里的元素,虽然我们不用他了,但是数组还在引用,那么lua就无法自动回收它,这时候就需要弱引用表。
Python
引用计数
Python中的垃圾回收是以引用计数为主,分代收集为辅。
1、导致引用计数+1的情况
对象被创建,例如a=23
对象被引用,例如b=a
对象被作为参数,传入到一个函数中,例如func(a)
对象作为一个元素,存储在容器中,例如list1=[a,a]
2、导致引用计数-1的情况
对象的别名被显式销毁,例如del a
对象的别名被赋予新的对象,例如a=24
一个对象离开它的作用域,例如f函数执行完毕时,func函数中的局部变量(全局变量不会)
对象所在的容器被销毁,或从容器中删除对象
标记删除
针对循环引用这个问题,比如有两个对象互相引用了对方,当外界没有对他们有任何引用,也就是说他们各自的引用计数都只有1的时候,如果可以识别出这个循环引用,把它们属于循环的计数减掉的话,就可以看到他们的真实引用计数了。基于这样一种考虑,有一种方法,比如从对象A出发,沿着引用寻找到对象B,把对象B的引用计数减去1;然后沿着B对A的引用回到A,把A的引用计数减1,这样就可以把这层循环引用关系给去掉了。
不过这么做还有一个考虑不周的地方。假如A对B的引用是单向的, 在到达B之前我不知道B是否也引用了A,这样子先给B减1的话就会使得B称为不可达的对象了。为了解决这个问题,python中常常把内存块一分为二,将一部分用于保存真的引用计数,另一部分拿来做为一个引用计数的副本,在这个副本上做一些实验。比如在副本中维护两张链表,一张里面放不可被回收的对象合集,另一张里面放被标记为可以被回收(计数经过上面所说的操作减为0)的对象,然后再到后者中找一些被前者表中一些对象直接或间接单向引用的对象,把这些移动到前面的表里面。这样就可以让不应该被回收的对象不会被回收,应该被回收的对象都被回收了。
分代回收
分代回收策略着眼于提升垃圾回收的效率。研究表明,任何语言,任何环境的编程中,对于变量在内存中的创建/销毁,总有频繁和不那么频繁的。比如任何程序中总有生命周期是全局的、部分的变量。
Python将所有的对象分为0,1,2三代;
所有的新建对象都是0代对象;
当某一代对象经历过垃圾回收,依然存活,就被归入下一代对象。
他们gc的频率不同,这样可以提高性能
C#
C++
RAII
c++经验之谈一:RAII原理介绍 - allen的文章 - 知乎
RAII(Resource Acquisition Is Initialization)是由c++之父Bjarne Stroustrup提出的,中文翻译为资源获取即初始化,他说:使用局部对象来管理资源的技术称为资源获取即初始化;这里的资源主要是指操作系统中有限的东西如内存、网络套接字等等,局部对象是指存储在栈的对象,它的生命周期是由操作系统来管理的,无需人工介入;
如果要自己写一个内存泄漏,可以考虑在new和malloc里面记录内存。
调试可以用数据断点来操作。
内存碎片
避免内存碎片
- 使用内存池(Memory Pool)
预先分配一大块内存,并在其中管理小块内存的分配和释放。这样可以减少频繁的动态内存分配和释放带来的碎片。
内存池可以根据对象的大小进行分区,确保相同大小的对象使用相同的内存块。
- 对象重用:
尽量重用对象,避免频繁的创建和销毁。可以使用对象池(Object Pool)来管理可重用的对象。
对象池可以在游戏中常用的对象(如子弹、敌人等)中使用,减少内存分配和释放的次数。
- 合理的内存分配策略:
使用合适的内存分配器,选择适合应用场景的分配策略(如分配器的对齐方式、分配大小等)。
避免频繁的小块内存分配,尽量使用较大的内存块进行分配。
- 合并空闲块:
在释放内存时,检查相邻的空闲块并合并它们,以减少外部碎片。
许多内存分配器会自动处理合并空闲块的逻辑。
- 使用智能指针:
在 C++ 中,使用智能指针(如 std::shared_ptr 和 std::unique_ptr)来管理内存,减少内存泄漏和碎片的可能性。
解决内存碎片
- 内存整理(Compaction):
在某些情况下,可以通过内存整理来解决外部碎片。内存整理的过程是将活动对象移动到内存的一端,释放出连续的空闲内存块。
这种方法在实时系统中可能不适用,因为它可能导致停顿。
- 使用更高效的内存分配器:
如果发现当前的内存分配器导致了严重的内存碎片,可以考虑使用其他内存分配器(如 jemalloc、tcmalloc 等),这些分配器在处理碎片方面通常更高效。
- 监控和分析内存使用:
使用内存分析工具(如 Valgrind、Visual Studio 的内存分析工具等)监控内存使用情况,识别和解决内存碎片问题。
定期检查和分析内存使用情况,及时发现和解决潜在的内存碎片问题。
- 重启应用程序:
在某些情况下,重启应用程序可以清除内存碎片,尤其是在长时间运行的应用程序中。