本文也是候捷《C++内存管理机制》的笔记,首先介绍了C++原始内存分配的基本方法以及优缺点,随后介绍了一个简单的内存池。虽然在Modern C++中已经不建议直接管理内存分配后的指针,而是采用智能指针的方式,但是这些都只是对原始内存分配的一些包装或处理,所以了解这些内容仍然有必要。

基本方式


《Effective C++》这本书开篇就指出,C++由几大部分组成:

  • C语言部分。C++是以C为基础,C++经过编译器处理后就是C语言。在C++中仍然保留了很多C语言的用法,比如区块(blocks)、语句(statement),数组(arrays)、指针(pointer)等等。所以C语言中的一些内存分配方法,在C++中仍然能够使用。
  • 面向对象,就是C with Class,包括类、封装、继承、多态等等。
  • 模版,Template C++是C++泛型编程的一部分。这一点跟C#中的泛型编程个人感觉区别巨大。但C++中的模版功能强大,STL标准模板库基本都是构建在模版之上。
  • STL。标准模版库,它类似C#中的BCL。STL中有容器(containers)、迭代器(iterators)、算法(algorithms)、分配器(allocator)、仿函数(Function Object)、容器适配器(Container Adapter)它们互为配合。有一本专门介绍STL的书叫《Effective STL》看完我大为震惊,虽然里面内容有些已经过时,但STL的一些用法和思路跟C#里面的容器完全不一样,这是使用STL特别需要注意的地方。

这四部分,从一部分切换到另外一部分时,一些高效编程的策略可能会发生变化,比如内置类型(C-like)而言“传值(pass-by-value)”,通常比“传引用(pass-by-reference)”更高效,但当切换到面向对象部分时,自定义类型由于构造函数和析构函数的存在,以“传常量引用类型(pass-by-reference-to-const)”往往更好,所以那本书调侃C++是一门“联邦”语言。

言归正传,因为C++以C为基础,所以里面保留了C里面的分配和释放内存的操作,同时在C++中又引入了自己的分配和释放对象的方法。所以乍看起来单是基本的内存分配就有好多种方法。下面的图来自候捷《C++内存管理机制》这门课,概括得很好。

🔺来源:候捷《C++内存管理机制》

从上到下,在C++中有如下分配内存的方法:

  • C++类库中的分配器(std::allocator),这个分配器一般用于容器也就是STL,在使用容器的时候,容器在内部会使用分配器。用户一般不会直接使用分配器,因为它有一个缺点就是必须要记住分配时分配了多少个元素,然后在释放的时候传入这个个数,否则就会出问题。它不像delete[]那样,只需要记住分配的时候是否是分配的数组,如果分配的是数组,使用delete[]就可以,不必传递个数。在《C++ Primer,5th》和《Effective C++》中都有直接使用分配器的例子。
  • new、delete、new()、::operator new(),这些都是C++引入的基本关键字(C++ primitives),也是用的最多的操作,可以对operator new和operator delete进行重写(overiding)。
  • malloc、free。它们是CRT (C运行环境,C runtime)的一部分。
  • O.S. API,比如Windows下面的HeapAlloc、VirtualAlloc。这就具体到各种底层操作系统的实现了,一般不会直接调用,因为它跟平台操作系统有关。

在以上层级关系中,上层调用下层的实现(std:allocator内部调用new和delete,new和delete在内部调用CRT的malloc和free),这也符合分层的思想,也能保证可移植性。下面是一些用法:

#include <iostream>
#include <complex>
#include "ext\pool_allocator.h"
int main()
{
    // method 1: malloc,free
    void *p1 = malloc(512);
    free(p1);

    // method 2: new , delete
    std::complex<int> *p2 = new std::complex<int>;
    delete p2;

    // method 3: operator new ,operator delete
    void *p3 = ::operator new(512);
    ::operator delete(p3);

    // method 4: 使用std::allocator
    int *p4 = std::allocator<int>().allocate(3);
    std::allocator<int>().deallocate(p4, 3);

    // method 5: 使用pool_alloc
    void *p5 = __gnu_cxx::__pool_alloc<int>().allocate(9);
    __gnu_cxx::__pool_alloc<int>().deallocate((int *)p5, 9);
}

上面的方法4和方法5都是使用分配器来实现,方法4是标准的allocator,在其内部其实是直接对new和delete的包装,方法5时gnu中的一个pool allocator,设计得很巧妙,后面可以单独写篇文章来详细讨论,要使用_pool_alloc需要单独引用“ext\pool_allocator.h”。

new和delete


先定义一个类A:

#include <iostream>
class A
{
private:
    int id;

public:
    A() : id(0)
    {
        std::cout << "default ctor. this=" << this << " id=" << id << std::endl;
    }

