前言:对C++游戏程序员来说,内存管理是一件相当头疼的问题。因为C++是将内存赤裸裸的交给程序员,而不像Java/C#有gc机制。

好处是这样对于高性能要求的游戏程序,原生的内存分配可以避免gc机制的臃肿操作,从而大大提高性能。
坏处是C++程序员得时时警惕内存问题:

内存泄露问题
do{ T* object = new T(); }while(0);
上面的例子中。忘记回收内存,函数退栈导致丢失了object指针,就再也找回不了new的内存地址,这时内存一直就会被占用着。

内存泄漏很容易理解,不作多讲。

内存碎片问题

由于对堆内存的分配/释放的顺序是随机的,导致申请的内存块随机分布于原始内存,倘若分布不是连续的(随机顺序往往导致多个内存块都是相隔开的),那么便会产生“洞”。



随着时间推移,堆内存越来越多出现这些“洞”,导致可用的自由内存块被拆分成多个小内存块。
这就导致即使有足够的自由内存,分配请求仍然可能会失败。

内存页切换问题

虚拟内存系统把不连续的物理内存块(即内存页)映射至虚拟地址空间,使内存页对于应用程序来说看上去是连续的。

在支持虚拟内存的操作系统上,多次使用原生C/C++内存分配,有可能其中几次是一个内存页,几次是第二个内存页,又有几次是第三个内存页的内容....在重复共同使用这些内存的时候有可能导致昂贵的切换内存页开销。

一些本世代游戏机虽然技术上支持虚拟内存,但由于其导致的开销,多数游戏引擎不会使用虚拟内存。

内存池(Memory Pool)

对C++内存分配进行适合当前程序的封装就显得尤为重要,这样C++程序员就能在封装完内存机制后减少大量心思警惕内存问题。
而如何封装还能高效的使用内存,就成了一门学问——内存池管理。

而内存池是什么:

预先通过new或者malloc(原生的内存分配函数)分配好一个大块内存(挖好池子),然后提供这块内存池的再分配函数。
当程序员需要分配小块堆内存时,可以向这个内存池请求分配小内存。

* 由于内存池本身往往内存比较大,所以内存池本身的分配释放不易产生内存碎片。
* 即使程序员由于操作失误导致内存池内部出现内存碎片或者内存泄漏问题,但是整个内存池本身只要正确释放,内存问题就不会向外扩张。
* 一次性分配好大内存,尽可能减少了多次分配可能导致的过多物理内存页,从而减少了切换内存页开销。
那么接下来就是内存池如何再分配内存给程序员使用的问题了:

堆栈分配器 Stack-based Allocators

堆栈分配器,也就是以类似堆栈的形式分配/释放内存。

它的实现是非常简单的,只要维护一个顶端指针。指针以下的内存是已分配的,以上的内存是未分配的。
每次需要分配内存,只需将顶端指针移上相应的位移大小。但是它的资源释放必须得按堆栈的顺序退栈回滚,把顶端指针一步步下移。



它的分配/释放操作是极为高效的,基本上只需简单地移动顶端指针(其实还有简单地记录回滚位置)。
此外为了让顶端指针正确回滚,再分配内存的时候还得额外分配一个记录用于记录回滚的位置。
class StackAllocator{ private: U32 top; //顶端指针 void* pool; //内存池 public:
//给定总大小,构建一个堆栈分配方式的内存池 StackAllocator(U32 statckSize_bytes);
//从顶端指针分配一个新的内存块,并记录新的回滚位置标记 void* alloc(U32 size_bytes); //从顶端指针回滚到之前的标记位置
void free(); //清空整个堆栈 void clear(); // ... };
由于它每次分配都得额外记录了回滚位置,所以相对比较适合 较大内存对象的分配/释放。
许多游戏都有装载/卸载游戏关卡对象的功能,使用堆栈分配器的内存池往往效果不错。

适用场景:按堆栈顺序分配&释放的对象。

此外部分游戏引擎使用的是 双端堆栈分配器(Double-ended Stack),这样可以从两端入栈退栈资源:
一端用于加载及卸载游戏关卡内存,另一端用于分配临时内存块。



单帧和双缓冲内存分配 Single Frame Memory & Double-buffered Frame Memory

单帧内存分配器:分配内存仅在当前帧有效。
双缓冲内存分配器:分配内存可在本帧/下一帧(两帧)有效。

需要分配只在当前帧(或两帧内) 有效的临时对象时,单帧和双缓冲内存分配器是不二之选。因为使用它们,你可以不用在意对象的内存释放问题:
它们会在一帧后简单地将内存池顶端指针重新指向内存块的起始地址,这样就能极为高效地每帧清理这些内存。
//单帧内存分配 class SingleFrameAllocator{ private: StackAllocator mStack;
//一个堆栈分配的内存池 public: //给定总大小,构建一个单帧分配的内存池 SingleFrameAllocator(U32
statckSize_bytes); //从底层的堆栈内存分配池中分配一个新的内存块 void* alloc(U32 size_bytes);
//游戏循环每帧需调用该函数用于清空堆栈内存池 void clear(); //单帧内存分配没有也不需要单独释放内存的函数 //void free(); //
... }; //双缓冲内存分配 class DoubleBufferedAllocator{ private: U32 mCurStack;
//mCurStack值应总是为0或1,通过逻辑取反来切换 StackAllocator mStack[2]; //两个堆栈分配的内存池 public:
//给定总大小,构建两个堆栈分配方式的内存池 DoubleBufferedAllocator(U32 statckSize_bytes);
//从当前堆栈内存池分配一个新的内存块 void* alloc(U32 size_bytes);
//游戏循环每帧需调用该函数用于清空另一个堆栈内存池,并且切换mCurStack void clear(); //双缓冲内存分配没有也不需要单独释放内存的函数
//void free(); // ... };
适用场景:需要分配只在当前帧(或两帧内) 有效的临时对象。

