C++手寫記憶體池

HickeyZhang發表於2021-08-07

引言

使用new expression為類的多個例項分配動態記憶體時,cookie導致記憶體利用率可能不高,此時我們通過實現類的記憶體池來降低overhead。從不成熟到巧妙優化的記憶體池,得益於union的分時複用特性,記憶體利用率得到了提高。


原因

在例項化某個類的物件時(在heap而不是stack中),若不使用array new,則每次例項化時都要呼叫一次記憶體分配函式,類的每個例項在記憶體中都有上下兩個cookie,從而降低了記憶體的利用率。然而,array new也有先天的缺陷,即只能呼叫預設無參建構函式,這對於很多沒有提供無參建構函式的類來說是不合適的。

因此,我們可以對於一個沒有例項化的類第一次例項化時,先分配一大塊記憶體(記憶體池),這一大塊記憶體記錄在類中,只有上下兩個cookie,能夠容納多個例項。後續例項化時,若記憶體池中還有剩餘記憶體,則不必申請記憶體分配,只在記憶體池中分配。記憶體回收時,將例項所佔用的記憶體回收到記憶體池中。若記憶體池中無記憶體,則再申請分配大塊記憶體。

脫褲子放屁方案

我們以連結串列的形式組織記憶體池,記憶體池中每個一個連結串列是一個小桶,這個桶中裝我們例項化的物件。

記憶體池連結串列的頭結點記錄在類中,即以class staic變數的形式儲存。組織形式如下:

實現程式碼如下:

#include <iostream>
using namespace std;
class DemoClass{
public:
    DemoClass() = default;
    DemoClass(int i):data(i){}
    static void* operator new(size_t size);
    static void operator delete(void *);
    virtual ~DemoClass(){}
private:
    DemoClass *next;
    int data;
    static DemoClass *freeMemHeader;
    static const size_t POOL_SIZE;
};
DemoClass * DemoClass::freeMemHeader = nullptr;
const size_t DemoClass::POOL_SIZE = 24;//設定記憶體池能容納24個DemoClass物件
void* DemoClass::operator new(size_t size){
    DemoClass* p;
    if(!freeMemHeader){//freeMemHeader為空,記憶體池中無空間,分配記憶體
        size_t pool_mem_bytes = size * POOL_SIZE;//記憶體池的位元組大小 = 每個例項的大小(位元組數)* 記憶體池中能容納的最大例項數
        freeMemHeader = reinterpret_cast<DemoClass*>(new char[pool_mem_bytes]);//new char[]分配pool_mem_bytes個位元組,因為每個char佔用1個位元組
        cout << "Info:向作業系統申請了" << pool_mem_bytes << "位元組的記憶體。" << endl;
        for(int i = 0;i < POOL_SIZE - 1; ++i){//將記憶體池中POOL_SIZE個小塊記憶體,串起來。
            freeMemHeader[i].next = &freeMemHeader[i + 1];
        }
        freeMemHeader[POOL_SIZE - 1].next = nullptr;
    }
    p = freeMemHeader;//取記憶體池(連結串列)的頭部,分配給要例項化的物件
    cout << "Info:從記憶體池中取了" << size << "位元組的記憶體。" << endl;
    freeMemHeader = freeMemHeader -> next;//從記憶體池中刪去取出的那一小塊地址,即更新記憶體池
    p -> next = nullptr;
    return p;
}
void DemoClass::operator delete(void* p){
    DemoClass* tmp = (DemoClass*) p;
    tmp -> next = freeMemHeader;
    freeMemHeader = tmp;
}

測試程式碼如下:

int main(int argc, char* argv[]){
    cout << "sizeof(DemoClass):" << sizeof(DemoClass) << endl;
    size_t N = 32;
    DemoClass* demos[N];
    for(int i = 0; i < N; ++i){
        demos[i] = new DemoClass(i);
        cout << "address of the ith demo:" << demos[i] << endl;
        cout << endl;
    }
    return 0;
}

其結果如下:

可以看到每個DemoClass的例項大小為24位元組,記憶體池一次從作業系統中申請了576個位元組的記憶體,這些記憶體可以容納24個例項。上面顯示出了每個例項的記憶體地址,記憶體池中相鄰例項的記憶體首地址之差為24,即例項的大小,證明了一個記憶體池的例項之間確實沒有cookie。

當記憶體池中記憶體用完後,又向作業系統申請了576個位元組的記憶體。

由此,只有每個記憶體池兩側有cookie,而記憶體池中的例項不存在cookie,相比於每次呼叫new expression例項化物件都有cookie,記憶體池的組織形式確實在形式上提高了記憶體利用率

那麼,有什麼問題麼

sizeof(DemoClass)等於24

  • int data資料域佔4個位元組
  • 兩個建構函式一個解構函式各佔4位元組,共12位元組
  • 額外的指標DemoClass*,在64位機器上,佔8個位元組

這樣一個DemoClass的大小確實是24位元組。wait,what?

