在C++中,动态内存分配是一把双刃剑,一方面,直接访问内存地址提高了应用程序的性能,与使用内存的灵活性;另一方面,由于程序没有正确地分配与释放造成的例如野指针,重复释放,内存泄漏等问题又严重影响着应用程序的稳定性。
人们尝试着不同的方案去避免这个问题,比较常用的如智能指针,自动垃圾回收等,这些要么影响了应用程序的性能,要么仍然需要依赖于开发者注意一些规则,要么给开发者带来了另外一些很丑陋的用法(实际上笔者很不喜欢智能指针)。因此,优秀的C++内存管理方案需要兼顾性能,易用性,所以到目前为止C++标准都没有给出真正的内存管理方案。
Cocos2d-x的内存管理机制实际上来源于Objective-C,这套机制几乎贯穿Cocos2d-x中所有的动态分配的对象。它使得管理动态分配到堆上的对象更简单,然而它独特的工作机制也使得一些开发者,尤其是不熟悉Objective-C的开发者对其造成一些”误解”。确保完整的理解,以及正确地使用Cocos2d-x的内存管理机制,是使用Cocos2d-x必须具备的基础准备工作。
一、C++显式堆内存管理
C++使用new关键字在运行时给一个对象动态分配内存,并返回堆上内存的地址供应用程序访问,通过动态分配的内存需要在对象不再被使用时通过delete运算符将其内存归还给内存池。
显式的内存管理在性能上有一定优势,但是极其容易出错,事实上,我们总是不能通过人的思维去保证一个逻辑的正确。不能正确处理堆内存的分配与释放通常会导致以下一些问题:
野指针:指针指向的内存单元已经被释放,但是其他一些指针可能还指向它,这些内存可能已经被重新分配给其他对象,从而导致不可预测的结果。
重复释放:重复释放一个已经被释放的内存单元,或者释放一个野指针(也是重复释放)都会导致C++运行时错误。
内存泄漏:不再被使用的内存单元如果不被释放就会一直占用内存单元,如果这些操作不断重复就会导致内存占用不断增加,在游戏中内存泄漏尤其严重,因为可能每一帧都在创建一个永远不会被回收的游戏对象。
二、C++11中的智能指针
根据用于分配内存的方法,C++有3种管理数据内存的方式:自动存储,静态存储和动态存储。其中静态存储用于存储一些整个应用程序执行期间都存在的静态变量,动态存储用于存储上一节讲述的通过new分配的内存单元。
而对于在函数内部定义的常规变量则使用自动存储空间,其对应的变量称为自动变量。自动变量在所属的函数被调用时自动产生,在该函数结束时消亡。实际上,自动变量是一个局部变量,其作用域为包含它的代码块。自动变量通常存储在栈上,这意味着进入代码块时,其中的变量将依次加入到栈中,而在离开该代码块时按相反的顺序释放这些变量。
由于自动变量通常不会导致内存问题,所以智能指针试图通过将一个动态分配的内存单元与一个自动变量关联,这个自动变量在离开代码块被自动释放的时候释放其内存单元,这使得程序员不再需要显式地调用delete就可以很好的管理动态分配的内存。
C++11使用三种不同的智能指针,unique_ptr,shared_ptr和weak_ptr。它们都是模板类型,我们可以通过如下的方式来使用它们:
int main(){
unique_ptr up1(new int(11));
unique_ptr up11=up1; //编译报错
shared_ptr up2(new int(22));
weak_ptr up3=up2;
}
每个智能指针都重载了*运算符,我们可以使用*up1这样的方式来访问所分配的堆内存。智能指针在析构或者调用reset成员的时候,都可能释放其所拥有的堆内存。三者之间的区别如下:
unique_ptr不能与其他智能指针共享所指对象的内存,例如通过将up1赋值给up11将会导致编译错误。但可以通过标准库的move函数来转移unique_ptr对对象的”拥有权”,一旦转移成功,原来的unique_ptr指针就失去了对象内存的所有权,再使用则会导致运行时错误。
多个shared_ptr则可以共享同一堆分配对象的内存,它在实现上采用引用计数,一旦一个shared_ptr放弃了所有权(调用了reset成员)并不会影响其他智能指针对象。只有所有引用计数归零的时候,才会真正释放所占有的堆内存的空间。
weak_ptr可以用来指向shared_ptr分配的对象内存,但是却并不拥有该内存,我们可以使用其lock成员来访问其指向内存的一个shared_ptr对象,当其所指向的内存无效时,返回指针空值(nullptr)。weak_ptr通常可以用来验证shared_ptr的有效性。
三、为什么不使用智能指针
看起来shared_ptr是一个完美的内存管理方案,然而实际上至少有两点原因使得Cocos2d-x不应该使用智能指针:
首先,智能指针有比较大的性能损失,Cocos2d-x论坛有过讨论是否使用智能指针的帖子[引用1],shared_ptr为了保证线程安全,必须使用一定形式的互斥锁来保证所有线程访问时其引用计数保持正确。这种性能损失对于一般的使用是没有问题的,然而对于游戏这种实时性非常高的应用程序却是不可接受的,游戏需要一种更简单的内存管理模型。
其次,虽然智能指针能帮助程序员进行有效的堆内存管理,但是它还是需要程序员显式地声明智能指针,例如创建一个Node的代码需要这么写:
shared_ptr node(new Node());
另外,在我们需要引用的地方一般应该使用weak_ptr,否则在Node被移除的时候shared_ptr就会指向一个已释放的内存,导致运行时错误:
weak_ptr refNode=node;
这些额外的约束使得智能指针使用起来很不自然,因此笔者特别讨厌智能指针,这种用一种约束的方式来避免逻辑错误虽然可取,但是却并不是一种优雅的方式,毕竟我们程序员要天天面对代码,我们需要更自然的内存管理方式,就像语言自身的特性一样,我们甚至几乎可以察觉不到其背后的机制。
四、垃圾回收机制
实际上,这样的方案已经存在,这就是垃圾回收机制。垃圾回收的堆内存管理将之前使用过,现在不再使用或者没有任何指针再指向的内存空间称为“垃圾”,将这些“垃圾”收集起来以便再次利用的机制称为“垃圾回收”。垃圾回收大约在1959年前后,由约翰 麦肯锡(John MaCarthy)为Lisp语言发明,在编程语言发展的过程中,垃圾回收的堆内存管理也得到了很大的发展,如今流行的一些语言如Java,C#,Ruby,PHP,Perl等都支持垃圾回收机制。
垃圾回收主要有两种方式:
基于引用计数:引用计数使用系统记录一个对象被引用的次数,当对象被引用的次数变为0时,该对象即被视作垃圾而被回收。这种算法的优点是实现方式比较简单。
基于跟踪处理:这种方法是产生跟踪对象的关系图,然后进行垃圾回收。其算法是首先将程序中正在使用的对象视为“根对象”,从根对象开始查找它们所引用的堆空间,并在这些堆空间上做标记。当标记结束之后所有未被标记的对象即被视作垃圾,在第二阶段会被清理。其第二阶段可以使用不同的方式进行清理,直接清理可能会产生大量垃圾碎片,另一些方法对正在使用的对象进行移动或者拷贝,从而减少内存碎片的产生。
不管哪种方法,自动垃圾回收都可以使得内存管理更自然,更重要的是程序员几乎不用为此做出任何被约束的事情。
五、Cocos2d-x内存管理机制
然而垃圾回收机制通常需要语言级别的实现,C++目前并没有包含完整的垃圾回收机制。Cocos2d-x中的内存管理机制实际上是基于智能指针的一个变体。但是它同时使得程序员可以像垃圾回收机制那样不需要声明智能指针。
1)引用计数
Cocos2d-x中所有对象几乎都继承自Ref基类,Ref唯一的职责就是对对象进行引用计数管理
class CC_DLL Ref
{
public:
void retain();
void release();
Ref* autorelease();
unsigned int getReferenceCount() const;
protected:
Ref();
protected:
/// count of references
unsigned int _referenceCount;
friend class AutoreleasePool;
};
当一个对象被使用new运算符分配内存时,其引用计数为1,调用retain()方法会增加其引用计数,调用release()则会减少其引用计数,release()方法会在其引用计数为0时自动调用delete运算符删除对象并释放内存。
除此之外,retain和release并没有做任何特别的事情,它只是帮助我们记录了一个对象被引用的次数,实际上在程序中很少直接单独使用retain 和release,因为最终最重要的还是要在设计的时候就明确它应该在哪个地方被释放,大多数引用的地方都只是一种弱引用关系,使用retain和release反而会增加复杂性。
我们来看一下,在仅有引用计数的情况下我们应该怎样管理UI元素:
auto node=new Node(); //引用计数为1
addChild(node); //引用计数为2
……
node->removeFromParent(); //引用计数为1
node->release(); //引用计数为0,对象被删除
我们马上发现这不是我们想要的结果,如果忘记调用release就会导致内存泄漏。
2)autorelease声明一个指针为”智能指针”
回想前面讲述的智能指针,如果将一个动态分配的内存关联到一个自动变量,则当这个自动变量的生命周期结束的时候将会释放这块堆内存,从而使程序员不必担心其内存释放。我们是否可以借鉴类似的机制来避免手动释放UI元素呢?
Cocos2d-x使用autorelease来声明一个对象指针为”智能指针”,但是这些”智能指针”并不单独关联到某个自动变量,而是全部被加入到一个AutoreleasePool中。在每一帧结束的时候对加入到AutoreleasePool中的对象进行清理,也即是说在Cocos2d-x中,一个“智能指针”的生命周期是从创建开始到当前帧结束。
Ref* Ref::autorelease()
{
PoolManager::getInstance()->getCurrentPool()->addObject(this);
return this;
}
如上的代码,Cocos2d-x通过autorelease方法将一个对象加入到AutoreleasePool中。
void DisplayLinkDirector::mainLoop()
{
if (! _invalid)
{
drawScene();
// release the objects
PoolManager::getInstance()->getCurrentPool()->clear();
}
}
如上的代码,Cocos2d-x在每一帧结束的时候清理AutoreleasePool中的对象。
void AutoreleasePool::clear()
{
#if defined(COCOS2D_DEBUG) && (COCOS2D_DEBUG > 0)
_isClearing = true;
#endif
for (const auto &obj : _managedObjectArray)
{
obj->release();
}
_managedObjectArray.clear();
#if defined(COCOS2D_DEBUG) && (COCOS2D_DEBUG > 0)
_isClearing = false;
#endif
}
实际的实现机制是AutoreleasePool对池中每个对象执行一次release操作,假设该对象的引用计数为1,表示其从未被使用,则执行release之后引用计数为0,将会被释放。例如创建一个不被使用的Node:
auto node=new Node(); //引用计数为1
node->autorelease(); //加入”智能指针池”
可以预期,在该帧结束的时候node对象将会被自动释放。如果该对象被使用,则:
auto node=new Node(); //引用计数为1
node->autorelease(); //加入”智能指针池”
addChild(node); //引用计数为2
则在该帧结束的时候,AutoreleasePool对其执行一次release操作之后引用计数为1,该对象继承存在。当下次该节点被移除的时候引用计数为0,就会被自动释放。通过这样,就实现了Ref对象的自动内存管理。
然而,不管是C++11中的智能指针,还是Cocos2d-x中变体的“智能指针”,都需要程序员手动声明其是“智能”的:
shared_ptr np1(new int()); //C++11声明智能指针
auto node=(new Node())->autorelease(); //Cocos2d-x中声明”智能指针”
为了简化这种声明,Cocos2d-x使用静态的create方法()来返回一个”智能指针”对象,Cocos2d-x中大部分的类都可以通过create来返回一个“智能指针”,例如Node,Action等,同时我们自定义的UI元素也应该遵循这样的风格,来简化其声明:
Node * Node::create(void)
{
Node * ret = new Node();
if (ret && ret->init()){
ret->autorelease();
}
else{
CC_SAFE_DELETE(ret);
}
return ret;
}
3)AutoreleasePool队列
对于有些游戏对象而言,”一帧”的生命周期显然有些过长,假设一帧会调用100个方法,每个方法创建10个“智能指针”对象,并且这些对象只在每个方法作用域内被使用,则在该帧末尾的时候内存当中的最大峰值为1000个游戏对象所占用的内存,这样游戏的平均内存占用将会大大增加,而实际上每帧平均只需要占用10个对象的内存,假设这些方法是顺序执行的。
默认AutoreleasePool一帧被清理一次主要是用来清理UI元素的,由于UI元素大部分都是添加到UI树中,会一直占用内存的,这种情况下每帧清理并不会对内存占用有多大影响。
显然,对于自定义数据对象,我们需要能够自定义AutoreleasePool的生命周期。Cocos2d-x通过实现一个AutoreleasePool的队列来实现“智能指针”生命周期的自定义,并由PoolManager来管理这个AutoreleasePool队列:
class CC_DLL PoolManager
{
public:
static PoolManager* getInstance();
static void destroyInstance();
AutoreleasePool *getCurrentPool() const;
bool isObjectInPools(Ref* obj) const;
friend class AutoreleasePool;
private:
PoolManager();
~PoolManager();
void push(AutoreleasePool *pool);
void pop();
static PoolManager* s_singleInstance;
std::deque _releasePoolStack;
AutoreleasePool *_curReleasePool;
};
PoolManager初始和默认至少有一个AutoreleasePool,它主要用来存储前面讲述的Cocos2d-x中的UI元素对象。我们可以创建自己的AutoreleasePool对象,将其压入到队列尾端。但是如果我们使用new运算符来创建AutoreleasePool对象,则又需要手动释放,为了达到和智能指针使用自动变量来管理内存的效果,Cocos2d-x对AutoreleasePool的构造和析构函数进行了特殊处理,以使我们可以通过自动变量来管理内存释放:
AutoreleasePool::AutoreleasePool()
: _name(“”)
#if defined(COCOS2D_DEBUG) && (COCOS2D_DEBUG > 0)
, _isClearing(false)
#endif
{
_managedObjectArray.reserve(150);
PoolManager::getInstance()->push(this);
}
AutoreleasePool::AutoreleasePool(const std::string &name)
: _name(name)
#if defined(COCOS2D_DEBUG) && (COCOS2D_DEBUG > 0)
, _isClearing(false)
#endif
{
_managedObjectArray.reserve(150);
PoolManager::getInstance()->push(this);
}
AutoreleasePool::~AutoreleasePool()
{
CCLOGINFO(“deallocing AutoreleasePool: %p”, this);
clear();
PoolManager::getInstance()->pop();
}
AutoreleasePool在构造函数中将自身指针添加到PoolManager的AutoreleasePool队列中,并在析构函数中从队列中移除自己,由于前面讲述的Ref::autorelease()始终将自己添加到“当前AutoreleasePool”中,只要当前AutoreleasePool始终为队列尾端的元素,声明一个AutoreleasePool对象就可以影响之后的对象,直到该AutoreleasePool对象被移除队列。这样在程序中我们就可以这么使用:
Class MyClass : public Ref
{
static MyClass* create(){
auto ref=new MyClass();
return ref->autorelease();
}
}
void customAutoreleasePool()
{
AutoreleasePool pool;
auto ref1=MyClass::create();
auto ref2=MyClass::create();
}
在该方法开始执行时,声明一个AutoreleasePool类型的自动变量pool,其构造函数会将自身加入的PoolManager的AutoreleasePool队列尾端,接下来ref1和ref2都会被加入到pool池中,当该方法结束时,pool自动变量的生命周期结束,其析构函数将会释放对象,并从队列中移除自己。
这样我们就能够通过自定义AutoreleasePool的生命周期来控制Cocos2d-x中“智能指针”的生命周期。
4)总结
Cocos2d-x有一套性能高效且实现精巧的内存管理机制,它本质上是一种“智能指针”的变体。它通过Ref::autorelease来声明一个“智能指针”,并通过将autorelease包装在create方法中,避免了程序员对“智能指针”的声明,默认在一帧结束的时候AutoreleasePool会清理所有的“智能指针对象”,并且我们可以自定义AutoreleasePool的作用域。
结合Cocos2d-x内存管理机制和特点,本节最后总结一些使用Cocos2d-x内存管理的注意事项:
1. Ref的引用计数并不是线程安全的,在多线程中我们需要处理互斥锁来保证线程安全。在Objective-C中由于AutoreleasePool是语言级别系统实现的,每个线程都有自己的AutoreleasePool队列。
2. 对于自定义Node的子类,为该类添加create方法,该方法返回一个autorelease对象。
3. 对于自动义数据类型,如果需要动态分配内存的,继承自Ref,并添加create静态方法返回autorelease对象。
4. 只在一个方法内部使用的Ref对象,使用自定义的AutoreleasePool来即时清理内存占用。
5. 不要动态分配AutoreleasePool对象,始终使用自动变量。