Cocos2d-x内存管理机制
在C++中,动态内存分配犹如一把双刃剑。一方面,直接访问内存地址显著提高了应用程序的性能,同时也增强了内存使用的灵活性;另一方面,若程序未能正确地进行内存分配与释放,诸如野指针、重复释放、内存泄漏等问题便会接踵而至,严重影响应用程序的稳定性。
为避免这些问题,人们尝试了多种方案,常见的有智能指针和自动垃圾回收等。然而,这些方案要么会对应用程序的性能产生影响,要么仍需开发者遵循特定规则,要么给开发者带来一些不太优雅的使用方式(笔者个人不太喜欢智能指针)。因此,一个优秀的C++内存管理方案需要兼顾性能和易用性,截至目前,C++标准尚未给出一个完美的内存管理方案。
Cocos2d-x的内存管理机制源自Objective-C,该机制几乎贯穿于Cocos2d-x中所有动态分配的对象。它简化了对堆上动态分配对象的管理,但由于其独特的工作机制,一些开发者,尤其是不熟悉Objective-C的开发者,可能会对其产生误解。因此,确保全面理解并正确使用Cocos2d-x的内存管理机制,是使用Cocos2d-x的基础准备工作。
一、C++显式堆内存管理
在C++中,使用new关键字可以在运行时为对象动态分配内存,并返回堆上内存的地址供应用程序访问。通过动态分配的内存,在对象不再被使用时,需要使用delete运算符将其归还给内存池。
显式的内存管理在性能上具有一定优势,但极容易出错。实际上,人类思维难以确保逻辑的绝对正确。若不能正确处理堆内存的分配与释放,通常会引发以下问题:
- 野指针:指针指向的内存单元已被释放,但其他指针可能仍指向该内存,而这块内存可能已被重新分配给其他对象,从而导致不可预测的结果。
- 重复释放:重复释放已被释放的内存单元,或者释放野指针(本质也是重复释放),都会引发C++运行时错误。
- 内存泄漏:若不再使用的内存单元未被释放,将持续占用内存。若此类操作不断重复,会导致内存占用不断增加。在游戏中,内存泄漏问题尤为严重,因为可能每一帧都会创建一个永远不会被回收的游戏对象。
二、C++11中的智能指针
根据内存分配方法,C++有三种管理数据内存的方式:自动存储、静态存储和动态存储。其中,静态存储用于存储在整个应用程序执行期间都存在的静态变量;动态存储用于存储通过new分配的内存单元。
对于在函数内部定义的常规变量,使用自动存储空间,对应的变量称为自动变量。自动变量在所属函数被调用时自动创建,在函数结束时自动销毁。实际上,自动变量是局部变量,其作用域为包含它的代码块。自动变量通常存储在栈上,进入代码块时,变量依次入栈;离开代码块时,按相反顺序释放这些变量。
由于自动变量通常不会引发内存问题,智能指针试图将动态分配的内存单元与自动变量关联起来。当自动变量离开代码块被自动释放时,其关联的内存单元也会被释放。这样,程序员无需显式调用delete,就能很好地管理动态分配的内存。
C++11提供了三种不同的智能指针:unique_ptr、shared_ptr和weak_ptr。它们均为模板类型,使用方式如下:
int main() {
std::unique_ptr<int> up1(new int(11));
// std::unique_ptr<int> up11 = up1; // 编译报错
std::shared_ptr<int> up2(new int(22));
std::weak_ptr<int> up3 = up2;
return 0;
}
每个智能指针都重载了*运算符,可以使用*up1的方式访问所分配的堆内存。智能指针在析构或调用reset成员函数时,可能会释放其所拥有的堆内存。三者的区别如下:
- unique_ptr:不能与其他智能指针共享所指对象的内存。例如,将
up1赋值给up11会导致编译错误。不过,可以通过标准库的move函数转移unique_ptr对对象的“拥有权”。一旦转移成功,原unique_ptr指针将失去对象内存的所有权,再次使用会导致运行时错误。 - shared_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对象的代码如下:std::shared_ptr<Node> node(new Node());此外,在需要引用的地方通常应使用
weak_ptr,否则当Node被移除时,shared_ptr会指向已释放的内存,导致运行时错误:std::weak_ptr<Node> 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()方法会减少引用计数。当引用计数为0时,release()方法会自动调用delete运算符删除对象并释放内存。
实际上,retain和release方法只是记录对象的引用次数,在程序中很少单独直接使用。因为在设计时就需要明确对象应该在何处释放,大多数引用关系是弱引用,使用retain和release反而会增加复杂性。
下面看一个仅使用引用计数管理UI元素的例子:
auto node = new Node(); // 引用计数为1
addChild(node); // 引用计数为2
// ……
node->removeFromParent(); // 引用计数为1
node->release(); // 引用计数为0,对象被删除
可以发现,如果忘记调用release,就会导致内存泄漏。
2)autorelease声明一个指针为“智能指针”
回顾前面提到的智能指针,如果将动态分配的内存与自动变量关联,当自动变量生命周期结束时,会自动释放堆内存,程序员无需担心内存释放问题。Cocos2d-x借鉴了类似机制,使用autorelease将对象指针声明为“智能指针”。这些“智能指针”并不单独关联到某个自动变量,而是全部加入到AutoreleasePool中。在每一帧结束时,会对加入到AutoreleasePool中的对象进行清理。也就是说,在Cocos2d-x中,“智能指针”的生命周期从创建开始到当前帧结束。
Ref::autorelease()方法的实现如下:
Ref* Ref::autorelease() {
PoolManager::getInstance()->getCurrentPool()->addObject(this);
return this;
}
通过该方法将对象加入到AutoreleasePool中。
Cocos2d-x在每一帧结束时清理AutoreleasePool中的对象,DisplayLinkDirector::mainLoop()方法的实现如下:
void DisplayLinkDirector::mainLoop() {
if (! _invalid) {
drawScene();
// release the objects
PoolManager::getInstance()->getCurrentPool()->clear();
}
}
AutoreleasePool::clear()方法的实现如下:
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中变体的“智能指针”,都需要程序员手动声明其为“智能”的:
std::shared_ptr<int> 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管理该队列:
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<AutoreleasePool*> _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 {
public:
static MyClass* create() {
auto ref = new MyClass();
return ref->autorelease();
}
};
void customAutoreleasePool() {
AutoreleasePool pool;
auto ref1 = MyClass::create();
auto ref2 = MyClass::create();
}
在customAutoreleasePool方法开始执行时,声明一个AutoreleasePool类型的自动变量pool,其构造函数会将自身加入到PoolManager的AutoreleasePool队列尾端。接下来创建的ref1和ref2都会加入到pool池中。当该方法结束时,pool自动变量的生命周期结束,其析构函数会释放对象,并从队列中移除自己。
通过这种方式,我们可以自定义AutoreleasePool的生命周期,从而控制Cocos2d-x中“智能指针”的生命周期。
4)总结
Cocos2d-x拥有一套性能高效且实现精巧的内存管理机制,本质上是“智能指针”的变体。它通过Ref::autorelease声明“智能指针”,并将autorelease封装在create方法中,避免了程序员手动声明“智能指针”。默认情况下,在一帧结束时,AutoreleasePool会清理所有“智能指针对象”,并且我们可以自定义AutoreleasePool的作用域。
结合Cocos2d-x内存管理机制和特点,以下是使用Cocos2d-x内存管理的注意事项:
Ref的引用计数并非线程安全的,在多线程环境中需要使用互斥锁来保证线程安全。在Objective-C中,由于AutoreleasePool是语言级别系统实现的,每个线程都有自己的AutoreleasePool队列。- 对于自定义
Node的子类,应添加create方法,该方法返回一个autorelease对象。 - 对于自定义数据类型,若需要动态分配内存,应继承自
Ref,并添加静态create方法返回autorelease对象。 - 仅在一个方法内部使用的
Ref对象,可使用自定义的AutoreleasePool即时清理内存占用。 - 不要动态分配
AutoreleasePool对象,始终使用自动变量。