    A(int i) : id(i)
    {
        std::cout << "ctor. this=" << this << " id=" << id << std::endl;
    }

    ~A()
    {
        std::cout << "dtor. this=" << this << " id=" << id << std::endl;
    }
};

一般使用new来初始化一个对象,返回一个指针。

A *pa = new A(1);
delete pa;

此时编译器相当于生成以下代码:

A *pa;
try
{
    void *mem = operator new(sizeof(A));
    A *pa= static_cast<A*>(mem);
    pa->A::A(1);
    pa->~A();
    operator delete(pa);
}
catch (const std::bad_alloc &e)
{
    std::cerr << e.what() << '\n';
}

首先,通过operator new来分配一块内存,大小为A的大小,由于A里面没有重写operator new操作符,所以调用的是全局的operator new 操作。然后使用static_cast将返回的的内存地址转型为指针pa,然后通过指针调用A的构造函数,传入参数1。遗憾的是,编译器不允许用户代码直接通过指针的方式调用构造函数。在gnu c中上述代码会报错:

D:\Study\CPPPrimer\HoujieStl\memorymanage_jj02.cpp:36:17: error: cannot call constructor 'A::A' directly
   36 |         paa->A::A(1);
      |                 ^

然而,C++还提供了定位new(placement new),允许将对象构建在已经分配好的内存中回想起来,在C#中很多方法都允许用户自己指定byte[]对象,比如在MemoryStream中,可以指定要写入的byte[]对象,而不是默认的不带参数的MemoryStream,在一些压缩解压类库中,也提供了将压缩对象或者解压缩对象放在指定的byte[]数组中,而不是默认的不提供让方法在内部产生byte[]然后返回,这种方便我们对这些byte[]进行池化管理以提升内存管理效率)。

将上述代码改为定位new之后如下:

A *pa;
try
{
    void *mem = operator new(sizeof(A));
    A *pa = static_cast<A*>(mem);
    new (pa) A(1);
    pa->~A();
    operator delete(pa);
}
catch (const std::bad_alloc &e)
{
    std::cerr << e.what() << '\n';
}

就能顺利通过编译。

总结下来,就是对于new,编译器会分解为三个步骤:

  1. 使用 operator new 分配对象大小的内存空间 void *mem。
  2. 将分配得到的内存空间转换为类型指针 A *pa,
  3. 然后在内部调用对象A的默认构造函数 pa->A::A();这一步只能编译器进行,用户代码不允许直接调用构造函数,不论是通过指针还是直接调用A:A()的方式。
  4. 用户代码可以通过placemenet new的方式来显示调用构造函数

对与delete,编译器会分解为两个步骤:

  1. 调用对象的析构函数 pa->~A(),用户代码也是可以直接通过指针调用析构函数的,这一点跟用户无法通过指针调用默认构造函数不同。
  2. 调用operator delete释放pa所指的内存。

new[]和delete[]


new[]也叫array new,用来初始化数组对象,new[]初始化的对象需要用delete[]来释放。

size_t SIZE = 3;
void test_arraynew_arraydelete()
{
    A *buf = new A[SIZE];
    A *tmp = buf;
    std::cout << "buf= " << buf << " tmp= " << tmp << std::endl;
    for (size_t i = 0; i < SIZE; ++i)
    {
        /* code */
        new (tmp++) A(i);
    }
    std::cout << "buf= " << buf << " tmp= " << tmp << std::endl;
    delete[] buf;
}

使用new[]来初始化对象,因为没有地方在初始化的时候,为对象赋予初值,所以必须为对象提供默认构造函数,否则编译器会报错。

memorymanage_jj02.cpp:56:28: error: no matching function for call to 'jj2study::A::A()'
   56 |         A *buf = new A[SIZE];

在上面的代码中,要给初始化的对象赋值,必须使用placement new,传入array new返回的指针。

最后,调用delete[]来释放对象。以上代码输出结果为:

default ctor. this=0x1b166fd1d78 id=0
default ctor. this=0x1b166fd1d7c id=0
default ctor. this=0x1b166fd1d80 id=0
buf= 0x1b166fd1d78 tmp= 0x1b166fd1d78
ctor. this=0x1b166fd1d78 id=0
ctor. this=0x1b166fd1d7c id=1
ctor. this=0x1b166fd1d80 id=2
buf= 0x1b166fd1d78 tmp= 0x1b166fd1d84
dtor. this=0x1b166fd1d80 id=2
dtor. this=0x1b166fd1d7c id=1
dtor. this=0x1b166fd1d78 id=0

