游戏编程模式-对象池
“使用固定的对象池重用对象,取代单独的分配和释放对象,以此来达到提升性能和优化内存使用的目的。”
动机
假设我们正在致力于游戏的视觉效果优化。当英雄释放魔法时,我们想让一个火花在屏幕上炸裂。这通常需要一个粒子系统(一个用来生成大量小的图形并在它们生存周期产生动画的引擎)来实现。而这个粒子系统实现这个火花的时候会产生大量的粒子,我们需要非常快速的创建这些粒子同时在这些粒子“死亡”的时候释放这些粒子对象。在这里,我们会碰到一个严重的问题——内存碎片化。
碎片化地害处
为游戏和移动设备编程在很多方面都比传统PC编程更接近于嵌入式编程。就像嵌入式编程一样,内存是稀缺地。用户希望游戏稳定运行,但这些平台上往往不具备高效地内存压缩管理器,一旦发生内存碎片化地问题,往往是致命的。碎片化意味了空闲的堆空间分成了很多小的内存碎片,而不是一整块连续的内存。这个时候可用的内存总量很大,但最长的、连续的区域却小的可怜。比如现在有两个不连续的7个字节的区域,那么总的可用的内存为14字节,但这个时候如果像分配一个12字节的区域却是不可能的,也就是说操作系统处理内存申请的时候是直接分配一整块连续可用的内存。
而且,即使碎片化的情况很少,它也仍然在消减着内存并使其成为一个千疮百孔而不可用的泡沫块,严重局限了整个游戏的表现力。
二者兼顾
由于碎片化,以及内存分配缓慢,我们必须很小心的处理内存分配。一个常用而有效的解决方案是,在游戏开始时申请一大块内存,然后在游戏中一直持有,知道游戏结束才释放。但这样一来,在游戏运行过程中创建和销毁对象对系统来说将会是一个很大的负担(内存只是被分配,但如何初始化为一个对象?如何回收这个对象?)。使用对象池可以让我们二者兼顾:对于内存管理器来说,我们仅分配一块很大的内存知道游戏结束释放它,而对于内存池的使用者来说,我们可以按照自己的意愿来分配和释放对象。
对象池模式
定义一个保持着可重用对象集合的对象池类,其中每个对象支持对其“使用(in use)”状态的访问,以确定这一对象目前是否“存货(alive)”。在对象池初始化时,它预先创建整个对象的集合(通常为一块连续的堆区域),并将它们置为“未使用(not in use)“状态。当你想要创建一个新对象时,就向对象池发送请求,对象池将搜索一个”未使用“状态的对象,然后将其状态改为”使用中“,然后返回给你。当对象不再使用时,再将其状态改为”未使用“,然后返回对象池中。这样,当你需要一个新对象的时候就无需进行内存或其他资源的分配和销毁了。
使用情境
这一设计模式被广泛的使用于游戏中的可见物体,如游戏实体对象、各种视觉特效,但同时也被使用于非可见的数据结构中,比如音频。我们在以下情况使用对象池:
- 当你需要频繁的创建和销毁对象时;
- 对象大小一致时;
- 在堆上进行对象内存分配较慢或会产生内存碎片时;
- 每个对象封装着获取代价昂贵并且可重用的资源时,如数据库,网络来连接。
使用须知
你一般依赖垃圾回收期或new和delete来管理内存,使用对象池模式之后,就意味着你告诉系统“我更明白这些自己如何使用”。也就意味着这个模式的规则完全由你来决定。
对象池可能在闲置的对象上浪费内存
对象池的大小需要根据游戏的需求量身定制。太小的情况往往很容易发现,这时对于新的请求无可用的对象;但也不能让对象池过大,游戏的其他模块同样需要内存。所以我们要在满足需求的同时尽量控制对象池的大小。
任意时刻处于存活状态的对象最大数目恒定
很明显,对象池是一块固定的内存,在这块内存上构建的对象数目是一定的,所以存活的对象的最大数据就是对象池中对象的数量。那么这个时候我们会碰到如下情况:当你向对象池申请新的对象时,可能会失败,因为对象池的对对象都在被使用。对于这种情况通常的对策由:
- 阻止其发生。这也是最常见的”修复方法”:扩大对象池。保证无论使用者如何分配都不会造成溢出。这是行之有效的。因为并没有什么所谓的”正确“的方法来处理对象池最后无可用对象这样的情况,所以最聪明的方法还是从根本上避免其发生。但这种情况导致的一个副作用就是为了其罕见的边际情况腾出大量的空间。所以,单一的固定大小的对象池并不适用于所有的游戏状态。例如有些关卡关注视觉特效和另一些关卡关注音效。这个时候可以根据场景需求动态调整对象池的大小。
- 不创建对象。这听起来很残忍,但这在某些情况下非常有效,比如粒子系统。当粒子系统中可用的对象都耗尽时,那么屏幕基本上被闪光的粒子覆盖了,这个时候你不创建新的粒子玩家也注意不到。
- 强行清理现存对象。以一个音效池来说,当我们需要播放一个新的音效时,对象池空了,但我们又不希望玩家忽视这个音效,那么我们可以找一个最被玩家注意到音效,然后回收它用于新的音效。新的音效将掩盖旧音效的中断。一般来说,如果新对象的出现能让我们无法觉察既有对象的消失,那么清理现存对象往往是一个很好的方法。
- 增加对象池大小。假如游戏允许你调配更多的内存,那么你可以在运行时对对象池扩容,或者增设一个二级的溢出池。假如你通过上述任何一种方法获取到更多的内存,那么当这些额外的内存不再被使用的时候你就必须考虑是否将对象池恢复到扩容之前。
每个对象的内存大小是固定的
多数对象池把创建的对象原地存入一个数组中,如果你的对象都是同一类型,这个当然没有问题。但如果你的对象池中需要存入不同类型的对象或子类型(子类型有额外的数据成员),那么你需要保证对象池的槽能够容纳最大的对象。否则大的对象占用相邻对象的空间,引发程序崩溃。另外来讲,当你的对象大小不一的时候,将会很浪费内存。因为需要保证每个槽都能存入最大的最大的对象,当槽中存入小的对象的时候,空闲的内存就被浪费了。对于这种情况,一个有效的额解决方案就是根据对象的尺寸将一个池分为多个大小不同的池。
重用对象将不会被自动处理
多数内存管理器都有一个排错特性,它们将刚需分配或者刚释放的内存置成某些特定值(比如null)。这一做法将帮助你找到那些“未初始化的变量”或者“使用了已释放内存”引发的致命错误。由于对象池并不通过内存管理器来重用对象。所以我们丧失了这层安全保障。更可怕的是,这些“新”对象使用的内存先前存储着另一个同类型的对象。这将使你无法分辨这个对象在创建的时候是否初始化。鉴于此,需要特别注意用于初始化对象池中新对象的代码是否完整地初始化了对象。甚至值得花些工夫为回收对象槽内存增设一个排错功能。
未使用地对象将占用内存
对象池在那些支持垃圾回收机制地系统中较少被使用,因为内存管理器通常会替你进行内存碎片处理。当然对象池在节省内存分配和释放开销方面依然会有所作为,在CPU处理速度较慢且回收机制较为简单地移动平台上尤其如此。假如你使用了对象池,请注意一个潜在地矛盾:由于对象池在对象不使用地时候并不真正地释放它,故它们将仍热占用内存。假如它们包含了其他对象地引用,那么也将阻碍内存管理器对它们进行回收。为了避免这个问题,当对象池中地对象不再被需要时,应当清空对象指向其他任何对象的引用。
示例代码
我们先来模拟一个简单的粒子系统,这个粒子系统现在只能直线移动一些距离,并在结束后销毁它们。我们使用这个简单的粒子系统来展示一下对象池的应用。让我们从最简单的实现开始,首先是粒子类:
class Particle { public: Particle():framesLeft_(0) {} void init(double x, double y, double xVel,double yVel, int lifeTime)
{
x_=x;
y_=y;
xVel_=xVel;
yVel_=yVel;
framesLeft_ = lifeTime;
} void animate()
{
if(!inUse()) return;
framesLeft_--;
x_+=xVel_;
y_+=yVel_;
}
bool inUse() const { return framesLeft_>0; } private: int framesLeft_; double x_,y_; double xVel_,yVel_; };
默认构造函数将粒子初始化为”未使用“状态。接下来调用init将其状态初始化为“使用中”。粒子随着时间播放动画,并逐帧调用函数animate。因为粒子的生命周期有限,我们可以使用变量framesLeft_变量来检查哪些粒子正在被使用。
对象池类很简单:
class ParticlePool { public: void create(double x,double y,double xVel,double yVel,int lifeTime) { for(int i=0;i<POOL_SIZE;++i) { if(!particles_[i].inUse()) { particles_[i].init(x,y,xVel,yVel,lifeTime); return; } } } void animate() { for(int i=0;i<POOL_SIZE;++i) { particles_[i].animate(); } } private: static const int POOL_SIZE=100; Particle particles_[POOL_SIZE]; };
对象池内提供一个create的类,它找到对象池中第一个未使用的粒子,然后调用init把其状态改为使用中。animate则是把遍历池中所有“使用中”状态的粒子,然后调用其animate函数模拟粒子运动。在这里,我们简单的把对象池的大小硬编码固定了,我们也可以根据所给的大小使用动态数组,或者使用模板函数来定义。而且我们也没有处理对象池空了的情况,但这个对象池已经可以简单使用了。
这里有个问题,就是我们寻找“未使用”的时候粒子时,是通过遍历对象数组查找的,当这个数组很大的时候而且大部分粒子都在使用的时候,此时创建一个粒子将会十分缓慢。我们有一些方案来提升它的性能。
空闲表
如果我们不想浪费时间去检索空闲的粒子,那么显然我们得跟踪它们,我们可以维护一个指向每个未使用粒子得列表,然后每次使用的时候从这个列表的头部移出一个粒子进行重用即可。不幸的是,这可能要求我们同样管理一个如同对象池一样大的指针列表。毕竟一开始的时候所有的粒子都是未使用的。这样太浪费内存了,如果能能不使用额外的内存就能解决这个问题就太好了。在c++语言中,有一个解决方法是使用这些粒子本身内存来解决这个问题的——就是union(联合体)。
很显然,未使用的粒子,它的内部状态大多是未定义的。比如我们例子中粒子的位置和速度,而对于这些未使用的粒子我们真正需要的就是表示它是否在使用中的属性,在本例子中是framesLeft_。其它的未使用的属性我们可以用来指向下一个未使用的粒子。对此我们的修改如下:
class Particle { public: Particle* getNext() const { return state_.next;}; void setNext(Particle* next) { state_.next = next; } private: int framesLeft_; union { struct { double x,y,xVel,yVel; } live; Particle* next; } state_; };
这个方法的巧妙之处就是union的使用,当粒子被使用的时候这块内存储存的就是位置和速度,如果未使用则储存的是下一个未使用粒子的指针。通过这些未使用粒子自身的内存空间来存储这个列表的方法被称之为空闲表(free list)。为了使其正常的工作,我们需要确保正确地初始化指针以及在创建和销毁粒子时保持住指针。当然,我们也需要时刻跟踪这个列表的头指针。
class ParticlePool { //previous stuff.. private: Particle* firstAvailable_; };
当对象池首次被 创建时,所有的粒子均处于可用状态,故我们的空闲表贯穿了整个对象池,对象池的构造函数如下:
ParticlePool::ParticlePool() { //The first one is available. firstAvailable_ = &particles_[0]; //each particle points to the next.. for(int i=0;i<POOL_SIZE-1;++i) { particles_[i].setNext(&particles_[i+1]); } //the last one terminates the list. particles_[POOL_SIZE-1].setNext(NULL); }
现在创建一个新粒子时我们跳转到第一个空闲的粒子:
void ParticlePool::create(double x,double y, double xVel, double yVel, int lifetime) { //make sure the pool isn't full. assert(firstAvailable_ != NULL); //Remove is from the available_ list. Particle* newParticle = firstAvailable_; firstAvailable_ = newParticle->getNext(); newParticle->init(x,y,xVel,yVel,lifetime); }
我们需要获知粒子何时死亡并将它置回空闲表中。于是我们将粒子类中的animate改为当这个存活的粒子在某一帧死掉时函数返回true。
bool Particle::animate() { if(!inUse()) return false; framesLeft_--; x_ += xVel_; y_ += yVel_; return framesLeft_ == 0; }
当粒子死亡时,我们就把这个粒子添回到空闲表:
void ParticlePool::animate() { for(int i=0;i<POOL_SIZE;++i) { if(particles_[i].animate()) { //add this particle to the front of the list.. particles_[i].setNext(firstAvailable_); firstAvailable_ = &particles_[i]; } } }
这就是了,我们实现了一个漂亮的小型对象池,该对象池在创建和删除对象时具有常量时间开销。
设计决策
如你所见,最简单的对象池实现几乎没有什么特别的,创建一个对象数组并在它们被需要时重新初始化。实际项目中的代码可不会这么简单。还由许多扩展对象池的方法,来使其更加通用、安全、便于管理。当你在自己的游戏中使用对象池时,你需要回答以下问题:
对象是否被加如对象池
当你在编写一个对象池时,首先要问的一个问题就是这些对象自身是否能知道自己处于对象池中。多数时间它们是知道的。但你不需要在一个可以存储任意对象的通用对象池类中做这项工作。
假如对象与对象池耦合。
- 实现很简单,你可以简单的未哪些池中的对象增加一个“使用”的标志位或者函数,这样就能解决问题了;
- 你可以保证对象只能通过对象池创建。这在c++中只需要把对象池类声明位对象类的友元类,并将对象类的构造函数私有化即可。
class Particle { friend class ParticlePool; private: Particle():inUse_(false) {} bool inUse_; }; class ParticlePool { Particles pool_[100]; };
上述代码直接指出了该对象类的使用方法(只能通过对象池类来创建对象);确保了开发者不会创建出超出脱离对象池管理的对象。这样做的好处是:
- 你可以避免储存一个“使用中”的标志位,许多对象已经维护了可以表示自身是否仍然存活的状态。例如,粒子可以通过“位置已经离开屏幕范围“来表示自身可被重用。假如对象类知道自己可能被对象池使用,可以提供inUse函数来检查这一状态。这避免了使用额外的空间来存储”标志位”。
假如对象独立于对象池
- 任意类型的对象都可以被置入池中。这是个巨大的优点,通过对象与对象池的解绑,你将能够实现一个通用、可重用的对象池类。
- “使用中”状态必须能够在对象外部被追踪。最简单的做法是在对象池中额外创建一块独立的空间:
template<class TObject> class GenericPool { private: static const int POOL_SIZE = 100; TObject pool_[POOL_SIZE]; BOOL inUse_[POOL_SIZE]; };
谁来初始化那些被重用的对象
为了重用现存的对象,他需要被重新初始化位新的状态。一个关键的问题在于实在对象池中初始化对象还是在外部初始化对象。
假如在对象池中初始化重用对象
- 对象池可以完全封装它管理的对象。这却决于你定义的对象类的其它功能,你或许能够完全置于对象池内部。这样可以确保外部代码不会引用到这些对象而引起意外的重用。
- 对象池于对象如何初始化密切相关。一个置入池中的对象可能会提供多个初始化函数。
class Particle { public: //Multiple ways to initialize void init(double x,double y); void init(double x,double y,double angle); void init((double x,double y,double xVel,double yVel); };
假如由对象池进行初始化管理,那么其接口必须支持所有的对象初始化方法,并相应的初始化对象。
class ParticlePool { public: void create(double x,double y) { //Forward to Particle... } void create(double x, double y,double angle) { //Forward to Particle.. } void create(double x,double y, double xVel, double yVel) { //Forward to Particle... } };
假如对象在外部被初始化
此时对象池的接口会简单一些。对象池只需简单的返回新对象的引用即可,而无需像上面那样提供不同的初始化接口来处理对象不同的初始化方法。
class ParticlePool { public: Particle* create() { //Return reference to available particle.. } private: Particle pool_[100]; };
调用者可以使用粒子类爆出的任何初始化接口来初始化对象:
ParticlePool pool; pool.create()->init(1,2); pool.create()->init(1,2,0.3); pool.create()->init(1,2,3.3,4.4);
外部编码可能需要处理新对象创建失败的情况。先前的例子假设了create一定会成功的返回一个指向对象的指针。假如对象池已经满了,那么它应当返回NULL。安全起见,你应该在初始化之前检测新的这个对象指针是否为空:
Particle* particle=pool.create(); if(particle != NULL) { particle->init(1,2); }