8.3 记一次内存泄漏调试

这是实际工作中一次艰难的内存泄漏调试,严格来说算是Cocos2d-x引擎的BUG,但也与我们的使用方式有关,通过这次调试,让笔者对引用计数这把双刃剑有了进一步的体会,虽然它方便了使用,但一旦由于引用计数引发内存泄漏,调试起来也麻烦很多,特别是当泄漏的地方位于引擎底层时。

8.3.1 内存泄漏表象

在项目开发到后期时,游戏运行一段时间之后会变得非常卡顿,就算在一个简单的场景下,没有执行任何逻辑,也会非常卡

通过任务管理器查看内存发现,程序的内存占用已经超过了1GB,这甚至比所有的游戏资源所需的内存还要多很多,但正常来说,就算存在这么多的内存泄漏,笔者的机器也有足够的空闲内存,所以这个卡顿并不是内存泄漏造成的!内存泄漏不一定会造成卡顿,只有当内存泄漏几乎耗光了所有的可用内存时,才会影响机器的性能,内存泄漏造成的卡顿,并不是卡这一个应用程序,而是整个机器,因为所有的程序都很难分配到内存了!

8.3.2 初步分析

于是笔者通过Visual Studio的性能诊断工具来分析游戏卡顿的原因,最后定位到是执行Update的调用,对应的Update函数并不是项目的代码,而是位于Cocos2d-x中,这个分析结果几乎毫无意义,于是笔者开始着手解决内存泄漏的问题。当你不清楚存在多少问题时,那么就把能解决的先解决吧。

内存泄漏,泄漏了这么多,怎么想都是纹理发生泄漏了,于是笔者把矛头对准TextureCache,因为几乎所有的纹理都是在这里创建的,如果TextureCache发生了内存泄漏,那么唯一的可能就是调用了removeTextureForKey、removeTexture或removeAllTextures,否则纹理都是会被TextureCache管理的,只有在某个地方将一个还有引用的纹理从TextureCache中删除,然后又没有使用对该纹理的引用,之后其他地方也使用到了该纹理,这时TextureCache就会重新加载这个纹理,反复如此那么纹理就会发生大量的泄漏。打了断点之后,发现TextureCache中并没有纹理泄漏,所有进入TextureCache中的纹理在被移除时,引用计数都是1,也就是说没有其他地方引用这些纹理,而且前面几个函数打的断点并没有触发,如果不是纹理,那什么东西能占用那么大的内存呢?

笔者仍然认为应该是纹理导致,会加载纹理的地方只有场景切换时的预加载,多半是预加载这里出了问题,于是笔者切换了一下场景,观察了游戏的内存,发现每切换一次场景,游戏的内存就会往上增加,没有上限,笔者在两个场景之间来回切换,每次都会增加,并且到后面越来越卡。如果不切换场景,则不会有任何影响。那么切换场景的时候做了什么呢?

❑ 关闭并释放所有UI。

❑ 清空自定义的资源管理器(如果该资源在新场景有用到,则不清理)。

❑ 调用Director的purgeCachedData。

❑ 预加载新场景的资源。

8.3.3 排查问题

经过了各种尝试之后,笔者发现将第二步的代码注释掉,就感觉不到内存泄漏了,仔细观察后,资源管理器中的代码看不出有内存泄漏的地方,所有的资源都释放了。资源管理器中管理了骨骼动画、纹理、CSB等资源,通过筛选定位,笔者发现是资源管理器中的CSB资源出了问题,于是在CSB创建的地方和释放的地方打印了日志,并禁用其不清理下一个场景会用到的资源这个功能,于是发现每一个资源都会释放,并且释放时资源本身的引用技术都是为1,也就是没有其他地方引用到了该资源。这就说明资源管理器本身没有泄漏,那为什么清空资源管理器就会出现内存泄漏,而不清空就不会出现呢?怎么会有这么莫名其妙的BUG呢?经验告诉笔者,任何BUG都是有原因的。

只能继续分析了,接下来笔者在Texture2D和Node的构造函数和析构函数处打了日志,将this指针打印了,并各自增加了一个静态变量,用于统计数量,在构造函数中自增1,析构函数中自减1,并将这两个变量也打印了出来。然后再进行测试,发现经过了两轮切换场景之后,每次切换这两个数值都会以一个固定的数值增长。接下来笔者仔细对比了冗长的日志文件,发现在创建某些CSB的时候,会创建若干个子节点和纹理,而在析构的时候,释放的节点和纹理明显少于其创建的。这就很蹊跷了,父节点都释放了,子节点却没有被释放?到底是哪里引用了它们呢?没有关系,一定可以查出来!

8.3.4 修改代码定位泄漏点

既然可以在构造函数和析构函数中统计是否有泄漏的对象,那么自然也可以获取到泄漏的是哪些对象。例如,要获取Node的泄漏详情,就需要修改CCNode.cpp,首先在cpp开头部分添加如下代码。

        #include <map>
        static int s_node = 0;
        static int s_count = 0;
        //两个map相互映射,可以帮助快速定位对象是第几个创建的
        static std::map<void*, int> s_nodemap;
        static std::map<int, void*> s_nodemap2;

