出自图书《制作自己的捕鱼达人》
复杂的内存管理
内存管理一直是一个不易处理的问题,开发者必须考虑分配回收的方式和时机,针对堆和栈做不同的优化处理,等等。内存管理的核心是动态分配的对象必须保证在使用完毕后有效地释放内存,即管理对象的生命周期。由于C++是一个较为底层的语言,其设计上不包含任何智能管理内存的机制。一个对象在使用完毕后必须被回收,然而在复杂的程序中,对象所有权在不同程序片段间传递或共享,使得确定回收的时机十分困难,因此内存管理成为了程序员十分头疼的问题。
另一方面,过于零散的对象分配回收可能导致堆中的内存碎片化,降低内存的使用效率。因此,我们需要一个合适的机制来缓解这个问题。
Boost库引入的智能指针(smart pointer)从对象所有权传递的角度来解决内存管理问题。但是,在很多情况下,智能指针还是显得单薄而无力,因为实际开发中对象间的关系十分复杂,所有权传递的操作在开发过程中会变得冗杂不堪。于是,各种基于C++的第三方工具库和引擎往往都会实现自己的智能内存管理机制来解决内存管理的难题,试图将开发者从烦琐而晦涩的内存管理中解放出来。
2.3.2 现有的智能内存管理技术
目前,主要有两种实现智能管理内存的技术,一是引用计数,一是垃圾回收。
引用计数:它是一种很有效的机制,通过给每个对象维护一个引用计数器,记录该对象当前被引用的次数。当对象增加一次引用时,计数器加1;而对象失去一次引用时,计数器减1;当引用计数为0时,标志着该对象的生命周期结束,自动触发对象的回收释放。引用计数的重要规则是每一个程序片段必须负责任地维护引用计数,在需要维持对象生存的程序段的开始和结束分别增加和减少一次引用计数,这样我们就可以实现十分灵活的智能内存管理了。实际上,这与new和delete的配对使用十分类似,但是很巧妙地将生成和回收的事件转换成了使用和使用结束的事件。对于程序员来说,维护引用计数比维护生命周期信息轻松了许多。引用计数解决了对象的生命周期管理问题,但堆碎片化和管理烦琐的问题仍然存在。
垃圾回收:它通过引入一种自动的内存回收器,试图将程序员从复杂的内存管理任务中完全解放出来。它会自动跟踪每一个对象的所有引用,以便找到所有正在使用的对象,然后释放其余不再需要的对象。垃圾回收器还可以压缩使用中的内存,以缩小堆所需要的工作空间。垃圾回收可以防止内存泄露,有效地使用可用内存。但是,垃圾回收器通常是作为一个单独的低级别的线程运行的,在不可预知的情况下对内存堆中已经死亡的或者长时间没有使用过的对象进行清除和回收,程序员不能手动指派垃圾回收器回收某个对象。回收机制包括分代复制垃圾回收、标记垃圾回收和增量垃圾回收。
2.3.3 Cocos2d-x的内存管理机制
Cocos2d-x很巧妙地运用了前面的引用计数机制。在谈论Cocos2d-x代码风格的时候,多次提到Cocos2d-x来源于Cocos2d-iPhone,因此为了与Objective-C一致,Cocos2d-x也采用了引用计数与自动回收的内存管理机制。熟悉Objective-C开发的读者可以轻松地掌握这种机制。后面还会看到,自动回收在工厂方法等对象生成器中还有特殊的用途。
为了实现对象的引用计数记录,Cocos2d-x实现了自己的根类CCObject,引擎中的所有类都派生自CCObject。在“CCObject.h”头文件中我们可以看到CCObject的定义:
class CC_DLL CCObject : public CCCopying
{
public:
//对象id,在脚本引擎中使用
unsigned int m_uID;
//Lua中的引用ID,同样被脚本引擎使用
int m_nLuaID;
protected:
//引用数量
unsigned int m_uReference;
//标识此对象是否已设置为autorelease
bool m_bManaged;
public:
CCObject(void);
virtual ~CCObject(void);
void release(void);
void retain(void);
CCObject* autorelease(void);
CCObject* copy(void);
bool isSingleRefrence(void);
unsigned int retainCount(void);
virtual bool isEqual(const CCObject* pObject);
virtual void update(ccTime dt) {CC_UNUSED_PARAM(dt);};
friend class CCAutoreleasePool;
};
每个对象包含一个用来控制生命周期的引用计数器,它就是CCObject的成员变量m_u- Reference。我们可以通过retainCount()方法获得对象当前的引用计数值。在对象通过构造函数创建的时候,该引用值被赋为1,表示对象由创建者所引用。在其他地方需要引用对象时,我们会调用retain()方法,令其引用计数增1,表示获取该对象的引用权;在引用结束的时候调用release()方法,令其引用计数值减1,表示释放该对象的引用权。
另一个很有趣的方法是autorelease(),其作用是将对象放入自动回收池(CCAutore- leasePool)。当回收池自身被释放的时候,它就会对池中的所有对象执行一次release()方法,实现灵活的垃圾回收。回收池可以手动创建和释放。除此之外,引擎在每次游戏循环开始之前也会创建一个回收池,在循环结束后释放回收池。因此,即使我们没有手工创建和释放回收池,每一帧结束的时候,自动回收池中的对象也都会被执行一次release()方法。我们马上就会领略到autorelease()的方便之处。
下面是一个简单的例子。可以看到,对象创建后,引用计数为1;执行一次retain()后,引用计数为2;执行一次release()后,引用计数回到1;执行一次autorelease()后,对象的引用计数值并没有立即减1,但是在下一帧开始前,对象会被释放掉。
下面是测试代码:
fish = new CCSprite();
fish->init();
CCLog("retainCount after init:%d",fish->retainCount());
fish->retain();
CCLog("retainCount after retain:%d",fish->retainCount());
fish->release();
CCLog("retainCount after release:%d",fish->retainCount());
fish->autorelease();
CCLog("retainCount afterautorelease: %d",fish->retainCount());
控制台显示的日志如下:
Cocos2d: retainCount after init: 1
Cocos2d: retainCount after retain: 2
Cocos2d: retainCount after release: 1
Cocos2d: retainCount after autorelease: 1
我们已经知道,调用了autorelease()方法的对象(下面简称“autorelease对象”),将会在自动回收池释放的时候被释放一次。虽然,Cocos2d-x已经保证每一帧结束后释放一次回收池,并在下一帧开始前创建一个新的回收池,但是我们也应该考虑到回收池本身维护着一个将要执行释放操作的对象列表,如果在一帧之内生成了大量的autorelease对象,将会导致回收池性能下降。因此,在生成autorelease对象密集的区域(通常是循环中)的前后,我们最好可以手动创建并释放一个回收池。
我们可以通过回收池管理器CCPoolManager的push()或pop()方法来创建或释放回收池,其中的CCPoolManager也是一个单例对象。在这里,我们通过这段简单的代码来分析自动回收池的嵌套机制:
CCPoolManager::sharedPoolManager()->push();
for(int i=0; i<n; i++)
{
CCString* dataItem = CCString::createWithFormat("%d",Data[i]);
stringArray->addObject(dataItem);
}
CCPoolManager::sharedPoolManager()->pop();
这段代码包含了一个执行n次的循环,每次都会创建一个autorelease对象CCString。为了保持回收池的性能,我们在循环前使用push()方法创建了一个新的回收池,在循环结束后使用pop()方法释放刚才创建的回收池。
不难看出,自动回收池是可嵌套的。通常,引擎维护着一个回收池,所有的autorelease对象都添加到了这个池中。多个自动回收池排列成栈结构,当我们手动创建了回收池后,回收池会压入栈的顶端,autorelease对象仅添加到顶端的池中。当顶层的回收池被弹出释放时,它内部所有的对象都会被释放一次,此后出现的autorelease对象则会添加到下一个池中。
在自动回收池嵌套的情况下,每一个对象是如何加入自动回收池以及如何释放的,相关代码如下所示:
//步骤a
obj1->autorelease();
obj2->autorelease();
//步骤b
CCPoolManager::sharedPoolManager()->push();
//步骤c
for(int i=0; i<n; i++) {
obj_array[i]->autorelease();
}
//步骤d
CCPoolManager::sharedPoolManager()->pop();
//步骤e
obj3->autorelease();
2.3.4 工厂方法
工厂方法是程序设计中一个经典的设计模式,指的是基类中只定义创建对象的接口,将实际的实现推迟到子类中。在这里,我们将它稍加推广,泛指一切生成并返回一个对象的静态函数。一个经典的工厂方法如同这样:
CCObject* factoryMethod() {
CCObject* ret = new CCObject();
//在这里对ret对象进行必要的初始化操作
return ret;
}
这段看起来正常的代码其实隐藏着一个问题:工厂方法对ret对象的引用在函数返回时已经结束,但是它没有释放对ret的引用,埋下了内存泄露的隐患。但是,如果在函数返回前就执行release(),这显然是不合适的,因为这会触发对象的回收,再返回的对象指针就成为了错误指针。
autorelease()方法很好地解决了这个问题。此函数结束时我们已经丧失了对ret的引用,为了把ret对象传递给接受者,需要对它进行一次autorelease操作,这是因为虽然我们调用了autorelease方法,但是对象直到自动回收池释放之前是不会被真正释放掉的(通常Cocos2d-x会在每一帧之间释放一次自动回收池),调用者有足够的时间来对它进行retain操作以便接管ret对象的引用权。因此,Cocos2d-x的执行机制很巧妙地保证了回收池中的对象不会在使用完毕前释放。利用autorelease()修改后的工厂方法如下:
CCObject* factoryMethod() {
CCObject* ret = new CCObject();
//这里对ret对象进行必要的初始化操作
ret->autorelease();
return ret;
}
在2.2节中,我们曾提到两种创建对象的方式。使用构造函数创建对象时,对象的引用计数为1,因此调用者需要在使用完毕后谨慎地释放对象;使用工厂方法创建对象时,虽然引用计数也为1,但是由于对象已经被放入了回收池,因此调用者没有对该对象的引用权,除非我们人为地调用了retain()来获取引用权,否则,不需要主动释放对象。
2.3.5 关于对象传值
将一个对象赋值给某一指针作为引用的时候,为了遵循内存管理的原则,我们需要获得新对象的引用权,释放旧对象的引用权。此时,release()和retain()的顺序是尤为重要的。首先来看下面一段代码:
void SomeClass::setObject(CCObject* other){
this->object->release();
other->retain();
this->object = other;
}
这里存在的隐患是,当other和object实际上指向同一个对象时,第一个release()可能会触发该对象的回收,这显然不是我们想看到的局面,所以应该先执行retain()来保证other对象有效,然后再释放旧对象:
void SomeClass::setObject(CCObject* other){
other->retain();
this->object->release();
this->object = other;
}
其他可行的解决方案也有很多,例如使用autorelease()方法来代替release()方法,或在赋值前判断两个对象是否相同。在Google的Objective-C编程规范中,推荐使用autorelease()方法代替release()方法。
2.3.6 释放:release()还是autorelease()?
上面的两个例子实际上提出了一个问题:在使用autorelease()可以达到与release()同样的效果,甚至还能避免release()的许多隐患的情况下,是不是应该完全用autorelease()代替release()呢?
实际上,autorelease()并不是毫无代价的,其背后的垃圾池机制同样需要占用内存和cpu资源,每次执行autorelease()的过程,实际上对应的是执行成对的retain()和release(),以及一次成对的容器存取,还包括其他的逻辑判断。过多不必要的autorelease()将导致垃圾池臃肿膨胀,在存在大量内存操作的程序中会尤为严重地挤占本来就紧张的系统资源。
此外,autorelease()只有在自动释放池被释放时才会进行一次释放操作,如果对象释放的次数超过了应有的次数,则这个错误在调用autorelease()时并不会被发现,只有当自动释放池被释放时(通常也就是游戏的每一帧结束时),游戏才会崩溃。在这种情况下,定位错误就变得十分困难了。例如,在游戏中,一个对象含有1个引用计数,但是却被调用了两次autorelease()。在第二次调用autorelease()时,游戏会继续执行这一帧,结束游戏时才会崩溃,很难及时找到出错的地点。
因此,我们建议在开发过程中应该避免滥用autorelease(),只在工厂方法等不得不用的情况下使用,尽量以release()来释放对象引用。
2.3.7 容器
Cocos2d-x引擎为我们提供了CCArray、CCDictionary等Objective-C风格的容器。对C++标准库比较熟悉的读者可能疑惑,开发过程中为什么不直接使用vector等标准库已经提供的高效容器呢?
使用Cocos2d-x容器的一个重要原因在于Cocos2d-x的内存管理。一般来说,被存入容器的对象在移除之前都应该保证是有效的,回顾一下引用计数的管理原则,对象的存入和移除必须对应一组retain()和release()或者对应autorelease()。直接使用STL容器,开发者势必进行烦琐重复的内存管理操作,而Cocos2d-x容器对这一过程进行了封装,保证了容器对对象的存取过程总是符合引用计数的内存管理原则。
按照Cocos2d-x容器的内存管理要求,存入容器的对象必须是CCObject或其派生类。同时,Cocos2d-x的容器本身也是CCObject的派生类,当容器被释放时,它保存的所有元素都会被释放一次引用。以下代码节选自线性表容器CCArray的定义,CCArray的代码位于引擎目录下的“cocos2dx\cocoa\CCArray.h(.cpp)”文件中:
class CC_DLL CCArray : public CCObject
{
public:
~CCArray();
...
bool initWithObjects(CCObject* pObject,...);
...
};
此外,对于跨语言移植游戏(如从Objective-C移植到C++)的开发者而言,把原游戏中大量使用的容器全部替换为STL库容器是一个极富挑战性的任务。容器存在的意义不仅仅局限于内存管理方面,因此我们应该尽量采用Cocos2d-x提供的容器类。
2.3.9 Cocos2d-x内存管理原则
至此,我们已经对Cocos2d-x采用的内存管理机制有了一个完整的认识。最后,我们将总结使用Cocos2d-x开发游戏时内存管理的原则:
程序段必须成对执行retain()和release()或者执行autorelease()来声明开始和结束对象的引用;
工厂方法返回前,应通过autorelease()结束对该对象的引用;
对象传值时,应考虑到新旧对象相同的特殊情况;
尽量使用release()而不是autorelease()来释放对象引用,以确保性能最优;
保存CCObject的子类对象时,应严格使用Cocos2d-x提供的容器,避免使用STL容器,对象必须以指针形式存入。
如果希望自定义的类也拥有Cocos2d-x的内存管理功能,可以把CCObject作为自定义类的基类,并在实现类时严格遵守Cocos2d-x的内存管理原则。