我們為了解決cookie帶來的記憶體浪費,引入了指標next,但卻又引入了8個位元組的overhead,脫褲子放屁,多此一舉

這樣看來確實沒有達到要求,但至少為我們提供了一種思路,不是麼?

分時複用改進方案

首先我們先回憶下c++ 中的Union:

在任意時刻,聯合中只能有一個資料成員可以有值。當給聯合中某個成員賦值之後,該聯合中的其它成員就變成未定義狀態了。

結合我們之前不成熟的記憶體池,我們發現,當記憶體池中的桶還沒有被分配給例項時,只有next域有用,而當桶被分配給例項後,next域就沒什麼用了;當桶被回收時,資料域變無用而next指標又需要用到。這不正是union的特性麼?

看一下程式碼實現:

#include <iostream>
using namespace std;
class DemoClass{
public:
    DemoClass() = default;
    DemoClass(int i, double p){
        data.num = i;
        data.price = p;
    }
    static void* operator new(size_t size);
    static void operator delete(void *);
    virtual ~DemoClass(){}
private:
    struct DemoData{
        int num;
        double price;
    };
private:
    static DemoClass *freeMemHeader;
    static const size_t POOL_SIZE;
    union {
        DemoClass *next;
        DemoData data;
    };
    
};
DemoClass * DemoClass::freeMemHeader = nullptr;
const size_t DemoClass::POOL_SIZE = 24;//設定記憶體池能容納24個DemoClass物件
void* DemoClass::operator new(size_t size){
    DemoClass* p;
    if(!freeMemHeader){//freeMemHeader為空,記憶體池中無空間,分配記憶體
        size_t pool_mem_bytes = size * POOL_SIZE;//記憶體池的位元組大小 = 每個例項的大小(位元組數)* 記憶體池中能容納的最大例項數
        freeMemHeader = reinterpret_cast<DemoClass*>(new char[pool_mem_bytes]);//new char[]分配pool_mem_bytes個位元組,因為每個char佔用1個位元組
        cout << "Info:向作業系統申請了" << pool_mem_bytes << "位元組的記憶體。" << endl;
        for(int i = 0;i < POOL_SIZE - 1; ++i){//將記憶體池中POOL_SIZE個小塊記憶體,串起來。
            freeMemHeader[i].next = &freeMemHeader[i + 1];
        }
        freeMemHeader[POOL_SIZE - 1].next = nullptr;
    }
    p = freeMemHeader;//取記憶體池(連結串列)的頭部,分配給要例項化的物件
    cout << "Info:從記憶體池中取了" << size << "位元組的記憶體。" << endl;
    freeMemHeader = freeMemHeader -> next;//從記憶體池中刪去取出的那一小塊地址,即更新記憶體池
    p -> next = nullptr;
    return p;
}
void DemoClass::operator delete(void* p){
    DemoClass* tmp = (DemoClass*) p;
    tmp -> next = freeMemHeader;
    freeMemHeader = tmp;
}

對比前一種實現程式碼,只是建構函式、資料域和指標域的組織形式發生了變化:

  • 由於資料域增加了price項,建構函式中也增加了對應的引數
  • 資料域被整合定義成一個類自定義struct型別
  • 資料域和指標域被組織為union

測試程式碼依舊:

int main(int argc, char* argv[]){
    cout << "sizeof(DemoClass):" << sizeof(DemoClass) << endl;
    size_t N = 32;
    DemoClass* demos[N];
    for(int i = 0; i < N; ++i){
        demos[i] = new DemoClass(i, i * i);
        cout << "address of the " << i << "th demo:" << demos[i] << endl;
        cout << endl;
    }
    return 0;
}

結果:

可以看到每個DemoClass的例項大小為24位元組,一個記憶體池的例項之間沒有cookie。

分析一下sizeof(DemoClass)等於24的緣由:

  • data資料域佔12個位元組(int 4位元組、double 8位元組)。
  • 兩個建構函式一個解構函式各佔4位元組,共12位元組。
  • 指標DemoClass,在64位機器上,佔8個位元組,但由於和資料域使用了union,data資料域12個位元組中的前8個位元組在適當的時機被看作DemoClass,而不佔用額外空間,消除了overhead。

這樣一個DemoClass的大小確實是24位元組。利用union的分時複用特性,我們消除了初步方案中指標帶來的脫褲子放屁效果。

另外的思考

細心的讀者可能會發現,前面的那兩種方案都有共同的小缺陷,即當程式一直例項化而不析構時,記憶體池會向作業系統申請多次大塊記憶體,而當這些物件一起回收時,記憶體池中的剩餘桶數會遠大於設定的POOL_SIZE的大小,這個峰值多大取決於類例項化和回收的時機。

另外,記憶體池中的記憶體暫時不會回收給作業系統,峰值很大可能會對記憶體分配帶來一些影響,不過這卻不屬於記憶體洩漏。在以後的文章中,我們可能會討論一些效能更好的記憶體分配方案。

參考資料

[1] Effective C++ 3/e

[2] C++ Primer 5/e

[3] 侯捷老師的記憶體管理課程

相關文章