该项目是模仿谷歌的tcmalloc库,例如GoLang上面就有使用。
使用内存池的好处
效率问题: 池化技术即一次申请过量的资源,拿的时候就不用频繁申请了。因为频繁调用malloc,new申请内存空间实际上是比较慢的,如果一次申请大量内存,那么能极大程度提高效率。
缓解内存碎片问题: 内存碎片包括内碎片和外碎片,内碎片在该项目当中可以通过控制每一个内存往多少对齐从而控制。外碎片在该项目的PageCache层能够将小页进行合并,能够一定程度缓解外碎片问题。
项目涉及到的C/C++语言;链表,哈希表等容器;操作系统内存管理模块,多线程,互斥锁;以及单例模式。
开胃菜,定长内存池
定长内存池可以用作对象池,若需要频繁申请某个对象,可以用定长内存池来管理。
什么是自由链表:
原理:
所以定长内存池在只申请固定长度内存时性能达到极致,并且不需要考虑内存碎片的问题。
注意:申请的内存块要是4/8字节,如果小于这个则无法使用这种方法,因为指针在32位是4字节,在64位是8字节!
T* New()
{
T* obj = nullptr;
// 优先把还回来内存块对象,再次重复利用
if (_freeList)
{
void* next = *((void**)_freeList);
obj = (T*)_freeList;
_freeList = next;
}
else
{
// 剩余内存不够一个对象大小时,则重新开大块空间
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;
//_memory = (char*)malloc(_remainBytes);
_memory = (char*)SystemAlloc(_remainBytes >> 13);
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
//需要考虑要的内存没有一个指针大的情况
obj = (T*)_memory;
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize;
_remainBytes -= objSize;
}
// 定位new,显示调用T的构造函数初始化
new(obj)T;
return obj;
}
代码片段讲解:
if(_memory == nullptr)
是一个错误的判断,因为只有第一次_memory会为空,后续即使内存不够开辟一个对象,此时_memory也不是NULL了,所以需要定义一个成员变量记录剩余的字节数,对剩余不足以T的对象空间直接抛弃,后续不会有人使用这块内存空间,不会影响我们的释放和申请逻辑。
所以判断调节变为if(_remainBytes < sizeof(T))
即剩余的内存只要不够就释放。
void Delete(T* obj)
{
// 显示调用析构函数清理对象
obj->~T();
// 头插
*(void**)obj = _freeList;
_freeList = obj;
}
代码片段讲解:
*(void**)obj = _freeList;_freeList = obj;
组成了头插,*(void**)obj
实际上是想取出obj的4/8字节,由于void**是地址,32位4字节,64位8字节,用这种写法可以适配两个平台。
若有内存m1,m2,区分m1 = m2 与 NextObj(m1) = m2
当然,若是要替换malloc,上述new,malloc都是要被替换的:
VirtualAlloc就是windows下申请内存的方法。
Linux需要替换成brk和mmap。
介绍每一层的大致作用,避免后面针对每一层的介绍完后忘记前面的作用~
thread cache是管理小块内存,如上图,0下标的桶后面挂着都是8字节的内存
,是每一个线程都各自私有一个的,他是由线程本地存储(TLS)实现的,thread cache类似有多个定长内存池实现,用户若是需要小于256KB的内存现在这一层寻找,若是能够找到,立马返回,并且这一层的访问由于是每一个线程私有一份,所以不用加锁,效率很高(只涉及单链表的头删)。
如0下标的桶,管理的都是若干页,一个页的大小8KB
,这里的不同字节后面挂着的页是不同的,当下标越大,则桶的页可能会越大。并且central cache的页的内存是已经被切分好若干块的,thread cache申请central cache只能到对应下标处,不能到其它的桶获取内存。
解释thread cache为什么要和central cache申请内存的时候对应
page cache与前两层不同,不同的桶管理的是不同大小的页
,若central cache的桶后挂的内存满足对应的页的大小,就可以往下放,生成一个更大的页。但是由于涉及到大页的合并,所以访问page cache层的时候需要加大锁。
但是不必太过担心效率问题,因为通常来说,分配的逻辑只有合适,到page cache的可能是比较低的。
了解了每一层大致的作用,接下来是对于每一层的详解
在上面的叙述中,我们知道thread cache是类似多个定长内存池,这个实现起来很简单,但是我们要多少个定长内存池,并且间距怎么设置合适其实更值得思考。
假设我们每8个字节都用一个桶映射:
32768个桶
,这个桶数是十分多的,每个桶虽然只有一个指针变量,并且这一种方式的内存浪费可以有效的控制在较低的一个范围。但是我们还是采取梯度对齐的方案。梯度对齐和按8字节的对比
综上,每一个字节都开一个自由链表更加不可能了,所以只能一个梯度设置一个大小,也就是申请的内存可能会给多,但是实际上使用的时候他并不会用的内存的剩余部分,最后没用上的部分称之为内碎片。所以我们需要选择一个合适的方式去对其,保证:桶的数量不会太多,并且空间浪费也处于一个合理的区间。
解决方案:
如何定位到我们需要的桶
[1,128]不纳入考虑,因为这个1即使对齐到2都是浪费了50%,总体上看浪费的空间实际上是10%
下图当中浪费率=浪费的空间/实际的空间,分子这里浪费的空间最多是对齐数-1,分母的最小的时候就是浪费率最高,我们看浪费率最高的场景,那么分别就是1+7,129+15,1025+127,8*1024+1025…算出来除了第一个,浪费率都在10%左右。
桶数就是128/8,(1024-128)/16即【128+1,1024】有56个桶
其中RoundUp函数就是让字节对齐到某个梯度。
void* ThreadCache::Allocate(size_t size)
{
assert(size <= MAX_BYTES);
//到对应的桶当中进行申请,若没有则像CentralCache层申请
size_t alignSize = SizeClass::RoundUp(size);//对齐数
size_t index = SizeClass::Index(size);//桶
//优先到桶中寻找
if (!_freeLists[index].Empty())
{
return _freeLists[index].Pop();
}
//不得不往CentralCache层要了
return FetchFromCentralCache(index, alignSize);
}
Index可以通过传进的字节数匹配到访问的对应的桶,由于是按照梯度进行计算,所以Index也需要按照梯度进行计算桶的位置。
static size_t Index(size_t size)
{
static int arr[] = { 16,56,56,56 };
if (size <= 128)
{
return _Index(size, 3);
}
else if (size <= 1024)
{
return _Index(size - 128, 4) + arr[0];
}
else if (size <= 8 * 1024)
{
return _Index(size - 1024, 7) + arr[0] + arr[1];
}
else if (size <= 64 * 1024)
{
return _Index(size - 8 * 1024, 10) + arr[0] + arr[1] + arr[2];
}
else if (size <= 256 * 1024)
{
return _Index(size - 64 * 1024, 13) + arr[0] + arr[1] + arr[2] + arr[3];
}
else
{
assert(false);
return -1;
}
}
static inline size_t _Index(size_t bytes, size_t align_shift)
{
//7 15
return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}
RoundUp函数的作用是对齐,类似size为7字节,他就会帮助我们对齐到8字节。
static size_t RoundUp(size_t size)
{
if (size <= 128)
{
return _RoundUp(size, 8);
}
else if (size <= 1024)
{
return _RoundUp(size, 16);
}
else if (size <= 8 * 1024)
{
return _RoundUp(size, 128);
}
else if (size <= 64 * 1024)
{
return _RoundUp(size, 1024);
}
else if (size <= 256 * 1024)
{
return _RoundUp(size, 8 * 1024);
}
else
{
assert(false);
return -1;
}
}
static size_t _RoundUp(size_t size, size_t align_size)
{
//7 8 14 & 7~ 1110 1000
return (size + align_size - 1) & (~(align_size - 1));
}
如何让每个线程有独立的threadCache:
自由链表结构,方法一览:
class FreeList
{
public:
void Push(void*& obj)
{
//头插两步骤
NextObj(obj) = _freeList;
_freeList = obj;
++_size;
}
void* Pop()
{
assert(_freeList);
//保存当前,_freeLists指向下一个节点即可
void* obj = _freeList;
_freeList = NextObj(obj);
_size--;
return obj;
}
bool Empty()
{
//为NULL则返回true
return _freeList == nullptr;
}
void PushRange(void* start, void* end, size_t n)
{
//将整个内存块看成一个整体
assert(start);
assert(end);
NextObj(end) = _freeList;
_freeList = start;
_size += n;
/*NextObj(end) = _freeList;
_size += n;
if (_freeList)
NextObj(_freeList) = start;
else
_freeList = start;*/
// 条件断点
/* int j = 0;
void* cur = start;
while (cur)
{
cur = NextObj(cur);
++j;
}
if (j != n)
{
int x = 0;
}*/
}
void PopRange(void*& start, void*& end, size_t n)
{
assert(n <= _size);
start = _freeList;
end = _freeList;
for (size_t i = 0; i < n - 1; ++i)
{
end = NextObj(end);
条件断点
/* if (end == nullptr)
{
int x = 0;
}*/
}
_freeList = NextObj(end);
NextObj(end) = nullptr;
_size -= n;
}
size_t& MaxSize()
{
return max_size;
}
size_t Size()
{
return _size;
}
private:
//void* 就是一个自由链表
void* _freeList = nullptr;
size_t max_size = 1;//标识一次性threadcache可以从centralcache拿多少
size_t _size = 0; //标识一个List当中有多少数据
};
central cache 与thread cache类似,也是按照对应的大小的对应规则的哈希结构。
并且这个用的是桶锁,虽然centreal cache需要加锁,但是加锁的粒度还是比较小的。
span是管理多个连续大块内存跨度的结构
central cache挂的是一个个的span,span即跨度,它可以将一个span切分成对应桶的大小,一个个切,span是以多个页为单位的内存。
由上到下span当中页的数量也是不一样的,因为一个页能切若干个8Byte,但是一个256KB的对象都切不出来。
centralcache给threadcache的时候给批量,剩余的依旧挂在桶上。
自由链表_list用于标识是否页是否被用完,若为nullptr则可以认为该页的内存全部被切割完。但是不能用来判断还剩下多少。
_usecount 能够标识是否全部还回来了,即_usecount能够用来标识还剩下多少块内存。
总结:
在Central Cache::GetOneSpan当中切内存块的时候,建议使用尾插,因为这样别人拿到的内存是一块连续的,缓存利用率高。
页号跟进程地址强相关,跟我们的地址是强相关的,跟指针类似,只不过我们这里的跨度更大而已。我们采取一页为8KB的方案。
注意事项:
//CentralCache|PageCache
class Span
{
public:
void* _freeList = nullptr;//管理小块内存的自由链表
size_t _n = 0;//标识有多少页
PAGE_ID _pageId = 0;//页号
size_t use_count = 0; //用于标识是否span有人使用,为0标识无人使用
Span* _next = nullptr;
Span* _prev = nullptr;
size_t _objsize = 0;
bool _isUse = false;
};
//封装了Span,是双链表结构
class SpanList
{
public:
SpanList()
{
_spanList = new Span;
_spanList->_next = _spanList;
_spanList->_prev = _spanList;
}
Span* begin()
{
return _spanList->_next;
}
Span* end()
{
return _spanList;
}
void Push_Front(Span* span)
{
assert(span);
insert(span, begin());
}
void insert(Span* newspan, Span* span)
{
assert(newspan);
assert(span);
//将newspan插入到span之前 prev newspan span
Span* prev = span->_prev;
//bug
prev->_next = newspan;
newspan->_prev = prev;
newspan->_next = span;
span->_prev = newspan;
}
bool Empty()
{
return _spanList->_next == _spanList;
}
Span* Pop_Front()
{
assert(_spanList->_prev != _spanList);
Span* sbegin = begin();
Erase(sbegin);
return sbegin;
}
void Erase(Span* span)
{
assert(span);
assert(span != _spanList);
Span* prev = span->_prev;
Span* next = span->_next;
prev->_next = next;
next->_prev = prev;
}
public:
std::mutex _mtx;//桶锁
public:
Span* _spanList;
};
单例模式的使用:
由于这里小对象可以给多一点,大对象给少一点,所以我们可以用一个NumMoveSize来从thread cache从中心缓存获取若干个对象,这里标识了一次性能够获取的最大对象数,并且这里的值会给一个上限,和一个下限,这里是别人研究好的,但也可以调整;但我们并不是一次就给他申请这么多。所以我们还需要一个慢开始的算法,慢开始的算法需要在每一个桶都添加一个_maxSize字段,标识这个桶曾经一次性最多开辟的对象的数量。
// 计算一次向系统获取几个页
// 单个对象 8byte
// ...
// 单个对象 256KB
static size_t NumMovePage(size_t size)
{
//计算出创建多少对象共需要的字节数,再计算要获取多少页
size_t num = NumMoveSize(size);
size_t total_bytes = num * size;
//这是/
size_t n = total_bytes >> PAGE_SHIFT;
if (n == 0)
n = 1;
return n;
}
// 一次从中心缓存获取多少个
static size_t NumMoveSize(size_t size)
{
//通过一次所需要的对象的大小来计算在哪个桶拿
assert(size > 0);
size_t objNum = MAX_BYTES / size;//256K除以单个对象的大小
//最多申请512个对象,最少申请2个对象
if (objNum > 512)
objNum = 512;
else if (objNum < 1)
objNum = 2;
return objNum;
}
span指向end的next,end的前四个字节指向空即可。
page cache 申请规则:
在threadcache当中不能出现申请超过256K的内存以及释放256K的内存空间,在ConcurrentAlloc当中就应当做判断,当申请大的内存块的时候应当直接去pageCache当中申请。
pagecache当中挂着是一个个的span,但是这里的映射规则与前面的不一样,总共128个桶,centralcache找pagecache的时候只关注要找多少页的桶即可。
最大的桶为128页,由于单个对象最大时256KB,既可以切出4个最大的内存,已经足够了。
pageCache的锁用的是大锁,因为他这里用到的时候需要把多个页合并
当这个对应的桶没有,可以去后面的桶里面找。如果没有的话就像系统申请一个128页的span。
这个128页的span的内存空间是连续的,它可以被切分成1~127的页,但是只要能够合并,最终都可以合并成128页。其实合并成128页就是为了防止要大内存的时候页都是被切分小的这种情况,所以将没有使用的页进行合并是为了减少外碎片。
建立页号到Span的映射原因:
为何同时存在is_use和use_count两个字段?
注意:
is_use什么时候设置成true
is_use什么时候设置成false
注意:
用大锁的好处,当有需要一页的span和两页的span,此时只有一个三页的span,若pagecache采取的是桶锁,这个时候1号和2号桶都被不同线程上锁了,此时假设需要1号线程的跑得快,他需要1页,就拿3页的切成1页和2页,这个时候就需要2页的锁,可是2页的被线程占住了,并且他需要3号线程的锁,此时形成了死锁状态。
解决方案:
如何找到知道需要多少页的span?
常用的桶实际上是1~64号桶
65~128
的页实际上用的不频繁,所以65~128
的页通常是用于挂着待切分,提前储备起来。当由于centralcache的上了桶锁了之后,要不要释放锁再去pagecache层?
从PageCache获取的span切分成小内存不需要加锁
min的,由于windows.h当中有宏定义,所以这里我们选择使用min。
可以通过这种方式来确认是否来连接上,观察他的内存可以知道是否指向下一个位置。
当size=24的时候,观察下图结果,是尾插并且切分无问题。
由于释放逻辑比较少,所以三层一起讲~
注意:
在我们实现的内存池当中,内碎片只是暂时的,而外碎片如果不采用小页合并成大页,那么外碎片问题则会一直存在。
常规的内存块的释放逻辑告一段落,由于超过256KB的内存是不会经过central cache层的,所以我们这里分开大块内存的申请与释放逻辑来讲。
void PageCache::ReleaseSpanToPageCache(Span* span)
{
assert(span);
if (span->_n >= NPAGES)
{
//大于128页直接还给堆,因为也是直接从堆申请的并且没有多个线程使用的。
SystemFree((void*)(span->_pageId << PAGE_SHIFT));
_spanPool.Delete(span);
}
}
inline static void SystemFree(void* ptr)
{
#ifdef _WIN32
VirtualFree(ptr, 0, MEM_RELEASE);
#else
// sbrk unmmap等
#endif
}
用对象池替换new
不传对象进行内存释放
遇到性能问题的处理
条件断点在这个项目用于检测是否切分的是否正确好用
,查看调用栈帧,配合监控使用定位问题。若遇到死循环,调试模式的全部中断能够帮助你快速定位错误。// 条件断点
int j = 0;
void* cur = span->_freeList;
while (cur)
{
cur = NextObj(cur);
++j;
}
if (j != count)
{
int x = 0;
}
性能瓶颈测试
加入基数树进行优化
采用基数树不需要加锁的原因:
vs打包成库
end~
release x86下访问不同桶的时候检测的时间。
项目源码