可以看到,调用new A[3]会调用三次默认构造函数,id的值默认设置为了0。返回的指针为元素的首地址。

然后调用定位new来给对象赋值,可以看到调用了构造函数,同时地址也没有发送改变,id的值为设置后的新值。

最后调用delete[]可以看到调用了三次析构函数,调用次序为从数组的最后一个元素开始调用。

对于使用new[]分配的对象,如果使用delete来析构,在运行的时候我的vscode+gcc的环境会报错。在实际情况下,这里因为对象A里面都是基本的值类型(class without ptr number),它的析构函数是不重要的 trival,所以理论上来说这里用delete也可以,不会造成内存泄漏,但是如果A对象里面有指针(class with pointer member),那么则A对象会指向另外一块内存空间,这时候A的析构函数就非常重要了,如果此时使用delete而不是delete[],则会导致数组中只有最后一个对象的析构函数得到了调用,其余对象的析构函数没有被调用,虽然指针被回收了,但是有n-1个对象的构造函数没有被调用,这就会造成内存泄漏。

重写operator new和operator delete


前面谈到,在C++中new和delete关键字在内部会调用operator new和operator delete,如果对象没有重写这两个操作那么就会调用全局的::operator new和::operator delete,这两个全局的方法在内部会调用crt里面的malloc和free两个方法。

operator new和operator delete可以重写,进而来进行内存管理,因为默认的内存管理操作是直接调用malloc和free,返回的内存对象包含了上下cookie(以方便进行上下内存块的合并操作)。为了消除这个不必要的cookie,可以对这两个方法进行重写。一般不会重写全局的::operator new和::operator delele,因为这个影响面很大。

全局的operator new的源码在new_op.cc这个文件中,内容如下:

_GLIBCXX_WEAK_DEFINITION void *
operator new(std::size_t sz) _GLIBCXX_THROW(std::bad_alloc)
{
    void *p;

    /* malloc (0) is unpredictable; avoid it.  */
    if (__builtin_expect(sz == 0, false))
        sz = 1;

    while ((p = malloc(sz)) == 0)
    {
        new_handler handler = std::get_new_handler();
        if (!handler)
            _GLIBCXX_THROW_OR_ABORT(bad_alloc());
        handler();
    }

    return p;
}

代码内部实际上就是调用了malloc,如果分配成功,直接返回分配的内存地址,否则,获取handle操作,再次尝试分配。

全局的operator delelete就更简单了,代码在del_op.cc中,内容如下,就是简单的调用free方法。

 _GLIBCXX_WEAK_DEFINITION void
 operator delete(void* ptr) noexcept
 {
    std::free(ptr);
 }

要重载全局的operator new和operator delete很简单,就是在代码里面不能包含在任何的namespace里面定义如下方法:

void* myAlloc(size_t size)
{
    return malloc(size);
}

void myFree(void* ptr)
{
    return free(ptr);
}

inline void* operator new(size_t size)
{
    std::cout<<" global new() \n";
    return myAlloc(size);
}

inline void* operator new[](size_t size)
{
    std::cout<<" global new[]() \n";
    return myAlloc(size);
}

inline void operator delete(void *ptr)
{
    std::cout<<" global delete() \n";
    myFree(ptr);
}

inline void operator delete[](void* ptr)
{
    std::cout<<" global delete[]() \n";
    myFree(ptr);
}

非常简单,只是在里面打印了一句话,并且最后调用了malloc和free方法。一般不应该这样重载全局的operator new和operator delete因为影响范围太广,而应该在类内部重载operator new和operator delete。

class Foo
{
public:
    int _id;
    long _data;
    std::string _str;
    Foo() : _id(0)
    {
        std::cout << "default ctor. this=" << this << " id=" << _id << std::endl;
    }
    Foo(int i) : _id(i)
    {
        std::cout << "ctor. this=" << this << " id=" << _id << std::endl;
    }
    virtual ~Foo()
    {
        std::cout << "dtor. this=" << this << " id=" << _id << std::endl;
    }
    static void *operator new(size_t size);
    static void operator delete(void *pdead, size_t size);
    static void *operator new[](size_t size);
    static void operator delete[](void *pdead, size_t size);
};

void *Foo::operator new(size_t size)
{
    Foo *p = (Foo *)malloc(size);
    std::cout << "Foo operator new(), size=" << size << " return:" << p << std::endl;
    return p;
}

void Foo::operator delete(void *pdead, size_t size)
{
    std::cout << "Foo operator delete(), pdead=" << pdead << " size=" << size << std::endl;
    free(pdead);
}

void *Foo::operator new[](size_t size)
{
    Foo *p = (Foo *)malloc(size);
    std::cout << "Foo operator new[](), size=" << size << " return:" << p << std::endl;
    return p;
}

