爲何要使用內存池
利用默認的內存管理函數,在堆上分配和釋放內存會有一些額外的開銷。
系統在接收到分配一定大小內存的請求時,首先查找內部維護的內存空閒塊表,並且需要根據一定的算法找到合適大小的空閒內存塊。其間,還涉及到空閒內存塊的分割、合併等問題。
默認的內存管理函數還考慮到多線程的應用,需要在每次分配和釋放內存時加鎖,同樣增加開銷。
頻繁在堆上分配和釋放內存,會導致性能的損失,並且使得系統中出現大量的內存碎片,降低利用率。
所以,對某個具體的應用程序來說,自定義適合自身的內存池,可以獲得更好的性能。
內存池的分類
從線程安全的角度來分,單線程內存池性能更高,多線程內存池適用範圍更廣(多個線程共享,每次分配和釋放內存時加鎖)[1]。
從可分配內存單元大小來分,可分爲固定內存池和可變內存池。
<?xml:namespace prefix = o />
內存池的優點
1) 針對特殊情況,例如需要頻繁分配釋放固定大小[2]的內存對象時,不需要複雜的分配算法[3]和多線程保護,也不需要維護內存空閒表的額外開銷,從而獲得較高的性能。
2) 由於開闢一定數量的連續內存空間作爲內存池塊,因而提高了程序局部性,提升了程序性能[4]。
3) 比較容易控制頁邊界對齊和內存字節對齊,沒有內存碎片的問題[5]。
內存池的實現
1) 內部構造
- 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)
- {
- //給出void指針進行刪除嗎?size_t應該給出對象的大小吧![6]
- ::operator delete(p);
- }
- };
- 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);
- };
2)
總體機制MemoryBlock只維護沒有分配的自由分配單元[10]的信息,nFree記錄這個本Block內剩餘自由分配單元的數目,nFirst則記錄下一個可供分配的單元編號。利用自由分配單元的頭兩個字節[11],記錄緊跟它之後的下一個自由分配單元的編號,在MemoryBlock內形成鏈接。
在接到新的內存請求時,MemoryPool會遍歷MemoryBlock鏈表,找到nFree大於0的塊,然後根據編號定位到自由單元的起始位置[12],然後修改nFirst和nFree。
如果現有的內存塊找不到自有單元,MemoryPool就會申請一個MemoryBlock,並初始化它[14]。
當某個被分配的單元因爲delete需要被回收時,該單元不會返回給進程堆,而是給MemoryPool。MemoryPool找到這個內存塊[13],將單元的編號放到自由分配單元鏈表的頭部,nFree++。如果考慮到資源有效利用及後繼操作,內存池還應考慮釋放那些全部單元都爲自由的MemoryBlock。
3) 細節剖析
aData存儲編號的實現方式,而不是結構體後面直接跟隨幾個變量,手動計算單元塊位置的實現方式:主要還是考慮不同平臺的“對齊”問題。雖然書上的示意圖將struct MemoryBlock和後面的自有單元塊畫得緊挨在一起,但是實現的時候,struct和單元塊之間,可能會因爲對齊的原因,產生空隙的(一兩個字節錯開,就要命了)。
更詳盡的代碼框架,見書本。在這裏就偷個懶吧!
4) 內存池使用方法
內存池主要有兩個接口:pType Alloc()和Free(pType)。
分配的信息由MemoryPool的構造函數指定,包括分配單元大小、內存池第一次申請的內存塊中所含分配單元的個數,以及後繼申請內存塊所含分配單元的個數等。
“一個類的所有對象都分配在同一個內存池對象中”這一需求很自然的設計方法就是爲這樣的類生命一個靜態內存池對象,同時爲了讓其它所有對象[7]都從這個內存池中開闢內存,而不是缺省的從進程堆中獲得,需要爲該類重載一個new操作符[8]。回收也是面向內存池,所以,還需要重載一個delete操作符。
性能比較
書上的結論是:採用內存池,耗時爲297ms,沒采用內存池則耗時625ms,速度提高了52.48%。這個測試方案仍然是比較簡單的:測試程序很小、單線程、也沒有考慮開發成本和代碼安全等方面的內容[9]。
[1] 根據“時間-空間互換”的概念,多線程共享既然犧牲了運行時間,那麼它一定是節省了空間。說得通俗一點,就是在內存很緊缺的時候,使用多線程池能夠改善性能(同時,由於訪問加鎖的緣故,可能導致每次訪問的時間變慢——對於併發網絡訪問比較頻繁的時候,進行很長的排隊,往往是用戶不能忍受的)。
此外,多線程還導致了開發週期的加長、後期代碼難以維護的問題,而且,增加內存似乎也並不是特別昂貴的代價、採用多線程未必能夠帶來“質的飛躍”(不可能讓性能有級數級別的提高,頂多也就是倍數級別——提高一兩倍)。在現實中,我們何時才需要在開發中“不得不採用”多線程呢?
我個人的感覺是:不要輕易嘗試多線程。
[2] 固定大小,也就是暗示存儲的對象都是一個類型的(暫不考慮不安全的類型轉換的情況)。
[3] 不復雜嗎?相對於別人封裝好的算法(比如new、malloc等),肯定算是複雜了。作者習慣於對實現“輕描淡寫”。
[4] 這麼理解:大塊的局部連續內存,使用時能夠提升性能。(總感覺和第一點有些重複)
[5] 在作者強調這些優點的同時,讀者更應該意識到一些隱蔽的缺點:開發管理上的難度、理解和使用的難度、安全性等。
[6] void指針不能簡單地釋放我們的對象,如果不知道類型的話:
- class A
- {
- public:
- A()
- {
- cout<<"constructor"<<endl;
- }
- ~A()
- {
- cout<<"destructor"<<endl;
- }
- };
- void main()
- {
- A * p = new A;
- // void *p1 = (void*)p; //雖然這樣可以
- // delete p1; //刪除也可以,但是沒有調用析構函數!(delete了什麼東西呢?)
- delete p; //所以必須知道是什麼類型的指針才能刪除
- };
[7] 試想一下,在我們使用Sessions連接池、數據庫連接池的時候,有感覺到池的存在嗎?如果沒有,底層是如何進行封裝的呢?在這本書裏面,雖然介紹了池的實現,但是沒有介紹池對象的實現呢!
[8] 在new運算符中用內存池的Alloc函數滿足該類對象的內存請求,在delete運算符中調用內存池的Free完成。
這樣的設計方案也影響了“池中對象”的設計(對於池中對象而言,它在編寫接口的時候,必須知道“池”的存在,並且能夠訪問這個池)。
[9] 我想,在實際開發過程中,會遇到更復雜的情況,考慮得更加深入,體會得更加多吧!也就是說:必須實際動手編寫代碼、運行得到實驗數據!
[10] 使用“自由分配單元”這個名字而不是“空閒單元”,是因爲這些單元並不是真正意義上“空閒”的:它們至少包含着下一個空閒單元的編號,而且,在被新的數據覆蓋以前,舊數據仍然存在。
[11] 由於這裏沒有給出自由分配單元的代碼,所以,實現的過程得自己體會了。
[12] 因爲分配單元大小固定,所以起始位置可以通過:編號*分配單元大小,來偏移定位,這個位置就是用來滿足此次內存申請請求的內存的起始地址(將類對象的初始化數據傳給這個塊就OK了)。
[13] 注意,在實際應用中,可能有的單元不在MemoryPool中——這種情況也是有可能的(根據最初設計方案而定了)。在實現的時候,邏輯上不要遺忘了這一條。
[14] nSize爲所有內存分配塊的大小(不包括MemoryBlock結構體的大小),nFree爲n-1(因爲有一個單元即將被分配出去),nFirst設爲1(0號單元即將被分配),aData設爲第一個分配單元的起始地址,其後的內存塊的“下一個空閒單元編號”依次賦值(最後一個單元的“下一個空閒單元編號”先空着)。