对象池设计模式
前言
一个相当经典的设计模式。因为项目中遇到一些关于对象池的问题,干脆就在这里整一下相关的知识点吧。
为什么需要对象池
对象池主要解决两个核心问题:一是避免频繁实例化/销毁对象带来的GC压力,二是减少内存碎片化。
就拿最简单明了的子弹来进行说明,如果游戏里面的枪械,我们希望它能够频繁的射出各种类型的子弹,那么使用最直接的形式————>需要的时候生成,不需要的时候销毁掉。这样做当然可以实现,但是这样做会有什么问题呢?
首先不难看出这样的效率低。我们当前不需要的就只能销毁掉,无法被拿出再次利用,每次都得走一遍重复的创建效果流程,这是完完全全的铺张浪费,此外如果是Unity的话,Destroy后的对象即使被销毁后,仍然可能残留在内存块中无法被复用,更别提这样大量频繁的操作会带来多少的开销了。
除此之外,这种形式还有可能造成严重的内存碎片化。
什么是内存的碎片化
内存碎片化是指物理内存空间被分割成大量不连续的小块,导致即使总空闲内存足够,也无法满足较大内存的连续分配请求。
内存的碎片化可以分为两类:外部的碎片化和内部的碎片化。我们这里主要阐述外部的碎片化。
这里直接借用《游戏设计模式》中的图来呈现,一目了然。
假设内存中有 3 个空闲块:[10MB] [被占用] [5MB] [被占用] [20MB],但程序需要分配 30MB 连续内存,此时即使总空闲内存是 35MB,仍然无法分配。
为什么说频繁地生存消耗会导致内存的碎片化?
一句话概括就是动态内存分配和释放的不可预测性。游戏中的不同资源可能需要不同大小的内存块,比如上面说的“各种类型的子弹”,每种子弹需要的空间并不是一样的。较小的内存请求可能会导致较大的内存块被分割成多个小块,从而增加外部碎片化的风险。也因此,当游戏频繁创建销毁的时,内存管理器无法高效地维护连续的内存空间,就会最终导致内存被分割成许多小块。想象一下猫和老鼠里面的奶酪,上面的空洞就是被占用的内存块,它们会把完整的内存进行切割成不同的小块,而无法连续利用。
还是再说一下Unity的Instantiate和Destroy的情况。当我们使用Instantiate去进行生成和初始化的时候,会立刻进行空间分配,而当这个GO被Destroy的时候,Unity会将它标记为“可被回收”,但这只是被标记,需要等待下一次GC进行回收,这期间就会出现即使GO被所谓的“销毁”,但是其空间仍然无法被复用的情况。
那我用DestroyImmediate不就行了吗?确实DestroyImmediate可以立刻销毁,但是这种形式也是有其对应的代价的,这里不详细展开。
哪怕碎片化发生得不频繁,它也仍会逐渐把堆变成有空洞和裂隙的不可用泡沫,最终完全无法运行游戏。
内存碎片化相关更多的的可以查看参考文档。
是时候引出我们的主角————对象池模式。通过使用对象池可以有效避免频繁的创建和销毁,减少gc,预加载等开销,提高内存的利用率等
如何设计一个的对象池
关于对象池的概念和使用这些就不多赘言了,感兴趣可以从参考文档中找到入口。这里主要介绍一下如何设计一个好的对象池。
对象池的基本实现
一个对象池最基本的功能就是能提供和缓存某一类型的对象。
当外部需要这个类型的对象的时候,如果池中有空闲的对象,就直接从池中取出,否则就创建一个新的对象。当对象不再需要时,就将其放回池中,以便下次使用。
如果能实现这一点,就相当于实现了一个基本的对象池。
这里我们参考GF,对这部分感兴趣可以直接参考文档里面的链接。
在ReferenceCollection中,表示每一个类型的池子。
1 | //基本定义,以队列形式存储对象 |
而如果要管理这种每一个类型的池子,参考ReferencePool,则用一个简单的字典定义即可。
1 | private static readonly Dictionary<Type, ReferenceCollection> s_ReferenceCollections = new Dictionary<Type, ReferenceCollection>(); |
优化内存访问效率
预分配策略
在空闲的时候去预加载一些,通过进行预分配,可以避免运行时动态分配造成的卡顿。
当然需要根据游戏场景分析,通常预加载预计峰值用量的70-80%。
冷热对象分离策略
在一个池子里面再去细分“热池”和“冷池”,根据对象使用频率进行分级管理,以提高内存的访问效率。
同一个池子里面,可能存在许多的缓存对象,当我们频繁从池子里面取的时候,便有可能一些对象会被高频使用,而一些对象则会被低频使用。其判断标准根据上一次使用的时间来定,可以每隔一段时间进行一次检查,进行对象的池内迁移。针对高频使用的我们在池子里面把对象细分到“热池”里面,而针对低频使用的我们在池子里面把对象细分到“冷池”里面。我们取的时候,先从热池取,如果没有,再从冷池取,如果没有,再去创建一个新的对象。
这里要说一个东西叫做 缓存命中率,它是我们从池子里面取对象的时候,从热池还是冷池取,从热池获取叫做缓存命中,从冷池取或者新建对象叫做缓存未命中。
所以我们可以得到计算公式:
1 | int totalRequests = hotHits + coldHits + newCreations; |
为了保证缓存的高效性,我们需要考虑根据缓存命中率来动态调节热池的大小
1 | void AdjustPoolSizes() { |
而联系到上面预分配,当热池里面剩余容量不足约30%的时候,且可能存在大量使用的情况下,我们就可以预加载,从冷池里面进行获取。
当物理内存不足时,系统会根据特定策略(LRU,LFU)将最久未使用的或者说使用频率最低的部分内存页进行换出。通过冷热分离的形式,热池里面的对象在CPU中因为会被频繁使用,所以会常驻L1/L2缓存中,而冷池则可能仅在L3或者主存里面。采用这种形式可以减少获取获取热对象的延迟,减缓获取冷对象的延迟,但整体获取的平均延迟是减少了的,也因此可以减少gc的触发频率。
其他优化策略
- 定时释放。当池子里面的物体存在固定时间没有被使用的时候,就进行释放。
- 数据统计。统计当前池中的对象数量,释放数量,缓存数量等等。这些可以方便即时查看以便查找问题。
- 增加锁定功能。当我们希望某个物体长期不使用也不会被释放,被需要的就可以快速响应的时候,可以锁定对应的物体。比如主页面。
- 拓展存储形式。比如通过调整key值的分配和基类的缓存去实现一个类型的池子中可以缓存都是这个类型但是细节有区分的物体。