void Foo::operator delete[](void *pdead, size_t size)
{
    std::cout << "Foo operator delete[](), pdead=" << pdead << " size=" << size << std::endl;
    free(pdead);
}

void test()
{
    std::cout << "sizeof(Foo)=" << sizeof(Foo) << std::endl;

    Foo *p = new Foo(7);
    delete p;

    Foo *pArray = new Foo[5];
    delete[] pArray;
}

一个需要注意的点是,类里面的opeartor new和delete必须是静态的,因为它是用来构造对象,此时对象还没有被构造出来,所以不能以实例的方式调用。另外,析构函数如果加virutal和不加virtual输出的结果是不一样的。

下面是析构函数不加virtual的结果:

sizeof(Foo)=40
Foo operator new(), size=40 return:0x23ce0181940
ctor. this=0x23ce0181940 id=7
dtor. this=0x23ce0181940 id=7
Foo operator delete(), pdead=0x23ce0181940 size=40
Foo operator new[](), size=208 return:0x23ce0183f20
default ctor. this=0x23ce0183f28 id=0
default ctor. this=0x23ce0183f50 id=0
default ctor. this=0x23ce0183f78 id=0
default ctor. this=0x23ce0183fa0 id=0
default ctor. this=0x23ce0183fc8 id=0
dtor. this=0x23ce0183fc8 id=0
dtor. this=0x23ce0183fa0 id=0
dtor. this=0x23ce0183f78 id=0
dtor. this=0x23ce0183f50 id=0
dtor. this=0x23ce0183f28 id=0
Foo operator delete[](), pdead=0x23ce0183f20 size=208

下面是加virtual的结果:

sizeof(Foo)=48
Foo operator new(), size=48 return:0x117b3cf1940
ctor. this=0x117b3cf1940 id=7
dtor. this=0x117b3cf1940 id=7
Foo operator delete(), pdead=0x117b3cf1940 size=48
Foo operator new[](), size=248 return:0x117b3cf3f20
default ctor. this=0x117b3cf3f28 id=0
default ctor. this=0x117b3cf3f58 id=0
default ctor. this=0x117b3cf3f88 id=0
default ctor. this=0x117b3cf3fb8 id=0
default ctor. this=0x117b3cf3fe8 id=0
dtor. this=0x117b3cf3fe8 id=0
dtor. this=0x117b3cf3fb8 id=0
dtor. this=0x117b3cf3f88 id=0
dtor. this=0x117b3cf3f58 id=0
dtor. this=0x117b3cf3f28 id=0
Foo operator delete[](), pdead=0x117b3cf3f20 size=248

因为将析构函数标注为virtual,那么就会有vpt,虚指针表,相当于增加了一个指针的大小,在我的本机64位系统上占8个字节。

当然也可以直接使用全局的::new和::delete,从而跳过重写的对应方法:

std::cout << "sizeof(Foo)=" << sizeof(Foo) << std::endl;

Foo *p = ::new Foo(7);
::delete p;

Foo *pArray = ::new Foo[5];
::delete[] pArray;

输出结果如下,可以发现重写的那些方法被绕过了,没有调用。

sizeof(Foo)=48
ctor. this=0x2161e9f1d70 id=7
dtor. this=0x2161e9f1d70 id=7
default ctor. this=0x2161e9f3e58 id=0
default ctor. this=0x2161e9f3e88 id=0
default ctor. this=0x2161e9f3eb8 id=0
default ctor. this=0x2161e9f3ee8 id=0
default ctor. this=0x2161e9f3f18 id=0
dtor. this=0x2161e9f3f18 id=0
dtor. this=0x2161e9f3ee8 id=0
dtor. this=0x2161e9f3eb8 id=0
dtor. this=0x2161e9f3e88 id=0
dtor. this=0x2161e9f3e58 id=0

 

分配器allocator


在容器中,允许提供自定义的内存管理功能,也就是自定义的分配器allocator,这个有专门的主题介绍,这里不展开。

 

简单的内存管理


每次调用malloc,系统都会分配一块内存,这块内存上下都带有cookie,这些都是系统用来解决碎片化进行内存合并时需要用到的信息。但这些额外的空间,对应用程序来说就是浪费。尽管malloc已经很快,但如果能避免调用malloc,也能提升效率。

所以我们可以对new和delete进行重载,当new的时候,一次性调用malloc分配一大块空间,这一大块空间上下都带有cookie,然后将这一大块内存空间切成一小块,并用链表串起来,然后返回其中的一小块,这一小块的内存空间就不带有cookie,而且因为只是简单的涉及到指针的移动,没有调用malloc,所以速度很快。当调用delete的时候,将指针放到链表中即表示将内存块还回到首次分配的大块空间中,也不必要调用delete释放内存。

