官术网_书友最值得收藏!

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,遍歷所花費的時間就會越來越多。

雖然這只是一次內存泄漏的調試,但中間使用了很多技巧,相信在調試其他問題的過程中,也可以派上用場,靈活使用調試器的強大功能,可以大大提高調試效率。

主站蜘蛛池模板: 双江| 平乐县| 东乌| 潼南县| 资阳市| 屯门区| 灵石县| 共和县| 安徽省| 肇源县| 三江| 梁平县| 达拉特旗| 河曲县| 商丘市| 盈江县| 兴隆县| 乐山市| 桓仁| 宽城| 通化县| 扎鲁特旗| 曲麻莱县| 无极县| 凤城市| 阿尔山市| 汉川市| 南通市| 乌兰县| 新蔡县| 通河县| 阿合奇县| 芦山县| 宝山区| 酒泉市| 天长市| 武定县| 海丰县| 通许县| 恩平市| 富宁县|