接下来在Node的构造函数中添加如下代码,除了自增统计数量之外,还自增创建的节点顺序,并将创建的节点以及创建的顺序记录到map中。

        ++s_node;
        ++s_count;
        s_nodemap[this] = s_count;
        s_nodemap2[s_count] = this;
        CCLOG("*********** new Node %p count %d times %d", this, s_node, s_count);

在Node的析构函数中添加如下代码,析构时自减统计数量,如此s_nodemap2中会存储着未释放的节点以及该节点的创建顺序。

        s_nodemap2.erase(s_nodemap[this]);
        s_nodemap.erase(this);
        --s_node;
        CCLOG("*********** delete Node %p count %d", this, s_node);

创建节点顺序记录了每个节点创建的顺序,这方便我们使用条件断点来定位问题,当检查一个场景是否存在内存泄漏时,以及是哪一个节点泄漏了,可以添加上述的代码,然后将程序切换至一个空场景。如果在代码中缓存了节点,需要执行释放的逻辑,接下来让程序暂停,添加监视查看s_nodemap2容器,可以发现所有未释放的节点。

如果s_nodemap2中的节点数量大于2,则说明可能存在内存泄漏,因为切换到新场景中会有场景节点和相机节点,此时不应该存在其他节点。如果场景开启了FPS监控,那么存在的节点应该是5个,因为除了场景和相机之外,还有左下角的3个文本节点。

由于是调试,所以笔者添加的变量命名比较随意,但是之所以使用两个map是为了方便查看。观察map时会按分配的顺序从小到大排序,如图8-30所示,由于是静态变量,可以直接在监视窗口输入变量名查看。

图8-30 监视未释放的Node

定位到某个节点存在内存泄漏时,就可以查看这个节点泄漏的原因了,因为使用了引用计数,所以Cocos2d-x中的泄漏比较复杂,但无非就是哪处地方retain了之后没有release,只要掌握了该节点所有的retain和release调用堆栈,即可轻易分析出泄漏点

我们需要在CCRef.cpp中进行少量的修改,首先添加一个静态变量,用于过滤目标节点,并添加一行打印,方便设置命中条件。这里之所以用命中条件而不用条件断点,是为了提高调试效率,万一这个节点被各种retain、release了几百次,调试快捷键得按到手软,而且每次手动查看堆栈,也不利于调试分析。

        static int s_checkRefId = 0;

接下来在retain()函数中添加如下代码。

        if (_ID == s_checkRefId)
        {
            CCLOG("retain()");
        }

然后在release()函数中添加如下代码。

        if (_ID == s_checkRefId)
        {
            CCLOG("release()");
        }

8.3.5 开始调试

代码写完之后,开始调试,首先要执行第一次程序,来定位是哪些节点泄漏了,执行完查看一下最后的s_nodemap2,如图8-30所示。

接下来在Node的构造函数处打一个条件断点,条件为s_count==指定的顺序,例如,图8-30中第一个没有释放的节点是第34个创建的,那么就判断s_count==39(去掉前面提到的场景节点、相机节点以及FPS监测的3个文本节点)。

这里仅仅适合节点创建顺序固定的条件,要达到这种条件并不困难,因为一样的执行流程创建节点的顺序一般是相同的,如果执行流程不确定的话,还可以用另外一种方式,就是设置一个开关,当执行到要检测的那部分代码时,再打开开关,记录分配的节点,这样也可以规避掉前面的流程的一些不确定因素。

当断到断点时,可以将当前节点的地址获取出来,查看当前节点的_ID,并查看创建处的堆栈进行分析。接下来需要分析所有retain了该节点,以及release该节点的地方

仅知道被retain和release了几次用处并不大,如果能知道哪些地方retain了它,哪些地方release了它,那么就可以很容易地分析出是哪里retain了之后没有release了!命中条件就可以完成这个任务,因为命中条件的效率比较低,且我们只关心指定Node的retain和release,所以需要使用s_checkRefId来进行过滤。下面分别在上面打印日志的地方设置两个命中条件,如图8-31所示。

图8-31 设置命中条件

接下来在retain()方法中设置断点,断在retain()方法中,并将s_checkRefId修改为目标节点的_ID(在目标节点的构造函数处可以获得_ID,因为为目标节点设置了一个条件断点),修改完之后取消断点,继续执行程序,再次正常执行到切换场景时,就可以在输出窗口得到所有retain和release的堆栈了。

