1. 程式人生 > >Cocos2d-x 的記憶體管理

Cocos2d-x 的記憶體管理

分享一下我老師大神的人工智慧教程!零基礎,通俗易懂!http://blog.csdn.net/jiangjunshow

也歡迎大家轉載本篇文章。分享知識,造福人民,實現我們中華民族偉大復興!

               

既然選擇了C++作為遊戲開發的語言, 手動的管理記憶體是難以避免的, 而Cocos2d-x的仿Objctive-C的記憶體管理方式, 事實上讓問題變得更加複雜(僅僅是移植更加方便了), 因為你需要很好的掌握兩種語言的記憶體管理方式, 並且在使用時頭腦清晰, 不能混用, 不然等待你的就是無窮的噩夢, 因為引用計數的原因, 問題比起純粹的C++記憶體洩漏還要難以定位的多. 

這裡統一的整理一下目前在Cocos2d-x中的記憶體管理相關的方法和問題. 為了讓思路更加清晰, 我提及的不僅僅是具體在Cocos2d-x中怎麼用, 也包含一些為啥在Cocos2d-x中應該這麼用.
並且, 因為以下講到的每個點要講的詳細了, 其實都可以寫一整篇文章了, 沒法在短時間內詳述, 這篇文章也就僅僅作為一個引子而已.

C++的記憶體管理

C語言的malloc, free

因為C++基本相容C, 所以C語言的malloc和free也是支援的. 簡單的說用法就是malloc後記得free即可.

#include <stdio.h>
#include <stdlib.h>const size_t kBufferSize = 16;void test() {  char *buffer = (char*)malloc(kBufferSize);  memset(buffer, 0, sizeof(char) * kBufferSize);  sprintf(buffer, "%s", "Hello World\n");  printf(buffer);  free(buffer);}int main(int, char**) {  test();  return
0;}

當然, 還有realloc和calloc這兩個平時較少用的記憶體分配函式, 這裡不提了, 在C語言時代, 我們就是用malloc和free解決了我們的記憶體問題. 重複的對同一段記憶體進行free是不安全的, 同時, 為了防止free後, 還拿著指標使用(即野指標), 我們一般使用將free後的記憶體置空.
因為這種操作特別的多, 我們常常會弄一個巨集, 比如Cocos2d-x中的

#define CC_SAFE_FREE(p)         if(p) { free(p); p = 0; }

C++的new, delete, new[], delete[]

為什麼malloc和free在C++時代還不夠呢? malloc,free在C++中使用有如下的缺點:

  1. malloc和free並不知道class的存在, 所以不會呼叫物件的建構函式和解構函式.
  2. malloc返回的是void*, 不符合C++向強型別發展的趨勢.

於是, BS在C++中增加了new-delete組合以替代malloc, free. 其實new, delete基本上都是用malloc, free實現的更高層函式, 只是增加了構造和析構的呼叫而已. 所以在平時使用的時候並無區別, 但是因為malloc其實是記錄了分配的長度的, 長度以位元組為單位, 所以一句malloc對應即可, 不管你是malloc出一個char, 一個int, 還是char的陣列或者int的陣列, 單位其實都是位元組. 而長度一般記錄在這個記憶體塊的前幾個位元組, 在Windows中, 甚至可以使用_msize函式從指標中取出malloc出的記憶體長度.
而new的時候有兩種情況, 可以是一個物件, 也可以是物件的陣列, 並且, 沒有統一的記錄長度. 使得我們需要通過自己記住什麼時候new出了一個物件, 什麼時候new出了多個物件. 這個問題最近雲風還吐槽過. 並且增加了delete[]用於刪除new出多個物件的情況. 讓問題更加容易隱藏不被發現的是, 當你講delete用於new[]分配的記憶體時, 實際相當於刪除了第一個物件, 是完全正確的語法, 不會報任何錯誤, 你只能在執行出現詭異問題的時候再去排查.
這個問題實際是語言設計的決策問題. 在BS這個完美主義者這裡, C++語言的設計原則之一就是零負荷規則 — 你不會為你所不使用的部分付出代價. 並且在這裡這個規則執行的顯然是要比C語言還要嚴格. 當你只分配一個物件的時候, 為啥要像分配多個物件時一樣, 記錄一個肯定為1的計數呢? 估計BS是這麼想的. 於是我們只好用人工來區分delete和delete[]. 某年某月, 我看到某人說C++是實用主義, 我笑了, 然後我看到C++的一條設計原則是”不一味的追求完美”, 我哭了……

