内存管理
前言
内存管理好有趣!
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 舒适区了()

内存管理
浙公网安备 33010602011771号