您當前的位置:首頁 > 遊戲

記憶體池技術的原理與實現

作者:由 Linux核心庫 發表于 遊戲時間:2022-10-16

序言

最近在網上看到了幾篇篇講述記憶體池技術的文章,有一篇是有IBM中國研發中心的人寫的,寫的不錯~~文章地址在本篇blog最後。原文的講述比我的要清晰很多,我在這只是把我的一些理解和遇到的一些問題和大家分享一下~~

一、為什麼要使用記憶體池技術呢

主要有兩個原因:1、減少new、delete次數,減少執行時間;2、避免記憶體碎片。

1、效率

c語言中使用malloc/free來分配記憶體,c++中使用new/delete來分配記憶體,他們的記憶體申請與釋放都是與作業系統進行互動的。具體的內容在嚴蔚敏資料結構的第八章有相關講述,主要就是系統要維護一個記憶體連結串列,當有一個記憶體申請過來時,根據相應的分配演算法在連結串列中找個一個合適的記憶體分配給它。這些演算法有的是分配最先找到的不小於申請記憶體的記憶體塊,有的是分配最大的記憶體塊,有的是分配最接近申請記憶體大小的記憶體塊。分配的記憶體塊可能會大於所申請的記憶體大小,這樣還有進行切割,將剩餘的記憶體插入到空閒連結串列中。當釋放的時候,系統可能要對記憶體進行整理,判斷free的記憶體塊的前後是否有空閒,若有的話還要進行合併。此外,new/delete還要考慮多執行緒的情況。總之一句話,呼叫庫中的記憶體分配函式,十分的耗時~~

2、記憶體碎片

什麼是記憶體碎片內,從字面意思就很好理解了,就是記憶體不再是一整塊的了,而是碎了。因為連續的這種new/delete操作,一大塊記憶體肯能就被分割成小的記憶體分配出去了,這些小的記憶體都是不連續的。當你再去分配大的連續記憶體的時候,儘管剩餘記憶體的總和可能大於所要分配的記憶體大小,但系統就找不到連續的記憶體了,所以導致分配錯誤。malloc的時候會導致返回NULL,而new的時候再vc6。0中返回NULL,vs2003以上則是丟擲異常。

我每天持續更新核心程式設計乾貨技術,本群免費分享文件影片教程和學習資料q群:891587639,歡迎大家進群交流。

二、原理

要解決上述兩個問題,最好的方法就是記憶體池技術。具體方法就是大小固定、提前申請、重複利用。

因為記憶體的申請和釋放是很低效的,所以我們只在開始時申請一塊大的記憶體(在該塊記憶體不夠用時在二次分配),然後每次需要時都從這塊記憶體中取出,並標記下這塊記憶體被用了,釋放時標記此記憶體被釋放了。釋放時,並不真的把記憶體釋放給作業系統,只要在一大塊記憶體都空閒的時候,才釋放給作業系統。這樣,就減少了new/delete的操作次數,從而提高了效率。

在呼叫記憶體分配函式的時候,大部分時間所分配的記憶體大小都是一定的,所以可以採用每次都分配固定大小的記憶體塊,這樣就避免了記憶體碎片產生的可能。

三、具體實現

我所採用的記憶體池的構造方法完全是按照文章1所介紹的方法,記憶體池的結構圖如下:

記憶體池技術的原理與實現

如圖所示MemoryPool是一個記憶體池類,其中pBlock是一個指向了一個記憶體塊的指標,nUintSzie是分配單元的大小,nInitSize是第一次分配時向系統申請的記憶體的大小,nGrouSize是後面每次向系統申請的記憶體的大小。

MemoryBloc代表一個記憶體塊單元,它有兩部分構成,一部分時MemoryBlock類的大小,另一部分則是實際的記憶體部分。一個MemoryBlock的記憶體是在過載的new運算子中分配的,如下所示:

void* MemoryBlock::operator new(size_t, int nUnitSize,int nUnitAmount ){ return ::operator new( sizeof(MemoryBlock) + nUnitSize * nUnitAmount );}

MemoryBlock內中,nSize程式碼該記憶體塊的大小(系統分配記憶體大小-MemoryBlock類的大小),nFree是空閒記憶體單元的個數,nFirst代表的是下一個要分配的記憶體單元的序號。aData是用來記錄待分配記憶體的位置的。因為要分配的記憶體是在new中一起向系統申請的,並沒有一個指標指向這塊記憶體的位置,但它的位置就在MemoryBlock這個類的地址開始的,所以可以用MemoryBlock的最後一個成員的位置來表示待分配記憶體的位置。

帶分配記憶體中,是以nUnitSize為單位的,一個記憶體單元的頭兩個位元組都記錄了下一個要分配的記憶體單元的序號,序號從0開始。這樣實際也就構成了一個數組連結串列。由MemoryBlock的建構函式來完成這個連結串列的初始化工作:

MemoryBlock::MemoryBlock( int nUnitSize,int nUnitAmount ) : nSize (nUnitAmount * nUnitSize), nFree (nUnitAmount - 1), //構造的時候,就已將第一個單元分配出去了,所以減一 nFirst (1), //同上 pNext (NULL){ //初始化陣列連結串列,將每個分配單元的下一個分配單元的序號寫在當前單元的前兩個位元組中 char* pData = aData; //最後一個位置不用寫入 for( int i = 1; i < nSize - 1; i++) { (*(USHORT*)pData) = i; pData += nUnitSize; }}

在MemoryPool的Alloc()中,遍歷block連結串列,找到nFree大於0的block,從其上分配記憶體單元。然後將nFree減一,修改nFirst的值。

在MemoryPool的Free(pFree)函式中,根據pFree的值,找到它所在的記憶體塊,然後將它的序號作為nFirst的值(因為它絕對是空閒的),在pFree的頭兩個位元組中寫入原來nFirst的值。然後要判斷,該block是否全部為free,方法是檢測nFree * nUnitSize == nSize。若是,則向系統釋放記憶體,若不是,則將該block放到連結串列的頭部,因為該block上一定含有空隙的記憶體單元,這樣可以減少分配時遍歷連結串列所消耗的時間。

四、使用

記憶體池一般都是作為一個類的靜態成員,或者全域性變數。使用時,過載new運算子,使其到MemoryPool中去分配記憶體,而不是向系統申請。這樣,一個類的所以物件都在一個記憶體池中開闢空間。

void CTest::operator delete( void* pTest ){ Pool。Free(pTest); } void* CTest::operator new(size_t){ return (CTest*)Pool。Alloc();}

五、程式碼

MemoryPool。h

+ View Code

MemoryPool。cpp

+ View Code

CTest。cpp

+ View Code

六、問題

在編寫程式碼時,遇到了一些小問題,現與大家分享如下:

1、過載new運算子時,編譯器要求是第一個引數必須是size_t,返回值必須是void*;free的第一個引數必須是void*。

2、一般要在類的成員中過載new運算子,而不要過載全域性的new運算子。

3、一個類中要是過載了一個new運算子,一定要有一個相應型別的delete運算子,可以什麼都不幹,但必須有,否則在建構函式失敗時,找不到對應的delete函式。

例如:

12

static void* operator new (size_t,int nUnitSize,int nUnitAmount); static void operator delete (void* ,int nUnitSize,int nUnitAmount){};

4、帶引數的new運算子

pBlock = (MemoryBlock*)new(nUnitSize,nInitSize) MemoryBlock(nUnitSize,nUnitSize);

第一個nUnitSize nInitSize是new運算子的引數,該new運算子是new了一個MemoryBlock物件,在new返回的地址上構造MemoryBlock的物件。

5、如果在類的內部不能進行靜態成員的定義的話,可以只在內部進行宣告,在外部定義:

MemoryPool CTest::Pool(sizeof(CTest));