#include <stdio.h>class Test {public:  Test() {    test_ = __LINE__;    printf("Test(): Run Code in %d\n", __LINE__);  }  ~Test() {    test_ = __LINE__;    printf("~Test(): Run Code in %d\n", __LINE__);  }  void print() {    printf("%d\n", test_);  }private:  int test_;};void test() {  Test *temp = new Test;  delete temp;  Test *temps = new Test[2];  delete []temps;  Test *error_delete_temps = new Test[2];  delete error_delete_temps;}int main(int, char **) {  test();  return 0;}

上面的程式碼最後用delete刪除了使用new[]分配的記憶體, 但是編譯時即使開啟-wall選項, 你也不會看到任何的警告, 執行時也不會報錯, 你只會發現解構函式少運行了一次. 實際上就是發生了記憶體洩漏.

C/C++記憶體管理的實際使用

上面虛構的例子也就是看看語法, 真實的使用情景就要複雜了很多. 最重要的原則之一就是誰分配誰釋放原則. 這個原則即使是用malloc, free也一樣需要遵循.
在C語言上的表現形態大概如下:

只管用, 不管分配

典型的例子就是C語言的檔案讀取API:

size_t fread ( void * ptr, size_t size, size_t count, FILE * stream );

這裡的ptr buffer並不由fread內部分配, 而是由外部傳進來, fread只管使用這段buffer, 並且假設這段buffer的size 大於等於傳進來的size. 通過這樣的方式, fread本身逃避了malloc, free的責任, 也就不用關心記憶體該怎麼管理的問題. 好處是fread的實現得到簡化, 壞處是外部使用的負擔增加了~~~
類似的API還有sprintf, fgets等, 而strcat這種API也算是這種型別的變化.

這種型別的API的一個最大問題在於很多時候不好確定buffer到底該多大, 於是在一些時候, 還有一個複雜的用法, 那就是在第一次傳特定引數呼叫函式時, 函式僅傳出需要buffer的大小, 分配了buffer後, 第二次呼叫才真正實現函式的功能.

這種API雖然外部的使用麻煩, 但是在絕大部分時候, 已經是C語言的最佳API設計方法.

管分配, 也管刪除

只管用, 不管分配的API設計方式在僅僅需要一個buffer, 或者簡單的結構快取的時候基本已經夠用, 但也不是萬能的, 在想要隱藏內部實現時, 往往就沒法使用這樣的API設計, 比如Windows API中一系列與核心物件建立相關的方法, 如CreateThread, CreateProcess等函式返回的都是一個核心物件的handle, 然後必須要使用CloseHandle來釋放, handle具體的對應什麼結構, 分配了多少記憶體, 此時並不由我們關心, 我們只需要保證一個CreateXXX, 就一定要CloseHandle即可.
這種API的設計方式, 還常見於我們熟知的工廠模式, 工廠模式提供Create和Delete的介面, 就要比只提供Create介面, 讓外部自己去Delete要好的多, 此時外部不用關心物件的刪除方式, 將來需要在建立物件時進行更多操作(比如對物件進行引用計數管理), 外部程式碼也不需要更改. 我在網上隨便一搜, 發現絕大部分關於工廠模式的程式碼中都沒有考慮這個問題, 需要特別注意.
這裡看一個我們常見的開源遊戲引擎Ogre中的例子: (Ogre1.8.1 OgreFileSystem.h)