Per-class allocator 1


在《C++ Primer》第3版中有一个例子(好奇的是第5版为什么删掉了?)演示了一个简单的内存管理。

class Screen
{
private:
    int i;
    Screen *next;
    static Screen *freeStore;
    static const int screenChunk;

public:
    Screen(int x) : i(x) {}
    int get() { return i; }
    void *operator new(size_t size);
    void operator delete(void *ptr, size_t size);
};
const int Screen::screenChunk = 24;
Screen *Screen::freeStore = nullptr;

void *Screen::operator new(size_t size)
{
    Screen *p;
    if (!freeStore)
    {
        size_t chunk = screenChunk * size;
        freeStore = p = reinterpret_cast<Screen *>(new char[chunk]);
        for (; p != &freeStore[screenChunk - 1]; ++p)
        {
            p->next = p + 1;
        }
        p->next = nullptr;
    }
    p = freeStore;
    freeStore = p->next;
    return p;
}

void Screen::operator delete(void *ptr, size_t t)
{
    (static_cast<Screen *>(ptr))->next = freeStore;
    freeStore = (static_cast<Screen *>(ptr));
}

可以看到,首次分配24个对象大小的空间,然后将这些空间分割成24块,串成一个链表。使用freeStore指向空闲的列表头。当归还时,将地址归还到链表头部。

测试代码如下:

void test()
{
    std::cout << "size Screen:" << sizeof(Screen) << std::endl;
    size_t const N = 100;
    Screen *p[N];
    for (int i = 0; i < N; ++i)
    {
        p[i] = new Screen(i);
    }

    for (int i = 0; i < 10; ++i)
    {
        std::cout << p[i] << std::endl;
    }

    for (int i = 0; i < N; ++i)
    {
        delete p[i];
    }
}

输出结果如下:

size Screen:16
0x2194d6e3e50
0x2194d6e3e60
0x2194d6e3e70
0x2194d6e3e80
0x2194d6e3e90
0x2194d6e3ea0
0x2194d6e3eb0
0x2194d6e3ec0
0x2194d6e3ed0
0x2194d6e3ee0

可以看到每个对象大小为16(里面包含1个int和一个next指针)。打印了前10个元素的地址,发现地址的间隔就是对象大小16。说明这些内存前后没有cookie。

如果将test代码里面的new和delet改成全局的::new和::delete,打印结果如下:

size Screen:16
0x24013dd1d70
0x24013dd1940
0x24013dd1980
0x24013dd3e50
0x24013dd3e90
0x24013dd3ed0
0x24013dd3f10
0x24013dd3f50
0x24013dd3f90
0x24013dd3fd0

可以看到,大小仍然是16,但是对象之间的间隔至少变为了4*16=64个字节(注意看第2和3之间, 4和5、6、7、8、9之间的间隔,由于内存分配器会找能容得下分配对象大小的内存地址,所以不保证所有的元素都是紧挨以64个字节相隔,但至少相隔64个字节)。

这个简单的内存分配器,缺点就是增加了一个指向本身的指针对象,这个指针对象占用了内存空间。下面的版本对这一情况进行了改进。

Per-class allocator 2


下面这个例子来自《Effective C++》第二版的Item10(第三版中没有了😂)。

class Airplane
{
private:
    struct AirplaneRep
    {
        unsigned long miles;
        char type;
    };
    union
    {
        AirplaneRep rep;
        Airplane *next;
    };

public:
    unsigned long getMiles() { return rep.miles; }
    char getType() { return rep.type; }
    void set(unsigned long m, char t)
    {
        rep.miles = m;
        rep.type = t;
    }
    static void *operator new(size_t size);
    static void operator delete(void *deadObject, size_t size);

private:
    static const int BLOCK_SIZE;
    static Airplane *headOfFreeList;
};

Airplane *Airplane::headOfFreeList;
const int Airplane::BLOCK_SIZE = 512;

void *Airplane::operator new(size_t size)
{
    // 发生继承时,大小会不一致
    if (size != sizeof(Airplane))
    {
        return ::operator new(size);
    }

    Airplane *p = headOfFreeList;
    // 如果p有效,则移动到下一个赋值给headOfFreeList
    if (p)
    {
        headOfFreeList = p->next;
    }
    else
    {
        Airplane *newBlock = static_cast<Airplane *>(::operator new(BLOCK_SIZE * sizeof(Airplane)));
        for (int i = 1; i < BLOCK_SIZE; ++i)
        {
            newBlock[i].next = &newBlock[i + 1];
        }
        newBlock[BLOCK_SIZE - 1].next = nullptr;
        p = newBlock;
        headOfFreeList = &newBlock[1];
    }
    return p;
}

