内存管理

前言

内存管理好有趣!

new/detele expression

class A
{
public:
    int id;

    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;}
};


void test01()
{
    int size=3;

    A* buf=new A[size];

    A* tmp=buf;

    for(int i=0;i<size;i++)
    {
        new(tmp++)A(i);
    }

    delete[] buf;
}

default ctor. this=0x55555556b028 id=0
default ctor. this=0x55555556b02c id=0
default ctor. this=0x55555556b030 id=0
ctor. this=0x55555556b028 id=0
ctor. this=0x55555556b02c id=1
ctor. this=0x55555556b030 id=2
dtor. this=0x55555556b030 id=2
dtor. this=0x55555556b02c id=1
dtor. this=0x55555556b028 id=0

对于 new[] 的操作,当前会调用对象的默认构造函数。而之后的 new(tmp++)A(i) 作为 Placement new,不会重新分配一块内存,而是在之前获得的基础上进行构造。这里需要注意,此时的 Placement new 会在原来对象的基础上原地替换,如果是复杂类的话需要先对源对象进行析构,再在这块内存上重新构造。

void test02()
{
    void* mem=::operator new(sizeof(A));

    A* ptr=new(mem)A(10);

    ptr->~A();

    ::operator delete(mem);
}

对于 Placement new,其可以在一块已经获得的内存上进行构造。这样理论上可以通过一块已经申请好的内存反复使用,减小每次动态分配的开销。这里需要注意,::new 就是强制使用全局的 new 而不是重载后的,其功能和普通的 new 一样,既负责分配内存也负责构造函数。而 ::operator new 调用的是全局内存分配函数,其不负责构造对象。

而对于 Placement delete,其只有当对应的 Placement new 抛出了异常才会被调用起来。

Per-class allocator 类专属内存分配器

这种分配器的核心思想是,提前申请一大块内存,然后将其切分成多个固定大小的块,中间通过单向链表管理空闲内存,从而减少每次分配的开销。

#include<cstddef>

class Screen
{
public:

    Screen(int x):i(x){}