/** Specialisation of ArchiveFactory for FileSystem files. *///class _OgrePrivate FileSystemArchiveFactory : public ArchiveFactoryclass _OgreExport FileSystemArchiveFactory : public ArchiveFactory{  public:    virtual ~FileSystemArchiveFactory() {}    /// @copydoc FactoryObj::getType    const String& getType(void) const;    /// @copydoc FactoryObj::createInstance    Archive *createInstance( const String& name )     {      return OGRE_NEW FileSystemArchive(name, "FileSystem");    }    /// @copydoc FactoryObj::destroyInstance    void destroyInstance( Archive* arch) { delete arch; }};

這裡這個FileSystemArchiveFactory就是典型的例子. 雖然這裡的destroyInstance僅僅是一個delete, 但是還是這麼設計了介面.

單獨的快取

一般而言, 所有與記憶體管理相關的C語言API設計都應該使用上面提到的兩種方案, 其他的方案基本都是錯的(或者有問題的), 但是因為各種考慮, 還是有些很少見的設計, 這裡把一些可能出現的設計也列出來.
所謂的單獨快取的設計, 指的是一段程式碼內部的確需要快取, 但是不由外部傳入, 而是自己直接分配, 只是每次的呼叫都使用這一份快取, 不再釋放.
比如C語言中對錯誤字串的處理:

char * strerror ( int errnum );

strerror這個API的設計咋一看就有蹊蹺, 什麼樣的設計能傳入一個整數, 然後返回一個字串呢? 字串的儲存空間哪裡來的? 一個簡單的例子就能看出這樣設計的問題:

#include <stdio.h>#include <string.h>#include <errno.h>int main (){  char *error1 = strerror(EPERM);  printf ("%s\n", error1);  char *error2 = strerror(ENOENT);  printf ("%s\n", error1);  printf ("%s\n", error2);  return 0;}

此時會輸出:
Operation not permitted
No such file or directory
No such file or directory

意思就是說, 當第二次呼叫strerror的時候, 連error1的內容都變了, 對於不明情況的使用者來說, 這絕對是個很意外的情況. 雖然說, 這個例子比較極端, 在錯誤處理的時候不太可能出現, 但是這種設計帶來的問題都是類似的. 至於為什麼獲得錯誤字串的API要做這樣的設計, 我就無從得知了, 可能是從易用性出發更多一些吧.

在另外一些基於效率的考慮時, 也可能會使用這樣的設計, 我只在一個對效率有極端要求的情況下, 在真實專案環境中見過這樣的設計. 那是在做伺服器的時候, 一些輔助函式(對收發資料包的處理)需要大的快取, 同時操作較為頻繁並且效率敏感, 才使用了這樣的設計.

一般來說, 這種設計常常導致更多的問題, 讓你覺得獲得的效率提升付出了不值得的代價. 所以除非萬一, 並不推薦使用.

我分配, 你負責釋放

這種API的唯一好處就是看起來使用較為簡單, 但是在任何情況下, 都不僅僅是容易導致更多問題, 這種設計就是問題本身, 幾乎只有錯誤的程式碼才會使用這樣的設計, 起碼有以下幾個簡單的原因:

  1. 返回一段buffer的介面, 內部可以使用new, 也可能使用malloc分配, 外部如何決定該使用delete還是free釋放, 只能額外說明, 或者看原始碼.
  2. 當API跨記憶體空間呼叫時, 就等於錯誤, 比如當API在動態庫中時. 這是100%的錯誤, 無論是delete還是free, 也不能釋放一個在動態庫中分配的記憶體.

正是因為這兩個原因, 你幾乎不能在任何成熟的程式碼中看到這樣的設計. 但是, 總有人經受不了使用看起來簡單的這個誘惑, 比如cocos2d-x中CCFileUtils類的兩個介面:

