跳转至

【游戏开发】池化技术探讨:对象池/缓存池/内存池

写这篇文章的时候是因为之前看到网上有不只一个面经扯到了用LRU、LFU去优化对象池。一开始我还没反应过来,以为确实可以这样优化。但后面做项目重写了一次对象池发现,实际上对象池根本无法使用LRU策略!!!

这是将各种池化技术混淆在一起的结果,写这篇文章的目的也是为了让大家能正确的分辨各种池化技术的实现与目的。并且以游戏开发常用的C++和C#作为场景,提供常见池化技术方案给大家

对象池、缓存池、内存池有何差异?

先来讲讲这几种池化技术的差异 - 对象池:目的是减少创建和销毁的开销,存储的是不用的相似对象,实际对象并不存储在对象池中。 - 缓存池:提高已有资源利用率。存储的是常用的相似对象 - 内存池:提高内存分配效率,减少空间碎片。存储的是完整的对象和空间碎片

一 对象池的一些特点

对象池本身有很多种实现,先来讲讲特点:

  • 复用和缓存(最大特点):对象池主要目的就是为了方便管理对象和重用对象,避免对象反复创建销毁带来的性能影响。
  • 尽量采用连续结构:一般采用连续结构进行存储,有利于缓存读写
  • 减少GC压力:减少创建销毁开销的附带作用。

对象池将暂时不用的对象进行存储,在需要用到对象池将其从对象池弹出用来实现逻辑,不用的对象重新放入池内,以此来实现减少创建销毁次数的目的

1.1 最基本的对象池

C++/C# 实现1

实现:一个环形双向链表list/LinkedList

优点: 1. 回收时,可自动加入到链表末尾。 2. 不需要扩容

缺点: 1. 内存不连续,访问慢

C++/C# 实现2

实现:一个扩容数组vector/List 优点: 1. 内存连续 2. 利用可变数组特性,池子大小不足可自动扩容自2倍

缺点: 1. 每次获取对象和存入对象需要遍历数组,池子越大访问效率越低 2. 对象池过大扩容导致可能导致空间利用率降低,复制成本高。

1.2 进阶对象池

进阶无非就是同时考虑链表和数组的优点,来达到定制高性能的效果。

C++ 实现1 -- 动态变化最优

实现:用C++的queue完成(deque作为底层),创建对象自动弹出最前面的数据,而回收对象自动加入到队列之后。

优点: - deque的每个块是连续内存,访问效率超越list不少。 - 即便对象池扩大,每次扩容的开销也都是一致的。 - 不用像纯数组每次从头开始遍历一遍

缺点: - 内存非完全连续:并非是完全连续的内存,跨块(512字节)访问依旧是跳跃访问的。

感觉也不算太大的缺点,毕竟queue的用法基本上都是基于连续访问的,而512字节也正好是常见L1告诉缓存的8倍,缓存读写效率应该不会太差。

C++/C# 实现2 -- 定制化最优

实现:分配固定内存做的queue(存固定长度) + 链表。

优点: - 定制化高性能:根据场景非常定制化的对象池,在明确知道对象池开销不会经常超过每个数量的时候,可以根据这个数量设计定长的内存 - 内存占用稳定:如果偶尔有一两个需要扩容,放链表里就行了,不用的时候删掉就好,不会占用内存。

缺点: - 开发者容易使用不当,性能退化:如果开发者错误估计了阈值,那该算法基本上会退化到纯list的形式了。

C# 实现3 -- 扩容少最优

实现:用C#的queue完成(List作为底层),创建对象自动弹出最前面的数据,而回收对象自动加入到队列之后。

优点: - List列表是完完全全的连续内存,访问效率应该是最高的 - 自动扩容 - 不用像纯数组每次从头开始遍历一遍

缺点: - 那当然还是扩容了

不过在确保不会频繁扩容的情况下,此法应当是最优解了。这里贴一个游戏开发常用的跳字对象池实现供大家学习。

public class JumpNumPool {  
    Transform poolRoot;  
    private Queue<JumpNum> jumpNumQue;  

    //初始化池子的大小
    public JumpNumPool(int count, Transform poolRoot) {  
        this.poolRoot = poolRoot;  
        jumpNumQue = new Queue<JumpNum>();  

        for(int i = 0; i < count; i++) {  
            PushOne(CreateOne());  
        }    
    }  

    //每次创建新对象id记一下
    int index = 0;  
    int Index {  
        get {            
            return ++index;  
        }  
    }    

    //创建对象的方法,可根据对象不同酌情修改
    JumpNum CreateOne() {  
        GameObject go = ResSvc.Instance.LoadPrefab("UIPrefab/DynamicItem/JumpNum");  //这里可以改成自己的资源加载和实例化的方法
        go.name = "JumpNum_" + Index;  
        go.transform.SetParent(poolRoot);  
        go.transform.localPosition = Vector3.zero;  
        go.transform.localScale = Vector3.one;  
        JumpNum jn = go.GetComponent<JumpNum>();  
        jn.Init(this);  
        return jn;  
    }  

    //需要使用对象,从队列池子中拿一个
    public JumpNum PopOne() {  
        if(jumpNumQue.Count > 0) {  
            return jumpNumQue.Dequeue();  
        }        
        //如果对象池里已经没有对象,那就需要自己创建对象了。
        else {  
            this.Warn("飘字超额,动态调整上限");  
            PushOne(CreateOne());  
            return PopOne();  
        }    }  

    //对象使用完毕,回收加入队列
    public void PushOne(JumpNum jn) {  
        jumpNumQue.Enqueue(jn);  
    }}

实际开发中,将跳字对象修改成自己需要使用的对象就可以了。也可以将其做成一个对象池模版基类,拓展的时候更方便。

Comments