記憶體池的設計和實現 -- C++應用程式效能優化
分享一下我老師大神的人工智慧教程!零基礎,通俗易懂!http://blog.csdn.net/jiangjunshow
也歡迎大家轉載本篇文章。分享知識,造福人民,實現我們中華民族偉大復興!
引言本書主要針對的是 C++ 程式的效能優化,深入介紹 C++ 程式效能優化的方法和例項。全書由 4 個篇組成,第 1 篇介紹 C++ 語言的物件模型,該篇是優化 C++ 程式的基礎;第 2 篇主要針對如何優化 C++ 程式的記憶體使用;第 3 篇介紹如何優化程式的啟動效能;第 4 篇介紹了三類效能優化工具,即記憶體分析工具、效能分析工具和 I/O 檢測工具,它們是測量程式效能的利器。
本章首先簡單介紹自定義記憶體池效能優化的原理,然後列舉軟體開發中常用的記憶體池的不同型別,並給出具體實現的例項。
書名:《C++應用程式效能優化》 作者:馮巨集華、徐瑩、程遠、汪磊 等編著 出版社:電子工業出版社 出版日期:2007 年 03 月 ISBN:978-7-121-03831-0 購買: 中國互動出版網 |
||
推薦章節: 更多推薦書籍,請訪問 developerWorks 圖書頻道。 歡迎您對本書提出寶貴的反饋意見。您可以通過本頁面最下方的 建議 欄目為本文打分,並反饋您的建議和意見。 如果您對 developerWorks 圖書頻道有什麼好的建議,歡迎您將建議發給我們。 |
如前所述,讀者已經瞭解到"堆"和"棧"的區別。而在程式設計實踐中,不可避免地要大量用到堆上的記憶體。例如在程式中維護一個連結串列的資料結構時,每次新增或者刪除一個連結串列的節點,都需要從記憶體堆上分配或者釋放一定的記憶體;在維護一個動態陣列時,如果動態陣列的大小不能滿足程式需要時,也要在記憶體堆上分配新的記憶體空間。
利用預設的記憶體管理函式new/delete或malloc/free在堆上分配和釋放記憶體會有一些額外的開銷。
系統在接收到分配一定大小記憶體的請求時,首先查詢內部維護的記憶體空閒塊表,並且需要根據一定的演算法(例如分配最先找到的不小於申請大小的記憶體塊給請求者,或者分配最適於申請大小的記憶體塊,或者分配最大空閒的記憶體塊等)找到合適大小的空閒記憶體塊。如果該空閒記憶體塊過大,還需要切割成已分配的部分和較小的空閒塊。然後系統更新記憶體空閒塊表,完成一次記憶體分配。類似地,在釋放記憶體時,系統把釋放的記憶體塊重新加入到空閒記憶體塊表中。如果有可能的話,可以把相鄰的空閒塊合併成較大的空閒塊。
預設的記憶體管理函式還考慮到多執行緒的應用,需要在每次分配和釋放記憶體時加鎖,同樣增加了開銷。
可見,如果應用程式頻繁地在堆上分配和釋放記憶體,則會導致效能的損失。並且會使系統中出現大量的記憶體碎片,降低記憶體的利用率。
預設的分配和釋放記憶體演算法自然也考慮了效能,然而這些記憶體管理演算法的通用版本為了應付更復雜、更廣泛的情況,需要做更多的額外工作。而對於某一個具體的應用程式來說,適合自身特定的記憶體分配釋放模式的自定義記憶體池則可以獲得更好的效能。
自定義記憶體池的思想通過這個"池"字表露無疑,應用程式可以通過系統的記憶體分配呼叫預先一次性申請適當大小的記憶體作為一個記憶體池,之後應用程式自己對記憶體的分配和釋放則可以通過這個記憶體池來完成。只有當記憶體池大小需要動態擴充套件時,才需要再呼叫系統的記憶體分配函式,其他時間對記憶體的一切操作都在應用程式的掌控之中。
應用程式自定義的記憶體池根據不同的適用場景又有不同的型別。
從執行緒安全的角度來分,記憶體池可以分為單執行緒記憶體池和多執行緒記憶體池。單執行緒記憶體池整個生命週期只被一個執行緒使用,因而不需要考慮互斥訪問的問題;多執行緒記憶體池有可能被多個執行緒共享,因此則需要在每次分配和釋放記憶體時加鎖。相對而言,單執行緒記憶體池效能更高,而多執行緒記憶體池適用範圍更廣。
從記憶體池可分配記憶體單元大小來分,可以分為固定記憶體池和可變記憶體池。所謂固定記憶體池是指應用程式每次從記憶體池中分配出來的記憶體單元大小事先已經確定,是固定不變的;而可變記憶體池則每次分配的記憶體單元大小可以按需變化,應用範圍更廣,而效能比固定記憶體池要低。
下面以固定記憶體池為例說明記憶體池的工作原理,如圖6-1所示。
圖6-1 固定記憶體池
固定記憶體池由一系列固定大小的記憶體塊組成,每一個記憶體塊又包含了固定數量和大小的記憶體單元。
如圖6-1所示,該記憶體池一共包含4個記憶體塊。在記憶體池初次生成時,只向系統申請了一個記憶體塊,返回的指標作為整個記憶體池的頭指標。之後隨著應用程式對記憶體的不斷需求,記憶體池判斷需要動態擴大時,才再次向系統申請新的記憶體塊,並把所有這些記憶體塊通過指標連結起來。對於作業系統來說,它已經為該應用程式分配了4個等大小的記憶體塊。由於是大小固定的,所以分配的速度比較快;而對於應用程式來說,其記憶體池開闢了一定大小,記憶體池內部卻還有剩餘的空間。
例如放大來看第4個記憶體塊,其中包含一部分記憶體池塊頭資訊和3個大小相等的記憶體池單元。單元1和單元3是空閒的,單元2已經分配。當應用程式需要通過該記憶體池分配一個單元大小的記憶體時,只需要簡單遍歷所有的記憶體池塊頭資訊,快速定位到還有空閒單元的那個記憶體池塊。然後根據該塊的塊頭資訊直接定位到第1個空閒的單元地址,把這個地址返回,並且標記下一個空閒單元即可;當應用程式釋放某一個記憶體池單元時,直接在對應的記憶體池塊頭資訊中標記該記憶體單元為空閒單元即可。
可見與系統管理記憶體相比,記憶體池的操作非常迅速,它在效能優化方面的優點主要如下。
(1)針對特殊情況,例如需要頻繁分配釋放固定大小的記憶體物件時,不需要複雜的分配演算法和多執行緒保護。也不需要維護記憶體空閒表的額外開銷,從而獲得較高的效能。
(2)由於開闢一定數量的連續記憶體空間作為記憶體池塊,因而一定程度上提高了程式區域性性,提升了程式效能。
(3)比較容易控制頁邊界對齊和記憶體位元組對齊,沒有記憶體碎片的問題。
本節分析在某個大型應用程式實際應用到的一個記憶體池實現,並詳細講解其使用方法與工作原理。這是一個應用於單執行緒環境且分配單元大小固定的記憶體池,一般用來為執行時會動態頻繁地建立且可能會被多次建立的類物件或者結構體分配記憶體。
本節首先講解該記憶體池的資料結構宣告及圖示,接著描述其原理及行為特徵。然後逐一講解實現細節,最後介紹如何在實際程式中應用此記憶體池,並與使用普通記憶體函式申請記憶體的程式效能作比較。
記憶體池類MemoryPool的宣告如下:
class MemoryPool{private: MemoryBlock* pBlock; USHORT nUnitSize; USHORT nInitSize; USHORT nGrowSize;public: MemoryPool( USHORT nUnitSize, USHORT nInitSize = 1024, USHORT nGrowSize = 256 ); ~MemoryPool(); void* Alloc(); void Free( void* p );}; |
MemoryBlock為記憶體池中附著在真正用來為記憶體請求分配記憶體的記憶體塊頭部的結構體,它描述了與之聯絡的記憶體塊的使用資訊:
struct MemoryBlock{ USHORT nSize; USHORT nFree; USHORT nFirst; USHORT nDummyAlign1; MemoryBlock* pNext; char aData[1]; static void* operator new(size_t, USHORT nTypes, USHORT nUnitSize) { return ::operator new(sizeof(MemoryBlock) + nTypes * nUnitSize); } static void operator delete(void *p, size_t) { ::operator delete (p); } MemoryBlock (USHORT nTypes = 1, USHORT nUnitSize = 0); ~MemoryBlock() {}}; |
此記憶體池的資料結構如圖6-2所示。
圖6-2 記憶體池的資料結構
此記憶體池的總體機制如下。
(1)在執行過程中,MemoryPool記憶體池可能會有多個用來滿足記憶體申請請求的記憶體塊,這些記憶體塊是從程序堆中開闢的一個較大的連續記憶體區域,它由一個MemoryBlock結構體和多個可供分配的記憶體單元組成,所有記憶體塊組成了一個記憶體塊連結串列,MemoryPool的pBlock是這個連結串列的頭。對每個記憶體塊,都可以通過其頭部的MemoryBlock結構體的pNext成員訪問緊跟在其後面的那個記憶體塊。
(2)每個記憶體塊由兩部分組成,即一個MemoryBlock結構體和多個記憶體分配單元。這些記憶體分配單元大小固定(由MemoryPool的nUnitSize表示),MemoryBlock結構體並不維護那些已經分配的單元的資訊;相反,它只維護沒有分配的自由分配單元的資訊。它有兩個成員比較重要:nFree和nFirst。nFree記錄這個記憶體塊中還有多少個自由分配單元,而nFirst則記錄下一個可供分配的單元的編號。每一個自由分配單元的頭兩個位元組(即一個USHORT型值)記錄了緊跟它之後的下一個自由分配單元的編號,這樣,通過利用每個自由分配單元的頭兩個位元組,一個MemoryBlock中的所有自由分配單元被連結起來。
(3)當有新的記憶體請求到來時,MemoryPool會通過pBlock遍歷MemoryBlock連結串列,直到找到某個MemoryBlock所在的記憶體塊,其中還有自由分配單元(通過檢測MemoryBlock結構體的nFree成員是否大於0)。如果找到這樣的記憶體塊,取得其MemoryBlock的nFirst值(此為該記憶體塊中第1個可供分配的自由單元的編號)。然後根據這個編號定位到該自由分配單元的起始位置(因為所有分配單元大小固定,因此每個分配單元的起始位置都可以通過編號分配單元大小來偏移定位),這個位置就是用來滿足此次記憶體申請請求的記憶體的起始地址。但在返回這個地址前,需要首先將該位置開始的頭兩個位元組的值(這兩個位元組值記錄其之後的下一個自由分配單元的編號)賦給本記憶體塊的MemoryBlock的nFirst成員。這樣下一次的請求就會用這個編號對應的記憶體單元來滿足,同時將此記憶體塊的MemoryBlock的nFree遞減1,然後才將剛才定位到的記憶體單元的起始位置作為此次記憶體請求的返回地址返回給呼叫者。
(4)如果從現有的記憶體塊中找不到一個自由的記憶體分配單元(當第1次請求記憶體,以及現有的所有記憶體塊中的所有記憶體分配單元都已經被分配時會發生這種情形),MemoryPool就會從程序堆中申請一個記憶體塊(這個記憶體塊包括一個MemoryBlock結構體,及緊鄰其後的多個記憶體分配單元,假設記憶體分配單元的個數為n,n可以取值MemoryPool中的nInitSize或者nGrowSize),申請完後,並不會立刻將其中的一個分配單元分配出去,而是需要首先初始化這個記憶體塊。初始化的操作包括設定MemoryBlock的nSize為所有記憶體分配單元的大小(注意,並不包括MemoryBlock結構體的大小)、nFree為n-1(注意,這裡是n-1而不是n,因為此次新記憶體塊就是為了滿足一次新的記憶體請求而申請的,馬上就會分配一塊自由儲存單元出去,如果設為n-1,分配一個自由儲存單元后無須再將n遞減1),nFirst為1(已經知道nFirst為下一個可以分配的自由儲存單元的編號。為1的原因與nFree為n-1相同,即立即會將編號為0的自由分配單元分配出去。現在設為1,其後不用修改nFirst的值),MemoryBlock的構造需要做更重要的事情,即將編號為0的分配單元之後的所有自由分配單元連結起來。如前所述,每個自由分配單元的頭兩個位元組用來儲存下一個自由分配單元的編號。另外,因為每個分配單元大小固定,所以可以通過其編號和單元大小(MemoryPool的nUnitSize成員)的乘積作為偏移值進行定位。現在唯一的問題是定位從哪個地址開始?答案是MemoryBlock的aData[1]成員開始。因為aData[1]實際上是屬於MemoryBlock結構體的(MemoryBlock結構體的最後一個位元組),所以實質上,MemoryBlock結構體的最後一個位元組也用做被分配出去的分配單元的一部分。因為整個記憶體塊由MemoryBlock結構體和整數個分配單元組成,這意味著記憶體塊的最後一個位元組會被浪費,這個位元組在圖6-2中用位於兩個記憶體的最後部分的濃黑背景的小塊標識。確定了分配單元的起始位置後,將自由分配單元連結起來的工作就很容易了。即從aData位置開始,每隔nUnitSize大小取其頭兩個位元組,記錄其之後的自由分配單元的編號。因為剛開始所有分配單元都是自由的,所以這個編號就是自身編號加1,即位置上緊跟其後的單元的編號。初始化後,將此記憶體塊的第1個分配單元的起始地址返回,已經知道這個地址就是aData。
(5)當某個被分配的單元因為delete需要回收時,該單元並不會返回給程序堆,而是返回給MemoryPool。返回時,MemoryPool能夠知道該單元的起始地址。這時,MemoryPool開始遍歷其所維護的記憶體塊連結串列,判斷該單元的起始地址是否落在某個記憶體塊的地址範圍內。如果不在所有記憶體地址範圍內,則這個被回收的單元不屬於這個MemoryPool;如果在某個記憶體塊的地址範圍內,那麼它會將這個剛剛回收的分配單元加到這個記憶體塊的MemoryBlock所維護的自由分配單元連結串列的頭部,同時將其nFree值遞增1。回收後,考慮到資源的有效利用及後續操作的效能,記憶體池的操作會繼續判斷:如果此記憶體塊的所有分配單元都是自由的,那麼這個記憶體塊就會從MemoryPool中被移出並作為一個整體返回給程序堆;如果該記憶體塊中還有非自由分配單元,這時不能將此記憶體塊返回給程序堆。但是因為剛剛有一個分配單元返回給了這個記憶體塊,即這個記憶體塊有自由分配單元可供下次分配,因此它會被移到MemoryPool維護的記憶體塊的頭部。這樣下次的記憶體請求到來,MemoryPool遍歷其記憶體塊連結串列以尋找自由分配單元時,第1次尋找就會找到這個記憶體塊。因為這個記憶體塊確實有自由分配單元,這樣可以減少MemoryPool的遍歷次數。
綜上所述,每個記憶體池(MemoryPool)維護一個記憶體塊連結串列(單鏈表),每個記憶體塊由一個維護該記憶體塊資訊的塊頭結構(MemoryBlock)和多個分配單元組成,塊頭結構MemoryBlock則進一步維護一個該記憶體塊的所有自由分配單元組成的"連結串列"。這個連結串列不是通過"指向下一個自由分配單元的指標"連結起來的,而是通過"下一個自由分配單元的編號"連結起來,這個編號值儲存在該自由分配單元的頭兩個位元組中。另外,第1個自由分配單元的起始位置並不是MemoryBlock結構體"後面的"第1個地址位置,而是MemoryBlock結構體"內部"的最後一個位元組aData(也可能不是最後一個,因為考慮到位元組對齊的問題),即分配單元實際上往前面錯了一位。又因為MemoryBlock結構體後面的空間剛好是分配單元的整數倍,這樣依次錯位下去,記憶體塊的最後一個位元組實際沒有被利用。這麼做的一個原因也是考慮到不同平臺的移植問題,因為不同平臺的對齊方式可能不盡相同。即當申請MemoryBlock大小記憶體時,可能會返回比其所有成員大小總和還要大一些的記憶體。最後的幾個位元組是為了"補齊",而使得aData成為第1個分配單元的起始位置,這樣在對齊方式不同的各種平臺上都可以工作。
有了上述的總體印象後,本節來仔細剖析其實現細節。
(1)MemoryPool的構造如下:
MemoryPool::MemoryPool( USHORT _nUnitSize, USHORT _nInitSize, USHORT _nGrowSize ){ pBlock = NULL; ① nInitSize = _nInitSize; ② nGrowSize = _nGrowSize; ③ if ( _nUnitSize > 4 ) nUnitSize = (_nUnitSize + (MEMPOOL_ALIGNMENT-1)) & ~(MEMPOOL_ALIGNMENT-1); ④ else if ( _nUnitSize <= 2 ) nUnitSize = 2; ⑤ else nUnitSize = 4;} |
從①處可以看出,MemoryPool建立時,並沒有立刻建立真正用來滿足記憶體申請的記憶體塊,即記憶體塊連結串列剛開始時為空。
②處和③處分別設定"第1次建立的記憶體塊所包含的分配單元的個數",及"隨後建立的記憶體塊所包含的分配單元的個數",這兩個值在MemoryPool建立時通過引數指定,其後在該MemoryPool物件生命週期中一直不變。
後面的程式碼用來設定nUnitSize,這個值參考傳入的_nUnitSize引數。但是還需要考慮兩個因素。如前所述,每個分配單元在自由狀態時,其頭兩個位元組用來存放"其下一個自由分配單元的編號"。即每個分配單元"最少"有"兩個位元組",這就是⑤處賦值的原因。④處是將大於4個位元組的大小_nUnitSize往上"取整到"大於_nUnitSize的最小的MEMPOOL_ ALIGNMENT的倍數(前提是MEMPOOL_ALIGNMENT為2的倍數)。如_nUnitSize為11時,MEMPOOL_ALIGNMENT為8,nUnitSize為16;MEMPOOL_ALIGNMENT為4,nUnitSize為12;MEMPOOL_ALIGNMENT為2,nUnitSize為12,依次類推。
(2)當向MemoryPool提出記憶體請求時:
void* MemoryPool::Alloc(){ if ( !pBlock ) ① { …… } MemoryBlock* pMyBlock = pBlock; while (pMyBlock && !pMyBlock->nFree )② pMyBlock = pMyBlock->pNext; if ( pMyBlock ) ③ { char* pFree = pMyBlock->aData+(pMyBlock->nFirst*nUnitSize); pMyBlock->nFirst = *((USHORT*)pFree); pMyBlock->nFree--; return (void*)pFree; } else ④ { if ( !nGrowSize ) return NULL; pMyBlock = new(nGrowSize, nUnitSize) FixedMemBlock(nGrowSize, nUnitSize); if ( !pMyBlock ) return NULL; pMyBlock->pNext = pBlock; pBlock = pMyBlock; return (void*)(pMyBlock->aData); }} |
MemoryPool滿足記憶體請求的步驟主要由四步組成。
①處首先判斷記憶體池當前記憶體塊連結串列是否為空,如果為空,則意味著這是第1次記憶體申請請求。這時,從程序堆中申請一個分配單元個數為nInitSize的記憶體塊,並初始化該記憶體塊(主要初始化MemoryBlock結構體成員,以及建立初始的自由分配單元連結串列,下面會詳細分析其程式碼)。如果該記憶體塊申請成功,並初始化完畢,返回第1個分配單元給呼叫函式。第1個分配單元以MemoryBlock結構體內的最後一個位元組為起始地址。
②處的作用是當記憶體池中已有記憶體塊(即記憶體塊連結串列不為空)時遍歷該記憶體塊連結串列,尋找還有"自由分配單元"的記憶體塊。
③處檢查如果找到還有自由分配單元的記憶體塊,則"定位"到該記憶體塊現在可以用的自由分配單元處。"定位"以MemoryBlock結構體內的最後一個位元組位置aData為起始位置,以MemoryPool的nUnitSize為步長來進行。找到後,需要修改MemoryBlock的nFree資訊(剩下來的自由分配單元比原來減少了一個),以及修改此記憶體塊的自由儲存單元連結串列的資訊。在找到的記憶體塊中,pMyBlock->nFirst為該記憶體塊中自由儲存單元連結串列的表頭,其下一個自由儲存單元的編號存放在pMyBlock->nFirst指示的自由儲存單元(亦即剛才定位到的自由儲存單元)的頭兩個位元組。通過剛才定位到的位置,取其頭兩個位元組的值,賦給pMyBlock->nFirst,這就是此記憶體塊的自由儲存單元連結串列的新的表頭,即下一次分配出去的自由分配單元的編號(如果nFree大於零的話)。修改維護資訊後,就可以將剛才定位到的自由分配單元的地址返回給此次申請的呼叫函式。注意,因為這個分配單元已經被分配,而記憶體塊無須維護已分配的分配單元,因此該分配單元的頭兩個位元組的資訊已經沒有用處。換個角度看,這個自由分配單元返回給呼叫函式後,呼叫函式如何處置這塊記憶體,記憶體池無從知曉,也無須知曉。此分配單元在返回給呼叫函式時,其內容對於呼叫函式來說是無意義的。因此幾乎可以肯定呼叫函式在用這個單元的記憶體時會覆蓋其原來的內容,即頭兩個位元組的內容也會被抹去。因此每個儲存單元並沒有因為需要連結而引入多餘的維護資訊,而是直接利用單元內的頭兩個位元組,當其分配後,頭兩個位元組也可以被呼叫函式利用。而在自由狀態時,則用來存放維護資訊,即下一個自由分配單元的編號,這是一個有效利用記憶體的好例子。
④處表示在②處遍歷時,沒有找到還有自由分配單元的記憶體塊,這時,需要重新向程序堆申請一個記憶體塊。因為不是第一次申請記憶體塊,所以申請的記憶體塊包含的分配單元個數為nGrowSize,而不再是nInitSize。與①處相同,先做這個新申請記憶體塊的初始化工作,然後將此記憶體塊插入MemoryPool的記憶體塊連結串列的頭部,再將此記憶體塊的第1個分配單元返回給呼叫函式。將此新記憶體塊插入記憶體塊連結串列的頭部的原因是該記憶體塊還有很多可供分配的自由分配單元(除非nGrowSize等於1,這應該不太可能。因為記憶體池的含義就是一次性地從程序堆中申請一大塊記憶體,以供後續的多次申請),放在頭部可以使得在下次收到記憶體申請時,減少②處對記憶體塊的遍歷時間。
可以用圖6-2的MemoryPool來展示MemoryPool::Alloc的過程。圖6-3是某個時刻MemoryPool的內部狀態。
圖6-3 某個時刻MemoryPool的內部狀態
因為MemoryPool的記憶體塊連結串列不為空,因此會遍歷其記憶體塊連結串列。又因為第1個記憶體塊裡有自由的分配單元,所以會從第1個記憶體塊中分配。檢查nFirst,其值為m,這時pBlock->aData+(pBlock->nFirst*nUnitSize)定位到編號為m的自由分配單元的起始位置(用pFree表示)。在返回pFree之前,需要修改此記憶體塊的維護資訊。首先將nFree遞減1,然後取得pFree處開始的頭兩個位元組的值(需要說明的是,這裡aData處值為k。其實不是這一個位元組。而是以aData和緊跟其後的另外一個位元組合在一起構成的一個USHORT的值,不可誤會)。發現為k,這時修改pBlock的nFirst為k。然後,返回pFree。此時MemoryPool的結構如圖6-4所示。
圖6-4 MemoryPool的結構
可以看到,原來的第1個可供分配的單元(m編號處)已經顯示為被分配的狀態。而pBlock的nFirst已經指向原來m單元下一個自由分配單元的編號,即k。
(3)MemoryPool回收記憶體時:
void MemoryPool::Free( void* pFree ){ …… MemoryBlock* pMyBlock = pBlock; while ( ((ULONG)pMyBlock->aData > (ULONG)pFree) || ((ULONG)pFree >= ((ULONG)pMyBlock->aData + pMyBlock->nSize)) )① { …… } pMyBlock->nFree++; ② *((USHORT*)pFree) = pMyBlock->nFirst; ③ pMyBlock->nFirst = (USHORT)(((ULONG)pFree-(ULONG)(pBlock->aData)) / nUnitSize);④ if (pMyBlock->nFree*nUnitSize == pMyBlock->nSize )⑤ { …… } else { …… }} |
如前所述,回收分配單元時,可能會將整個記憶體塊返回給程序堆,也可能將被回收分配單元所屬的記憶體塊移至記憶體池的記憶體塊連結串列的頭部。這兩個操作都需要修改連結串列結構。這時需要知道該記憶體塊在連結串列中前一個位置的記憶體塊。
①處遍歷記憶體池的記憶體塊連結串列,確定該待回收分配單元(pFree)落在哪一個記憶體塊的指標範圍內,通過比較指標值來確定。
執行到②處,pMyBlock即找到的包含pFree所指向的待回收分配單元的記憶體塊(當然,這時應該還需要檢查pMyBlock為NULL時的情形,即pFree不屬於此記憶體池的範圍,因此不能返回給此記憶體池,讀者可以自行加上)。這時將pMyBlock的nFree遞增1,表示此記憶體塊的自由分配單元多了一個。
③處用來修改該記憶體塊的自由分配單元連結串列的資訊,它將這個待回收分配單元的頭兩個位元組的值指向該記憶體塊原來的第一個可分配的自由分配單元的編號。
④處將pMyBlock的nFirst值改變為指向這個待回收分配單元的編號,其編號通過計算此單元的起始位置相對pMyBlock的aData位置的差值,然後除以步長(nUnitSize)得到。
實質上,③和④兩步的作用就是將此待回收分配單元"真正回收"。值得注意的是,這兩步實際上是使得此回收單元成為此記憶體塊的下一個可分配的自由分配單元,即將它放在了自由分配單元連結串列的頭部。注意,其記憶體地址並沒有發生改變。實際上,一個分配單元的記憶體地址無論是在分配後,還是處於自由狀態時,一直都不會變化。變化的只是其狀態(已分配/自由),以及當其處於自由狀態時在自由分配單元連結串列中的位置。
⑤處檢查當回收完畢後,包含此回收單元的記憶體塊的所有單元是否都處於自由狀態,且此記憶體是否處於記憶體塊連結串列的頭部。如果是,將此記憶體塊整個的返回給程序堆,同時修改記憶體塊連結串列結構。
注意,這裡在判斷一個記憶體塊的所有單元是否都處於自由狀態時,並沒有遍歷其所有單元,而是判斷nFree乘以nUnitSize是否等於nSize。nSize是記憶體塊中所有分配單元的大小,而不包括頭部MemoryBlock結構體的大小。這裡可以看到其用意,即用來快速檢查某個記憶體塊中所有分配單元是否全部處於自由狀態。因為只需結合nFree和nUnitSize來計算得出結論,而無須遍歷和計算所有自由狀態的分配單元的個數。
另外還需注意的是,這裡並不能比較nFree與nInitSize或nGrowSize的大小來判斷某個記憶體塊中所有分配單元都為自由狀態,這是因為第1次分配的記憶體塊(分配單元個數為nInitSize)可能被移到連結串列的後面,甚至可能在移到連結串列後面後,因為某個時間其所有單元都處於自由狀態而被整個返回給程序堆。即在回收分配單元時,無法判定某個記憶體塊中的分配單元個數到底是nInitSize還是nGrowSize,也就無法通過比較nFree與nInitSize或nGrowSize的大小來判斷一個記憶體塊的所有分配單元是否都為自由狀態。
以上面分配後的記憶體池狀態作為例子,假設這時第2個記憶體塊中的最後一個單元需要回收(已被分配,假設其編號為m,pFree指標指向它),如圖6-5所示。
不難發現,這時nFirst的值由原來的0變為m。即此記憶體塊下一個被分配的單元是m編號的單元,而不是0編號的單元(最先分配的是最新回收的單元,從這一點看,這個過程與棧的原理類似,即先進後出。只不過這裡的"進"意味著"回收",而"出"則意味著"分配")。相應地,m的"下一個自由單元"標記為0,即記憶體塊原來的"下一個將被分配出去的單元",這也表明最近回收的分配單元被插到了記憶體塊的"自由分配單元連結串列"的頭部。當然,nFree遞增1。
圖6-5 分配後的記憶體池狀態
處理至⑥處之前,其狀態如圖6-6所示。
圖6-6 處理至⑥處之前的記憶體池狀態
這裡需要注意的是,雖然pFree被"回收",但是pFree仍然指向m編號的單元,這個單元在回收過程中,其頭兩個位元組被覆寫,但其他部分的內容並沒有改變。而且從整個程序的記憶體使用角度來看,這個m編號的單元的狀態仍然是"有效的"。因為這裡的"回收"只是回收給了記憶體池,而並沒有回收給程序堆,因此程式仍然可以通過pFree訪問此單元。但是這是一個很危險的操作,因為首先該單元在回收過程中頭兩個位元組已被覆寫,並且該單元可能很快就會被記憶體池重新分配。因此回收後通過pFree指標對這個單元的訪問都是錯誤的,讀操作會讀到錯誤的資料,寫操作則可能會破壞程式中其他地方的資料,因此需要格外小心。
接著,需要判斷該記憶體塊的內部使用情況,及其在記憶體塊連結串列中的位置。如果該記憶體塊中省略號"……"所表示的其他部分中還有被分配的單元,即nFree乘以nUnitSize不等於nSize。因為此記憶體塊不在連結串列頭,因此還需要將其移到連結串列頭部,如圖6-7所示。
圖6-7 因回收引起的MemoryBlock移動
如果該記憶體塊中省略號"……"表示的其他部分中全部都是自由分配單元,即nFree乘以nUnitSize等於nSize。因為此記憶體塊不在連結串列頭,所以此時需要將此記憶體塊整個回收給程序堆,回收後記憶體池的結構如圖6-8所示。
圖6-8 回收後記憶體池的結構
一個記憶體塊在申請後會初始化,主要是為了建立最初的自由分配單元連結串列,下面是其詳細程式碼:
MemoryBlock::MemoryBlock (USHORT nTypes, USHORT nUnitSize) : nSize (nTypes * nUnitSize), nFree (nTypes - 1), ④ nFirst (1), ⑤ pNext (0){ char * pData = aData; ① for (USHORT i = 1; i < nTypes; i++) ② { *reinterpret_cast<USHORT*>(pData) = i; ③ pData += nUnitSize; }} |
這裡可以看到,①處pData的初值是aData,即0編號單元。但是②處的迴圈中i卻是從1開始,然後在迴圈內部的③處將pData的頭兩個位元組值置為i。即0號單元的頭兩個位元組值為1,1號單元的頭兩個位元組值為2,一直到(nTypes-2)號單元的頭兩個位元組值為(nTypes-1)。這意味著記憶體塊初始時,其自由分配單元連結串列是從0號開始。依次串聯,一直到倒數第2個單元指向最後一個單元。
還需要注意的是,在其初始化列表中,nFree初始化為nTypes-1(而不是nTypes),nFirst初始化為1(而不是0)。這是因為第1個單元,即0編號單元構造完畢後,立刻會被分配。另外注意到最後一個單元初始並沒有設定頭兩個位元組的值,因為該單元初始在本記憶體塊中並沒有下一個自由分配單元。但是從上面例子中可以看到,當最後一個單元被分配並回收後,其頭兩個位元組會被設定。
圖6-9所示為一個記憶體塊初始化後的狀態。
圖6-9 一個記憶體塊初始化後的狀態
當記憶體池析構時,需要將記憶體池的所有記憶體塊返回給程序堆:
MemoryPool::~MemoryPool(){ MemoryBlock* pMyBlock = pBlock; while ( pMyBlock ) { …… }} |
分析記憶體池的內部原理後,本節說明如何使用它。從上面的分析可以看到,該記憶體池主要有兩個對外介面函式,即Alloc和Free。Alloc返回所申請的分配單元(固定大小記憶體),Free則回收傳入的指標代表的分配單元的記憶體給記憶體池。分配的資訊則通過MemoryPool的建構函式指定,包括分配單元大小、記憶體池第1次申請的記憶體塊中所含分配單元的個數,以及記憶體池後續申請的記憶體塊所含分配單元的個數等。
綜上所述,當需要提高某些關鍵類物件的申請/回收效率時,可以考慮將該類所有生成物件所需的空間都從某個這樣的記憶體池中開闢。在銷燬物件時,只需要返回給該記憶體池。"一個類的所有物件都分配在同一個記憶體池物件中"這一需求很自然的設計方法就是為這樣的類宣告一個靜態記憶體池物件,同時為了讓其所有物件都從這個記憶體池中開闢記憶體,而不是預設的從程序堆中獲得,需要為該類過載一個new運算子。因為相應地,回收也是面向記憶體池,而不是程序的預設堆,還需要過載一個delete運算子。在new運算子中用記憶體池的Alloc函式滿足所有該類物件的記憶體請求,而銷燬某物件則可以通過在delete運算子中呼叫記憶體池的Free完成。
為了測試利用記憶體池後的效果,通過一個很小的測試程式可以發現採用記憶體池機制後耗時為297 ms。而沒有采用記憶體池機制則耗時625 ms,速度提高了52.48%。速度提高的原因可以歸結為幾點,其一,除了偶爾的記憶體申請和銷燬會導致從程序堆中分配和銷燬記憶體塊外,絕大多數的記憶體申請和銷燬都由記憶體池在已經申請到的記憶體塊中進行,而沒有直接與程序堆打交道,而直接與程序堆打交道是很耗時的操作;其二,這是單執行緒環境的記憶體池,可以看到記憶體池的Alloc和Free操作中並沒有加執行緒保護措施。因此如果類A用到該記憶體池,則所有類A物件的建立和銷燬都必須發生在同一個執行緒中。但如果類A用到記憶體池,類B也用到記憶體池,那麼類A的使用執行緒可以不必與類B的使用執行緒是同一個執行緒。
另外,在第1章中已經討論過,因為記憶體池技術使得同類型的物件分佈在相鄰的記憶體區域,而程式會經常對同一型別的物件進行遍歷操作。因此在程式執行過程中發生的缺頁應該會相應少一些,但這個一般只能在真實的複雜應用環境中進行驗證。
記憶體的申請和釋放對一個應用程式的整體效能影響極大,甚至在很多時候成為某個應用程式的瓶頸。消除記憶體申請和釋放引起的瓶頸的方法往往是針對記憶體使用的實際情況提供一個合適的記憶體池。記憶體池之所以能夠提高效能,主要是因為它能夠利用應用程式的實際記憶體使用場景中的某些"特性"。比如某些記憶體申請與釋放肯定發生在一個執行緒中,某種型別的物件生成和銷燬與應用程式中的其他型別物件要頻繁得多,等等。針對這些特性,可以為這些特殊的記憶體使用場景提供量身定做的記憶體池。這樣能夠消除系統提供的預設記憶體機制中,對於該實際應用場景中的不必要的操作,從而提升應用程式的整體效能。
- 閱讀本書的 前言 和目錄。
- 閱讀本書的 第 2 章:C++ 語言特性的效能分析。
- 閱讀本書的 第 6 章:記憶體池。
- 更多推薦書籍,請訪問 developerWorks 圖書頻道。
馮巨集華,清華大學電腦科學與技術系碩士。IBM 中國開發中心高階軟體工程師。 2003 年 12 月加入 IBM 中國開發中心,主要從事 IBM 產品的開發、效能優化等工作。興趣包括 C/C++ 應用程式效能調優,Windows 應用程式開發,Web 應用程式開發等。
徐瑩,山東大學電腦科學與技術系碩士。2003 年 4 月加入 IBM 中國開發中心,現任 IBM 中國開發中心開發經理,一直從事IBM軟體產品在多個作業系統平臺上的開發工作。曾參與 IBM 產品在 Windows 和 Linux 平臺上的效能優化工作,對 C/C++ 程式語言和跨平臺的大型軟體系統的開發有較豐富的經驗。
程遠,北京大學電腦科學與技術系碩士。IBM 中國開發中心高階軟體工程師。2003 年加入 IBM 中國開發中心,主要從事IBM Productivity Tools 產品的開發、效能優化等工作。興趣包括 C/C++ 程式語言,軟體效能工程,Windows/Linux 平臺效能測試優化工具等。
汪磊,北京航空航天大學電腦科學與技術系碩士,目前是 IBM 中國軟體開發中心高階軟體工程師。從 2002 年 12 月加入 IBM 中國開發中心至今一直從事旨在提高企業生產效率的應用軟體開發。興趣包括 C\C++ 應用程式的效能調優,Java 應用程式的效能調優。