/**@brief Get resource file data@param[in]  pszFileName The resource file name which contains the path.@param[in]  pszMode The read mode of the file.@param[out] pSize If the file read operation succeeds, it will be the data size, otherwise 0.@return Upon success, a pointer to the data is returned, otherwise NULL.@warning Recall: you are responsible for calling delete[] on any Non-NULL pointer returned.*/unsigned char* getFileData(const char* pszFileName, const char* pszMode, unsigned long * pSize);/**@brief Get resource file data from a zip file.@param[in]  pszFileName The resource file name which contains the relative path of the zip file.@param[out] pSize If the file read operation succeeds, it will be the data size, otherwise 0.@return Upon success, a pointer to the data is returned, otherwise NULL.@warning Recall: you are responsible for calling delete[] on any Non-NULL pointer returned.*/unsigned char* getFileDataFromZip(const char* pszZipFilePath, const char* pszFileName, unsigned long * pSize);

這種直接返回資料的介面自然比fread這樣的介面看起來容易使用很多, 同時還通過一個warning的註釋表明了, 應該使用delete[]來釋放返回的快取, 以避免前面提到的第一個問題, 但是, 不管再怎麼做, 當你將cocos2d-x編譯成動態庫, 然後嘗試使用上述兩個API, 等待你的就是錯誤. 有興趣的可以嘗試.

這種API還是偶爾能見到, 有的時候可能還不如上面這個例子這麼明顯, 比如當一個函式要求傳入Class** obj引數, 並且返回物件的時候, 其實也已經是一樣的效果了.

再次宣告, 個人認為這種API本身就是錯誤, 不推薦在任何時候使用, 看到這麼設計的API, 也最好不要在任何時候呼叫.

C++對記憶體管理的改進

上述提到的幾種涉及記憶體分配的API設計雖然是C語言時代的, 但是基本也適用於C++. 只是在C++中, 在Class層面, 對記憶體管理進行了一些新的改進.
有new就delete, 有malloc就free, 聽起來很簡單, 但是在一些複雜情況下, 還是會成為負擔, 讓程式碼變得很難看. 更不要提, 程式設計師其實也是會犯錯誤的. C++對此的解決之道之一就是通過建構函式和解構函式.

看下面的例子:
在極其簡單的情況下, 我們這樣就能保證記憶體不洩漏:

const int32_t kBufferSize = 32;void Test1() {  char *buffer = new char[kBufferSize];  // code here  delete[] buffer;}

但是, 這僅限於最簡單的情況, 當有可能的錯誤發生時, 情況就複雜了:

const int32_t kBufferSize = 32;bool Init() {  char *buffer = new char[kBufferSize];  bool result = true;  // code here  if (!result) {    delete[] buffer;    return false;  }  char *buffer2 = new buffer[kBufferSize];  // code here  if (!result) {    delete[] buffer;    delete[] buffer2;    return false;  }  delete[] buffer;  delete[] buffer2;  return true;}

僅僅是兩次錯誤處理, 分配兩段記憶體, 你不小心的寫程式碼, 就很有可能出現錯誤了. 這不是什麼好事情, 更進一步的說, 其實, 我上面這段話還是基於不考慮異常的情況, 當考慮異常發生時, 上述程式碼就已經是可能發生記憶體洩漏的程式碼了. 考慮到異常, 當嘗試分配buffer2的記憶體時, 假如分配失敗, 此時會丟擲異常(對, 在C++中普通的new分配失敗是丟擲異常, 而不是返回NULL), 那麼實際上此時buffer指向的記憶體就沒有程式碼負責釋放了. 在C++中, 講buffer放入物件中, 通過解構函式來保證記憶體的時候, 就不會有這樣的問題, 因為C++的設計保證了, 無論以何種方式退出作用域(不管是正常退出還是異常), 臨時物件的解構函式都會被呼叫.
程式碼大概就會如下面這樣:

const int32_t kBufferSize = 32;class Test {public:  Test() {