void Airplane::operator delete(void *deadObj, size_t size)
{
    if (deadObj == nullptr)
        return;
    if (size != sizeof(Airplane))
    {
        ::operator delete(deadObj);
        return;
    }
    Airplane *carcass = static_cast<Airplane *>(deadObj);
    carcass->next = headOfFreeList;
    headOfFreeList = carcass;
}

基本思路跟前面一样,只是这里使用了union将指针和代表私有字段的结构体放在了一起,这样能够节省内存。这种就是嵌入式指针的概念。这个next指针指向下一个空闲的Airplan对象的地址,同时Airplan类型的next指针和AirplanRep类型的Rep占用同一块内存,也就是说它们只能存在其一。当对象在内存池中时,只有next有用,保存着下一个空闲对象的地址。当从内存池中取出来时,此时next指针已经没有用处了,这时Rep是真正有用的字段。当归还到内存池时,又将next赋值为headofFreeList,即将内存池中空闲对象列表的头指针赋值给待归还对象的next指针。

运行一下代码:

void test()
{
    std::cout << "size Airplane:" << sizeof(Airplane) << std::endl;
    size_t const N = 100;
    Airplane *p[N];
    for (int i = 0; i < N; ++i)
    {
        p[i] = new Airplane;
    }
    p[1]->set(1000, 'A');
    p[5]->set(2000, 'B');
    p[9]->set(500000, 'C');
    std::cout << p[1] << ' ' << p[1]->getType() << ' ' << p[1]->getMiles() << std::endl;
    std::cout << p[5] << ' ' << p[5]->getType() << ' ' << p[5]->getMiles() << std::endl;
    std::cout << p[9] << ' ' << p[9]->getType() << ' ' << p[9]->getMiles() << std::endl;
    for (int i = 0; i < 10; ++i)
    {
        std::cout << p[i] << std::endl;
    }

    for (int i = 0; i < N; ++i)
    {
        delete p[i];
    }
}

输出结果如下:

size Airplane:8
0x1dabd153e58 A 1000
0x1dabd153e78 B 2000
0x1dabd153e98 C 500000
0x1dabd153e50
0x1dabd153e58
0x1dabd153e60
0x1dabd153e68
0x1dabd153e70
0x1dabd153e78
0x1dabd153e80
0x1dabd153e88
0x1dabd153e90
0x1dabd153e98

可以看到,Airplance的大小为8,每个元素之间的间隔也为8,所以每个元素前后没有cookie。如果将test里面的new和delete改为全局调用::new和::delete,则运行结果为:

size Airplane:8
0x20e80b51940 A 1000
0x20e80b53ed0 B 2000
0x20e80b53fd0 C 500000
0x20e80b51d70
0x20e80b51940
0x20e80b51980
0x20e80b53e50
0x20e80b53e90
0x20e80b53ed0
0x20e80b53f10
0x20e80b53f50
0x20e80b53f90
0x20e80b53fd0

可以看到,现在每个对象之间间隔4*16=64个字节。

和前面的例子一样,这里的缺点是,使用了operator new分配内存,但在释放的时候,只是把内存地址放在了链表中,并没有真正释放,所以空闲列表的长度会有个峰值。这个空闲列表所挂的内存,并不会还给操作系统。

上面两个例子,还有个缺点是,这些简单的内存池,都是在每个类里面定义的,所以如果新增一个类,也需要类似的内存管理,则所有的相似代码需要重写一份,这不符合软件开发的DRY的原则。所以接下来就是第三个例子,静态分配器。

Static Allocator


上面两个例子的分配器都是写在各自的类里,不具有共用性,所以可以把这部分相似的逻辑抽取出来,放到一个单独的class中,这样就可以共用了。

class allocator
{
private:
    struct obj
    {
        struct obj *next; // embedded pointer
    };

public:
    void *allocate(size_t size);
    void deallocate(void *deadObject, size_t t);
    void check();

private:
    obj *freeStore = nullptr;
    const int CHUNK = 5;
};

void *allocator::allocate(size_t size)
{
    obj *p;
    if (!freeStore)
    {
        size_t chunk = CHUNK * size;
        freeStore = p = (obj *)malloc(chunk);
        for (int i = 0; i < (CHUNK - 1); ++i)
        {
            p->next = (obj *)((char *)p + size);
            p = p->next;
        }
        p->next = nullptr;
    }
    p = freeStore;
    freeStore = p->next;
    return p;
}