下方是整理后的堆栈输出日志,分析堆栈日志可以发现一共retain了2次,release了2次,少了一次释放。因为new出来的节点默认的引用计数为1, retain了2次,release了2次,引用计数仍然为1。分析每个堆栈可以发现,第二次retain添加节点对应了第一次release移除节点,而第二次release则是由于create方法调用了autorelease,那么第一次的retain并没有对应的release,也就是说这个引用计数是握在ActionManger手上。

        retain    libcocos2d.dll! cocos2d::Ref::retain
            libcocos2d.dll! cocos2d::ActionManager::addAction
            libcocos2d.dll! cocos2d::Node::runAction
            libcocos2d.dll! cocos2d::CSLoader::nodeWithFlatBuffers
            libcocos2d.dll! cocos2d::CSLoader::nodeWithFlatBuffers
            libcocos2d.dll! cocos2d::CSLoader::nodeWithFlatBuffers
            libcocos2d.dll! cocos2d::CSLoader::nodeWithFlatBuffersFile
            libcocos2d.dll! cocos2d::CSLoader::createNodeWithFlatBuffersFile
            libcocos2d.dll! cocos2d::CSLoader::createNodeWithFlatBuffersFile
            libcocos2d.dll! cocos2d::CSLoader::createNode
            cpp-empty-test.exe! HelloWorld::init
            cpp-empty-test.exe! HelloWorld::create
            cpp-empty-test.exe! HelloWorld::scene
            cpp-empty-test.exe! AppDelegate::applicationDidFinishLaunching
            libcocos2d.dll! cocos2d::Application::run
            cpp-empty-test.exe! wWinMain
            cpp-empty-test.exe! __tmainCRTStartup
            cpp-empty-test.exe! wWinMainCRTStartup
            kernel32.dll!7720338a
            [下面的框架可能不正确和/或缺失,没有为kernel32.dll加载符号]
            ntdll.dll!778c9f72
            ntdll.dll!778c9f45

        retain()
        retain    libcocos2d.dll! cocos2d::Ref::retain
            libcocos2d.dll! cocos2d::Vector<cocos2d::Node *>::pushBack
            libcocos2d.dll! cocos2d::Node::insertChild
            libcocos2d.dll! cocos2d::Node::addChildHelper
            libcocos2d.dll! cocos2d::Node::addChild
            libcocos2d.dll! cocos2d::ui::Layout::addChild
            libcocos2d.dll! cocos2d::ui::Layout::addChild
            libcocos2d.dll! cocos2d::CSLoader::nodeWithFlatBuffers
            libcocos2d.dll! cocos2d::CSLoader::nodeWithFlatBuffers
            libcocos2d.dll! cocos2d::CSLoader::nodeWithFlatBuffersFile
            libcocos2d.dll! cocos2d::CSLoader::createNodeWithFlatBuffersFile
            libcocos2d.dll! cocos2d::CSLoader::createNodeWithFlatBuffersFile
            libcocos2d.dll! cocos2d::CSLoader::createNode
            cpp-empty-test.exe! HelloWorld::init
            cpp-empty-test.exe! HelloWorld::create
            cpp-empty-test.exe! HelloWorld::scene
            cpp-empty-test.exe! AppDelegate::applicationDidFinishLaunching
            libcocos2d.dll! cocos2d::Application::run
            cpp-empty-test.exe! wWinMain
            cpp-empty-test.exe! __tmainCRTStartup
            cpp-empty-test.exe! wWinMainCRTStartup
            kernel32.dll!7720338a
            [下面的框架可能不正确和/或缺失,没有为kernel32.dll加载符号]
            ntdll.dll!778c9f72
            ntdll.dll!778c9f45

        ret120338a
            [下面的框架可能不正确和/或缺失,没有为kernel32.dll加载符号]
            ntdll.dll!778c9f72

            ntdll.dll!778c9f45
        release()

        release    libcocos2d.dll! cocos2d::Ref::release
            libcocos2d.dll! cocos2d::AutoreleasePool::clear
            libcocos2d.dll! cocos2d::DisplayLinkDirector::mainLoop
            libcocos2d.dll! cocos2d::Application::run
            cpp-empty-test.exe! wWinMain
            cpp-empty-test.exe! __tmainCRTStartup
            cpp-empty-test.exe! wWinMainCRTStartup
            kernel32.dll!7720338a
            [下面的框架可能不正确和/或缺失,没有为kernel32.dll加载符号]
            ntdll.dll!778c9f72
            ntdll.dll!778c9f45
        release()

根据堆栈分析可以发现,没有release的那次是由于执行了runAction导致,这是CSLoader内部的代码,正常来说runAction会在节点执行cleanup的时候被移除,而且在Node的析构函数中也会被移除,但如果ActionManager应用了Node,那么Node的析构函数是无论如何都不会执行的。

到这里可以得出结论,如果一个Node执行了runAction之后,没有被添加到场景中,或者被添加到场景之后调用移除时cleanup参数传入了false,那么就会导致内存泄漏了!只要我们在释放之前手动cleanup一下就可以解决这个问题。

那么内存泄漏为什么会导致卡顿呢?这是因为,ActionManager每次都会遍历所有在ActionManager中的Node,不论其是否处于激活状态,如果发生了大量的泄漏,那么ActionManager中就会存在大量的Node,遍历所花费的时间就会越来越多。

虽然这只是一次内存泄漏的调试,但中间使用了很多技巧,相信在调试其他问题的过程中,也可以派上用场,灵活使用调试器的强大功能,可以大大提高调试效率。