    void* operator new(size_t size)
    {
        Screen* p;
        if(freeStore==nullptr)
        {
            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=freeStore->next;
        return p;
    }

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

private:
    Screen* next;

    static Screen* freeStore;
    static const int screenChunk;

private:
    int i;
};
Screen* Screen::freeStore=nullptr;
const int Screen::screenChunk=24;

大体框架就是这样,在 new 的时候若 freeStore 为空表示无可用内存了,那么就重新申请一大块内存,然后不断往后跳进行连接,每次返回空间链表的头节点即可。在 delete 的时候直接把销毁对象的内存放到链表的头节点即可。

注意 reinterpret_cast 和 static_cast 的区别是,前者是底层的指针转换,后者是常规类型转换。这里由于 char* 不能直接转到 Screen*,所以不能使用 static_cast。如果要用就需要先转成 void* 后再转。

当然,这个版本的内存池没有严格处理对象生命周期问题,在还没进行构造时就访问其成员变量 Screen*,而且析构后仍通过指针对其修改。而且内存池也没有记录每批内存的原始地址,也缺少最终的释放机制。除此之外,额外添加的这个 Screen* 指针也会增加内存占用。

struct ScreenRep
{
	int i;
};

union{
	ScreenRep rep;
	Screen* next;
};

对于后者问题,这里考虑使用匿名 union 让这块内存既可以保留成员数据也可以直接当做指针解释,可以对这块内存进行复用。此时就需要注意内存对齐的问题,现代 C++ 可以使用 alignas(64) 来手动调整内存对齐。

static allocator

class Foo
{
public:
	...
	static allocator myAlloc;
};
allocator Foo::myAlloc;

为了避免每个类都实现一个 Per-class allocator,所以可以单独把分配器给提出来。

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

class Foo
{
    DECLARE_POOL_ALLOC()
};
IMPLEMENT_POOL_ALLOC(Foo)

由于这个写法和类无关,所以可以考虑通过宏定义进一步简化。

new handler

当没有内存可以分配的时候,此时就会调用 new handler 尝试释放掉一些不影响程序的内存,之后就会回去重新尝试分配内存。若始终无法分配,最终就会抛出异常。

__gnu_cxx::__pool_alloc 分配器

template<typename Alloc>
void cookie_test(Alloc alloc,size_t n)
{
    typename Alloc::value_type *p1,*p2,*p3;

    p1=alloc.allocate(n);
    p2=alloc.allocate(n);
    p3=alloc.allocate(n);

    std::cout<<"p1="<<p1<<std::endl;
    std::cout<<"p2="<<p2<<std::endl;
    std::cout<<"p3="<<p3<<std::endl;

    alloc.deallocate(p1,n);
    alloc.deallocate(p2,n);
    alloc.deallocate(p3,n);
}

cookie_test(__gnu_cxx::__pool_alloc<int>(),1);
cookie_test(std::allocator<int>(),1);
p1=0x55555556c020
p2=0x55555556c028
p3=0x55555556c030
p1=0x55555556c580
p2=0x55555556c5a0
p3=0x55555556c5c0

首先,对于 __gnu_cxx::__pool_alloc 分配器,可以发现其每次申请的内存都是恰好的 8 的倍数,是不含有用来记录块大小等信息的 Metadata 的。其内部会把小块内存按 8 字节为单位划分出 Size Class,每个都是 8 的倍数大小,使用的时候向上取整拿即可。

__default_alloc_template 分配器

std::alloc 分配器的底层原理就是之前提过的二级空间配置器,但细节上稍有改进。

当要申请的 free list 不够时,此时就会申请一大块内存,然后再做切割。但此时不会把整块内存全切割了,会剩下部分内存维持整块的形态。等到万一别的 free list 不够的时候,此时就不需要再申请了,直接拿之前剩下的切割即可。而为了进一步减少申请的次数,每次申请一大块后都会附带一大块空闲内存作为准备,然后每次增加这个准备内存大小。

当剩余的内存不够开出当前需要的一块时,此时选择把剩下的这块内存挂到恰好大小的 free list 上。这里由于每次操作都是 8 的倍数,所以必然存在一个 free list 的一块恰好就是当前剩下的内存大小。

当无法再申请内存的时候,此时就会看右侧的 free list 里有没有空闲的内存,有的话就把这些空闲的哪来用。而当一直找到最右侧都没有了,那么会交给一级分配器处理。

Loki Allocator

首先,这个 Allocator 包含三个类,从底到顶分别是 Chunk、FixedAllocator 和 SmallObjAllocator。其中 Chunk 负责维护一大块内存,同时维护即将分配的编号以及还剩几块可用;FixedAllocator 通过维护一个 vector<Chunk> 来维护多块内存;而再往上的 SmallObjAllocator 又维护了 vector<FixedAllocator>。

相比之前的分配器,Loki Allocator 的优势是可以释放申请的大块内存。但与此同时,其每次遍历和小块的定位也带来了新的开销。

Chunk

对于 Chunk,首先在 new 出一大块内存后,此时即将分配的编号就是 0 号,剩余的块数就是一开始申请的块数。之后,和之前的链表不同,Chunk 是通过给每小块内存的第一个字节分配一个 unsigned char 编号来维护。此时由于不存在分散的小块了,那么在释放的时候直接释放指向这一大块的指针即可。

之后,当申请一小块的时候,此时可以通过即将分配的编号和块大小,配合指向这一大块的指针,就可以索引到对应的小块。之后由于这一小块即将分配出去,那么就会将下一次即将分配的编号更新为这一小块的 unsigned char 编号。这个可以理解为通过维护一个 1 到块数的排列数组来模拟指针的功能。

当释放时,只需要根据指针找到对应块的编号,然后将当前即将分配的编号写到这一块上,然后再将其改为这一块即可,相当于链表的头插。

FixedAllocator

对于 FixedAllocator,其内部的两个指向 Chunk 的指针维护的就是上一次申请和上一次释放的大块,每次优先考虑这两块。而当大块用完了,此时就会遍历每一个大块看是否有剩余。需要注意的是,当 vector 进行 push_back 发生扩容时,之前的迭代器会失效。此时不仅要重设上一次申请的指针,还要重设上一次释放的指针。

对于释放时找该小块属于那个大块,此时采取的是从上一次释放的大块开始,分别往左往右同时找。这样做虽然复杂度不会改变,但 cache 和常数时间会好一点。那么在归还后,若这一块完全空闲了,此时会选择延缓释放,避免释放后立刻申请的代价。方法也很简单,就是判断是否有大于等于两个完全空闲的块,有的话就释放一个,以此来保证手上一直有一个完全空闲可用的块。

array_allocator

对于 array_allocator,其底部就是一个静态数组,所以也没有释放的问题。这个主要是用于容量已经大体确定好的容器,那么直接开一个够用的静态数组,每次用这个数组的内存即可,不需要动态分配内存了。可以看作直接开足够 n 大小的全局 int a[MAXN],然后根据每个测试的 n 动态使用。

当然,也可以 new 一个数组给它做分配用,最后手动释放掉这个数组即可。

bitmap_allocator

这个分配器就是专攻一次只要一个元素的情况,其底层的原理就是位图。

对于一次申请的内存,其开头会用两个数分别记录整块的大小和已经用掉几小块。然后会通过位图来记录每小块是否分配出去了。对于多个整块,其还要用一个类似 vector 的东西维护。这个 vector 会通过两个指针维护开辟的多个整块开头和结尾,然后一个指针维护 vector 的真实结尾。这个可以理解为 begin()、end() 和 capacity。而对于 vector 里的每个元素,其表示每次申请的整块,那么就还是两根指针分别指向这个整块可分配部分的头和尾。

当有整块回收的时候,其还会有一个类似 vector 的东西来管理完全空闲的整块。对于这个 vector 当超过一定限额后,此时才会把最大的那整块释放给操作系统。

总结

感觉这种数据结构回到 ap 舒适区了()

END

posted @ 2026-11-13 01:52  WBluuue  阅读(9)  评论(0)    收藏  举报