void allocator::deallocate(void *deadObject, size_t size)
{
    ((obj *)deadObject)->next = freeStore;
    freeStore = ((obj *)deadObject);
}

void allocator::check()
{
    obj *p = freeStore;
    int count = 0;

    while (p)
    {
        std::cout << p << std::endl;
        p = p->next;
        count++;
    }
    std::cout << count << std::endl;
}

现在,要让一个类具有内存管理功能,只需要类似如下,将allocator作为一个静态成员变量即可:

class Foo
{
public:
    long L;
    std::string str;
    static allocator myAlloc;

public:
    Foo(long i) : L(i) {}
    void *operator new(size_t size)
    {
        return myAlloc.allocate(size);
    }

    void operator delete(void *ptr, size_t size)
    {
        myAlloc.deallocate(ptr, size);
    }
};
allocator Foo::myAlloc;

再写一个:

class Goo
{
public:
    std::complex<double> c;
    std::string str;
    static allocator myAlloc;

public:
    Goo(const std::complex<double> &x) : c(x) {}
    static void *operator new(size_t size)
    {
        return myAlloc.allocate(size);
    }
    static void operator delete(void *pdead, size_t size)
    {
        return myAlloc.deallocate(pdead, size);
    }
};
allocator Goo::myAlloc;

然后编写如下测试代码:

void test_static_allocator()
{
    std::cout << "\n\n\ntest_static_allocator().......... \n";
    {
        Foo *p[100];

        std::cout << "sizeof(Foo)= " << sizeof(Foo) << std::endl;
        for (int i = 0; i < 23; ++i)
        { // 23,任意數, 隨意看看結果
            p[i] = new Foo(i);
            std::cout << p[i] << ' ' << p[i]->L << std::endl;
        }
        // Foo::myAlloc.check();

        for (int i = 0; i < 23; ++i)
        {
            delete p[i];
        }
        // Foo::myAlloc.check();
    }

    {
        Goo *p[100];

        std::cout << "sizeof(Goo)= " << sizeof(Goo) << std::endl;
        for (int i = 0; i < 17; ++i)
        { // 17,任意數, 隨意看看結果
            p[i] = new Goo(std::complex<double>(i, i));
            std::cout << p[i] << ' ' << p[i]->c << std::endl;
        }
        // Goo::myAlloc.check();

        for (int i = 0; i < 17; ++i)
        {
            delete p[i];
        }
        // Goo::myAlloc.check();
    }
}

输出结果为:

test_static_allocator()..........
sizeof(Foo)= 40
0x1f0d8bf3e50 0
0x1f0d8bf3e78 1
0x1f0d8bf3ea0 2
0x1f0d8bf3ec8 3
0x1f0d8bf3ef0 4
0x1f0d8bf3f50 5
0x1f0d8bf3f78 6
0x1f0d8bf3fa0 7
0x1f0d8bf3fc8 8
0x1f0d8bf3ff0 9
0x1f0d8bf4050 10
0x1f0d8bf4078 11
0x1f0d8bf40a0 12
0x1f0d8bf40c8 13
0x1f0d8bf40f0 14
0x1f0d8bf4150 15
0x1f0d8bf4178 16
0x1f0d8bf41a0 17
0x1f0d8bf41c8 18
0x1f0d8bf41f0 19
0x1f0d8bf4250 20
0x1f0d8bf4278 21
0x1f0d8bf42a0 22
sizeof(Goo)= 48
0x1f0d8bf4350 (0,0)
0x1f0d8bf4380 (1,1)
0x1f0d8bf43b0 (2,2)
0x1f0d8bf43e0 (3,3)
0x1f0d8bf4410 (4,4)
0x1f0d8bf4470 (5,5)
0x1f0d8bf44a0 (6,6)
0x1f0d8bf44d0 (7,7)
0x1f0d8bf4500 (8,8)
0x1f0d8bf4530 (9,9)
0x1f0d8bf4720 (10,10)
0x1f0d8bf4750 (11,11)
0x1f0d8bf4780 (12,12)
0x1f0d8bf47b0 (13,13)
0x1f0d8bf47e0 (14,14)
0x1f0d8bf4840 (15,15)
0x1f0d8bf4870 (16,16)

可以看到,这两个类型Foo和Goo的对象分配之间的间隔,跟对象大小是一致的。

Macro for static allocator


前面的Foo和Goo的代码看起来重复的地方也很多,在Foo和Goo的内部,跟对象Foo看起来没有关系,只是在外部定义的时候使用到了类的名称。所以这部分的重复代码可以使用macro宏来事先定义。

