池化技术是将程序中需要经常使用的核心资源
先申请出 来,放到一个池
,由程序源自己管理,这样可以提高资源的使用效率,它可以避免核心资源申请和释放带来的开销,也可以保证本程序占有的资源数量。 经常使用的池化技术包括内存池、线程池和连接池
等。
内存池(Memory Pool)
是一种动态内存分配与管理技术。 通常情况下,我们可以直接使用 new、 delete、malloc、free
等API申请分配和释放内存。这样导致的后果是:当程序长时间运行时,由于所申请内存块的大小不定,频繁使用时会造成大量的内存碎片
从而降低程序和操作系统的性能。内存池则是在真正 使用内存之前,先申请分配一大块内存(内存池)留作备用,当程序员申请内存时,从池中取出一块内存,当程序员释放内存时,将释放的内存再放入池内,再次申请池可以 再取出来使用,并尽量与周边的空闲内存块合并。若内存池不够时,则自动扩大内存池,从操作系统中申请更大的内存池。
造成堆利用率很低的一个主要原因就是内存碎片化
。如果有未使用的存储器,但是这块存储器不能用来满足分配的请求,这时候就会产生内存碎片化
问题。内存碎片化分为内部碎片和外部碎片
。
内部碎片是指一个已分配的块比有效载荷
大时发生的。(假设以前分配了10
个大小的字节,现在只用了5
个字节,则剩下的5
个字节就会内碎片
)。内部碎片的大小就是已经分配的块的大小和他们的有效载荷之差的和。因此内部碎片取决于以前请求内存的模式和分配器实现(对齐的规则)的模式。
外部碎片就是当空闲的存储器
的总和足够满足一个分配请求,但是没有一个单独的空闲块足够大可以处理这个请求。外部碎片取决于以前的请求内存的模式和分配器的实现模式,还取决于将来的内存请求模式。所以外部碎片难以量化。
一个链表指向空闲内存
,分配就是遍历找到一块大小和它一致或者是比它大一些的,取出一块来,然后在修改链表,将剩余的空间挂回到链表中。释放就是放回到链表里面。注意做好标记和保护,避免二次释放,还可以优化如何查找适合大小的内存快的搜索上,减少内存碎片,但是可以增加内存池的外碎片。
优点 :实现简单
缺点:分配时搜索合适的内存块效率低,释放回归内存后归并比较消耗大,实际中不实用。
实现一个 FreeList
,这个自由链表用于分配固定大小
的内存块,比如用于分配 32字节
对象的固定内存分配器。每个内存分配器里面有两个链表。OpenList
用于存储未分配的空闲对象,CloseList
用于存储已分配的内存对象。所谓的分配就是从 OpenList 中取出一个对象放到 CloseList 里并且返回给用户, 释放又是从 CloseList 移回到 OpenList。 分配时内存如果不够,那么就需要增长OpenList
,向系统申请一个更大一点的内存块,切割成相同大小的对象添加到 OpenList
中。这个固定内存分配器回收的时候,统一把先前向系统申请的内存块全部还给系统
。
优点:简单。分配和释放的效率高,解决实际中特定场景下的问题有效。
缺点:功能单一。只能解决定长的内存需求,另外占着内存没有释放。
关于内存池内存不够的情况,应该继续想系统去申请:
在定长分配器的基础上,按照不同对象大小(8,16,32,64,128,256,512,1k…64K),构造十多个固定内存分配器,分配内存时根据要申请内存大小进行对齐然后查H表,决定到底由哪个分配器负责,分配后要在内存头部的 header 处写上 cookie,表示由该块内存哪一个分配器分配的,这样释放时候你才能正确归还。如果大于64K,则直接用系统的 malloc作为分配,如此以浪费内存为代价你得到了一个分配时间近似O(1)的内存分配器。这种内存池的缺点是假设某个 FreeList 如果高峰期占用了大量内存即使后面不用,也无法支援到其他内存不够的 FreeList,达不到分配均衡的效果。
优点: :本质是定长内存池的改进,分配和释放的效率高。可以解决一定长度内存分配的问题。
缺点 :存在内碎片的问题,且将一块大内存切小以后,申请大内存无法使用,别的FreeList挂了很多空闲的内存块而分配不到,但是其他的FreeList缺不够分配。在多线程并发场景下,可能会导致线程安全的问题,可以通过加锁解决,但是锁竞争激烈,申请释放效率会降低。
关于STL空间配置器参考:
https://blog.csdn.net/LF_2016/article/details/53511648
关于malloc底层:https://blog.csdn.net/hudazhe/article/details/79535220
malloc优点: 使用自由链表的数组,提高分配释放效率;减少内存碎片,可以合并空闲的内存(根据脚步)
malloc缺点: 为了维护隐式/显示链表需要维护一些信息,空间利用率不高;在多线程的情况下,会出现线程安全的问题,如果以加锁的方式解决,会大大降低效率。
现在大部分的开发环境都是多核多线程,在申请内存的场景下,必然存在激烈的锁竞争问题。要实现一个高并发的内存池,必须要考虑以下几个问题:
线程
独有的,用于小于64k
的内存的分配,线程从这里申请内存不需要加锁,每个线程独享一个cache,这也就是这个并发线程池高效的地方。thread cache
中的对象,避免一个线程占用了太多的内存,而其他线程的内存吃紧。达到内存分配在多个线程中更均衡的按需调度
的目的。Central cache是存在竞争的,所以从这里取内存对象是需要加锁
。页
为单位存储及分配 的,Central cache没有内存对象(Span)
时,从Page cache分配出一定数量的page,并切割成定长大小的小块内存,分配给Central cache。Page cache
会回收Central cache满足条件的Span(使用计数为0)
对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。注:怎么实现每个线程都拥有自己唯一的线程缓存呢?
为了避免加锁带来的效率,在Thread Cache中使用thread local storage
保存每个线程本地的ThreadCache
的指针,这样Thread Cache
在申请释放内存是不需要锁的。因为每一个线程都拥有了自己唯一的一个全局变量。
class ThreadCache
{
public:
//分配内存
void* Allocate(size_t size);
//释放内存
void Deallocate(void* ptr, size_t size);
//从中心缓存中获取内存对象
void* FetchFromCentralCache(size_t index, size_t size);
//当自由链表中的对象超过一次分配给threadcache的数量,则开始回收
void ListTooLong(FreeList* freelist, size_t byte);
private:
FreeList _freelist[NLISTS];// 创建了一个自由链表数组
};
关于FreeList这个类,我们只要封装一个普通指针和链表的长度即可。
Thread Cache申请内存:
size<=64k
时在thread cache中申请内存,先计算size在自由链表中的位置,如果自由链表中有内存对象时,直接从FistList[i]
中Pop然后返回对象,时间复杂度是O(1),并且没有锁竞争,效率极高。 当FreeList[i]中没有对象时,则批量从Central cache中获取一定数量
的对象,剩余的n-1个对象插入到自由链表并返回一 个对象。Thread Cache释放内存:
小于64k
时将内存释放回thread cache,先计算size在自由链表中的位置,然后将对象Push到 FreeList[i] // 控制内碎片浪费不要太大
//[1, 128] 8byte对齐 freelist[0,16)
//[129, 1024] 16byte对齐 freelist[17, 72)
//[1025, 8 * 1024] 64byte对齐 freelist[72, 128)
//[8 * 1024 + 1, 64 * 1024] 512byte对齐 freelist[128, 240)
// 也就是说对于自由链表数组只需要开辟240个空间就可以了
// 大小类
class ClassSize
{
public:
// align是对齐数
static inline size_t _RoundUp(size_t size, size_t align)
{
// 比如size是15 < 128,对齐数align是8,那么要进行向上取整,
// ((15 + 7) / 8) * 8就可以了
// 这个式子就是将(align - 1)加上去,这样的话就可以进一个对齐数
// 然后再将加上去的二进制的低三位设置为0,也就是向上取整了
// 15 + 7 = 22 : 10110 (16 + 4 + 2)
// 7 : 111 ~7 : 000
// 22 & ~7 : 10000 (16)就达到了向上取整的效果
return (size + align - 1) & ~(align - 1);
}
// 向上取整
static inline size_t RoundUp(size_t size)
{
assert(size <= MAXBYTES);
if (size <= 128)
{
return _RoundUp(size, 8);
}
if (size <= 8 * 128)
{
return _RoundUp(size, 16);
}
if (size <= 8 * 1024)
{
return _RoundUp(size, 128);
}
if (size <= 64 * 1024)
{
return _RoundUp(size, 512);
}
else
{
return -1;
}
}
//求出在该区间的第几个
static size_t _Index(size_t bytes, size_t align_shift)
{
//对于(1 << align_sjift)相当于求出对齐数
//给bytes加上对齐数减一也就是,让其可以跨越到下一个自由链表的数组的元素中
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
//获取自由链表的下标
static inline size_t Index(size_t bytes)
{
//开辟的字节数,必须小于可以开辟的最大的字节数
assert(bytes < MAXBYTES);
//每个对齐区间中,有着多少条自由链表
static int group_array[4] = { 16, 56, 56, 112 };
if (bytes <= 128)
{
return _Index(bytes, 3);
}
else if (bytes <= 1024) //(8 * 128)
{
return _Index(bytes - 128, 4) + group_array[0];
}
else if (bytes <= 4096) //(8 * 8 * 128)
{
return _Index(bytes - 1024, 7) + group_array[1] + group_array[0];
}
else if (bytes <= 8 * 128)
{
return _Index(bytes - 4096, 9) + group_array[2] + group_array[1] + group_array[0];
}
else
{
return -1;
}
}
};
// span结构
// 对于span是为了对于thread cache还回来的内存进行管理
// 一个span中包含了内存块
typedef size_t PageID;
struct Span
{
PageID _pageid = 0; //起始页号(一个span包含多个页)
size_t _npage = 0; //页的数量
Span* _next = nullptr; // 维护双向span链表
Span* _prev = nullptr;
void* _objlist = nullptr; //对象自由链表
size_t _objsize = 0; //记录该span上的内存块的大小
size_t _usecount = 0; //使用计数
};
关于spanlist,设计为一个双向链表,插入删除效率较高:
class SpanList
{
public:
// 双向循环带头结点链表
SpanList()
{
_head = new Span;
_head->_next = _head;
_head->_prev = _head;
}
Span* begin()
{
return _head->_next;
}
Span* end()
{
return _head;
}
bool Empty()
{
return _head == _head->_next;
}
void Insert(Span* cur, Span* newspan)
{
assert(cur);
Span* prev = cur->_prev;
//prev newspan cur
prev->_next = newspan;
newspan->_prev = prev;
newspan->_next = cur;
cur->_prev = newspan;
}
void Erase(Span* cur)
{
assert(cur != nullptr && cur != _head);
Span* prev = cur->_prev;
Span* next = cur->_next;
prev->_next = next;
next->_prev = prev;
}
void PushBack(Span* cur)
{
Insert(end(), cur);
}
void PopBack()
{
Span* span = end();
Erase(span);
}
void PushFront(Span* cur)
{
Insert(begin(), cur);
}
Span* PopFront()
{
Span* span = begin();
Erase(span);
return span;
}
// 给每一个Spanlist桶加锁
std::mutex _mtx;
private:
Span * _head = nullptr;
};
Central Cache申请内存:
Central cache
申请一定数量的内存对象,Central cache也是一个哈希映射的Spanlist,Spanlist中挂着span
,从span中取出对象给thread cache,这个过程是需要加锁
的,可能会存在多个线程同时取对象,会导致线程安全的问题。Page cache
申请一个span
对象, span对象中是一些以页
为单位的内存,将这个人span对象切成需要的内存大小并链接起来,最后挂到Central Cache中。use_count
,分配一个对象给thread cache,就++use_count
,当这个span的使用计数为0,说明这个span所有的内存对象都是空闲的,然后将它交给Page Cache
合并成更大的页,减少内存碎片。Central Cache释放内存:
--use_count
。0
时则表示所有对象都回到了span,则将span释放回Page cache
,在Page cache中会对前后相邻的空闲页进行合并。注:怎么才能将Thread Cache中的内存对象还给他原来的span呢?
答:可以在Page Cache中维护一个页号到span的映射,当Span Cache给Central Cache分配一个span时,将这个映射更新到map中去,在Thread Cache还给Central Cache时,可以查这个map找到对应的span。
页
为单位的span自由链表Page cache
,这个类可以被设计成了单例模式// 采用饿汉模式,在main函数之前单例对象已经被创建
class PageCache
{
public:
// 获取单例模式
static PageCache* GetInstance()
{
return &_inst;
}
// 在SpanList中获取一个span对象,如果没有或者申请内存大于128页,则直接去系统申请
Span* NewSpan(size_t npage);
Span* _NewSpan(size_t npage);
// 获取从对象到span的映射
Span* MapObjectToSpan(void* obj);
// 从CentralCache归还span到Page,然后PageCache进行合并
void RelaseToPageCache(Span* span);
private:
// NPAGES是129,最大页数为128,也就是下标从1开始到128分别为1页到128页
SpanList _pagelist[NPAGES];
private:
PageCache() = default;
PageCache(const PageCache&) = delete;
PageCache& operator=(const PageCache&) = delete;
static PageCache _inst;
// 为了锁住SpanList,可能会存在多个线程同时来PageCache申请span
std::mutex _mtx;
std::unordered_map<PageID, Span*> _id_span_map;
};
mmap、brk(Linux)
或者是VirtualAlloc(windows)
等方式申请128page span挂在自由链表中,再重复1中的过程。PageCache释放内存:
page id
的span,看是否可以合并,如果能够合并继续向前寻找。这样就可以将切小的内存合并收缩成大的span,减少内存碎片。但是合并的最大页数超过128页,则不能合并。关于brk参考:https://www.cnblogs.com/vinozly/p/5489138.html
static inline void* SystemAlloc(size_t npage)
{
#ifdef _WIN32
// 从系统申请内存,一次申请128页的内存,这样的话,提高效率,一次申请够不需要频繁申请
void* ptr = VirtualAlloc(NULL, (NPAGES - 1) << PAGE_SHIFT, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (ptr == nullptr)
{
throw std::bad_alloc();
}
return ptr;
#else
#endif //_WIN32
}
static inline void SystemFree(void* ptr)
{
#ifdef _WIN32
VirtualFree(ptr, 0, MEM_RELEASE);
if (ptr == nullptr)
{
throw std::bad_alloc();
}
#else
#endif //_WIN32
}
本项目没有完全脱离malloc和free,需要使用new和delete去创建span来维护从系统申请来的堆内存
解决方案:在项目中增加一个定长的ObjectPool的对象池,对象池的内存直接使用brk、VirarulAlloc等向系统申请,new Span替换成对象池申请内存。这样就完全脱离的malloc,就可以替换掉malloc
平台兼容问题:linux系统下面,需要将VirtualAlloc替换为brk
。
我们每次去申请内存的时候,都是使用new和delete,怎么实现才能每次申请内存的时候使用我们自己实现的内存分配器呢?
解决方案: Linux系统下可以使用了weak alias,相当于替换别名。Windows下可以使用hook钩子技术(不太懂)。
关于hook了解与博客:https://www.cnblogs.com/feng9exe/p/6015910.html
GitHub:https://github.com/hansionz/ConcurrentMemoryPool