对象池 Object Pool

对象池,是一个存放内存相同大小对象结构的内存池。
例如粒子对象池存放同种粒子对象,怪物对象池存放同种怪物对象...


template<class T> class ObjectPool{ private: U32 top; //顶端指针索引
std::vector<U32> freeMarks;//存储已释放的索引 T* pool; //内存池 public: //给定总大小,构建一个对象池
ObjectPool(U32 statckSize_bytes); //先从freeMarks查找已释放空闲的内存块
//若无空闲,则从顶端指针分配一个新的对象内存块,上移顶端指针 T* alloc(); //通过指针释放对应的对象内存块,再添加已释放索引到freeMarks
void free(T* ptr); //清空整个对象池 void clear(); // ... };
对于遍历同种对象列表,对象池更加容易命中cpu缓存。

另外在游戏引擎里,每帧都要进行同种组件遍历更新,所以说组件比较适合用对象池存储。
类似的在游戏逻辑里,还有大量同种类怪物都很适合用对象池来存储。

适用场景:需要分配较多同类型的对象。

可整理碎片的内存池

若要分配/释放不同大小的对象(不可用对象池),而且生命周期还不止一两帧(不可单帧和双缓冲内存分配器),而且还是随机次序进行(不可堆栈分配器)。
那么可以考虑实现可整理内存碎片的功能。



重定向指针

若使用可整理碎片的内存池,一般分配函数应该返还一个封装好的智能指针(即指向一个原生指针的指针)。这样当移动复制内存的时候,给智能指针里指向新复制好的内存地址。


不过,需要注意的是,这种智能指针的调用会有两次指针跳转的开销。

分摊碎片整理成本

碎片整理还有个比较苦恼开销较大的操作:复制移动内存块。
所以为了避免一次性大开销(容易造成卡顿),我们无需一次性将所有碎片全部整理,可以将该成本平均分摊至N帧完成。

例如可以设定一帧最多可以进行K次内存块移动(通常是个小数目),这样可以预计大概若干帧便可以把所有碎片全部整理完,而且也不会对游戏造成卡顿的影响(毕竟开销平摊给每帧)。

顽皮狗的引擎中,重定向整理碎片的内存池只应用于游戏对象上,而游戏对象一般很小,从不会超过数千字节。

适用场景:不适用对象池/单帧双帧/堆栈分配的对象,并且整个内存池允许数据总量应该偏小,因为碎片整理是需要付出一定代价的。

额外

* 尽量使用栈内存:这样就可以尽量把内存交给栈管理,而无需考虑堆内存分配的各种问题。
*
慎用STL的智能指针:其使用效率一般不如自定义的好,而且也相对上面自定义内存机制来说更易引发内存碎片问题。若一定要使用,请保证你深入了解STL智能指针并且审慎对待。
*
各种分配方式的内存池是可以且推荐 嵌套使用的。

例如一种可行的内存分配搭配方式是:
双端堆栈分配器作为程序里最大的内存池,它一端分配单帧或双缓冲内存池用于存放帧内临时变量,另一端分配另一个堆栈分配池,该堆栈分配池有含有对象池。

游戏设计模式系列-其他文章:https://www.cnblogs.com/KillerAery/category/1307176.html
<https://www.cnblogs.com/KillerAery/category/1307176.html>