#define DECLARE_POOL_ALLOCATOR()                                                                   \
public:                                                                                            \
    void *operator new(size_t size) { return myAlloc.allocate(size); }                             \
    void operator delete(void *deadobj, size_t size) { return myAlloc.deallocate(deadobj, size); } \
                                                                                                   \
protected:                                                                                         \
    static allocator myAlloc;

#define IMPLEMENT_POOL_ALLOCATOR(class_name) \
    allocator class_name::myAlloc;

现在Foo和Goo的写法可以改写为:

class Foo
{
    DECLARE_POOL_ALLOCATOR();

public:
    long L;
    std::string str;
    Foo(long l) : L(l) {}
};
IMPLEMENT_POOL_ALLOCATOR(Foo);

class Goo
{
    DECLARE_POOL_ALLOCATOR();

public:
    std::complex<double> c;
    std::string str;
    Goo(const std::complex<double> &x) : c(x) {}
};
IMPLEMENT_POOL_ALLOCATOR(Goo);

简单了很多。测试代码如下:

void test_macros_for_static_allocator()
{
    std::cout << "\n\n\ntest_macro_for_static_allocator().......... \n";

    Foo *pF[100];
    Goo *pG[100];

    std::cout << "sizeof(Foo)= " << sizeof(Foo) << std::endl;
    std::cout << "sizeof(Goo)= " << sizeof(Goo) << std::endl;

    for (int i = 0; i < 23; ++i)
    { // 23,任意數, 隨意看看結果
        pF[i] = new Foo(i);
        pG[i] = new Goo(std::complex<double>(i, i));
        std::cout << pF[i] << ' ' << pF[i]->L << "\t";
        std::cout << pG[i] << ' ' << pG[i]->c << "\n";
    }

    for (int i = 0; i < 23; ++i)
    {
        delete pF[i];
        delete pG[i];
    }
}

输出结果如下:

test_macro_for_static_allocator().......... 
sizeof(Foo)= 40
sizeof(Goo)= 48
0x2943f853f20 0        0x2943f854020 (0,0)
0x2943f853f48 1        0x2943f854050 (1,1)
0x2943f853f70 2        0x2943f854080 (2,2)
0x2943f853f98 3        0x2943f8540b0 (3,3)
0x2943f853fc0 4        0x2943f8540e0 (4,4)
0x2943f854140 5        0x2943f854460 (5,5)
0x2943f854168 6        0x2943f854490 (6,6)
0x2943f854190 7        0x2943f8544c0 (7,7)
0x2943f8541b8 8        0x2943f8544f0 (8,8)
0x2943f8541e0 9        0x2943f854520 (9,9)
0x2943f854580 10        0x2943f854680 (10,10)
0x2943f8545a8 11        0x2943f8546b0 (11,11)
0x2943f8545d0 12        0x2943f8546e0 (12,12)
0x2943f8545f8 13        0x2943f854710 (13,13)
0x2943f854620 14        0x2943f854740 (14,14)
0x2943f8547a0 15        0x2943f8548a0 (15,15)
0x2943f8547c8 16        0x2943f8548d0 (16,16)
0x2943f8547f0 17        0x2943f854900 (17,17)
0x2943f854818 18        0x2943f854930 (18,18)
0x2943f854840 19        0x2943f854960 (19,19)
0x2943f8549c0 20        0x2943f854ac0 (20,20)
0x2943f8549e8 21        0x2943f854af0 (21,21)
0x2943f854a10 22        0x2943f854b20 (22,22)

左侧为Foo对象,右侧为Goo对象,可以观察发现,它们各自之间的内存地址相聚分别为40和48,也就是对象的大小。这也说明这些对象之间没有cookie。

总结


本文首先介绍了C++中的各种基本内存分配的方式,这些方式包括new、delete,new[]、delete[]、定点new,operator new、operator delete,allocator等等,并介绍了它们之间的相互调用关系。在此基础上介绍了如何重载operator new和operator delete以接管内存的分配和释放。

由于原始的内存分配方法会在每一块内存地址的上下都会加上cookie,这会增加实际额外的内存分配。使用池化技术,在首次分配对象时,只请求一次malloc,分配一大块内存,然后将这一大块内存切成多个小块,然后用链表把这些小块内存串起来,当需要内存的时候,直接返回该链表上的这一小块即可,不再去调用malloc,这既增加了效率,也节省了额外的上下两个cookie所占用的内存空间。

如何编写这种单链表的简单的内存池管理,文中介绍了几种方式,包括在每个对象内部使用指针指向本身的实现、将字段放到Implement结构体并和指针一起放在union里的方式减少内存占用、将分配逻辑提出出来做成一个静态的对象的方式以及最后使用宏的方式进一步减少重复代码。