前言
本项目仅为了学习并提升代码能力,不作为实际运用。
项目完整代码地址:gitee仓库地址
文章目录
- 1.项目介绍
- 2.什么是内存池
- 3. 先设计一个定长的内存池
- 4.高并发内存池整体框架设计
- 5.`thread cache`整体设计
- 6.哈希桶映射对齐规则
- 7. TLS -- thread local storage
- 8.`central cache`的整体设计
- 9.`central cache`结构设计
- 10.`central cache`核心实现
- 11. `page cache`的整体设计
- 12 `page cache`中获取`Span`上
- 13`page cache`中获取`Span`中
- 14. threadcache回收内存
- 15. central cache回收内存
- 16. pagecache回收内存
- 17. 大于256KB的大块内存申请问题
- 18. 释放对象时优化为不传对象大小
- 19. 多线程环境对比malloc测试
- 20. 性能瓶颈分析
- 21. 针对性能瓶颈使用基数树优化
- 22. 使用基数树进行优化代码实现
1.项目介绍
项目原型是google
的开源项目tcmalloc
。即线程缓存的malloc
,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数malloc
和free
。
项目特点:1.比较难 2.知名度高(很多的大厂程序员都知道这个项目,并且go
语言的内存分配器就是这个)所以面试官可能会问的很细。
知识点:C/C++
,数据结构(链表,哈希桶),操作系统内存管理,单例模式,多线程,互斥锁
2.什么是内存池
2.1 池化技术
“池化技术”就是程序向系统先申请过量资源,然后自己管理,以备不是之需。因为每一次申请资源都需要较大的开销,所以提前申请好了资源,这样在使用的时候,就会大大提高程序运行的效率。
除了内存池,还有连接池,线程池,对象池等等。以线程池为例,它的主要思想就是:先启动若干数量的线程,让它们先处于睡眠状态,当接收客户端的请求的时候,唤醒线程池中的某个睡眠的线程来处于客户端的请求,当处理完这个请求后,该线程再进入睡眠状态。
2.2 内存池
原理和线程池类似
2.3 内存池主要解决的问题
内存池主要可以解决两个方面的问题:
- 效率问题
- 内存碎片问题
内存碎片分两种
- 外碎片
- 内碎片
例子:
(外碎片)
2.4 malloc
malloc
实际就是一个内存池,malloc
相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给不同的进程。
但是malloc
的实现方式有很多种。windows
中有自己vs
系列的一套,linux
中有ptmalloc
。
有很多的文章讲到了malloc
的实现方式可以看看malloc
的实现。
tcmalloc
比普通的malloc
要快,并且在多线程高发可以很快
3. 先设计一个定长的内存池
先做一个定长的内存池,一方面可以先熟悉一下内存池,另一方面可以作为后面项目的一个基础组件。
定长内存池的功能:
- 定长的内存池可以解决,固定大小的内存申请释放需求。这里为了后面可以方便当成组件使用。所以这里需要传入一个对象,根据对象的大小可以分配内存。
项目特点:
- 性能达到极值
- 不考虑内存碎片的问题
设计思想:
- 特点在于使用自由链表来管理归还后的资源。每一次申请T对象大小的内存的时候先去链表中找,如果链表中没有资源了,再直接去向系统申请。
问题1:不采用
void* _memory
,而是采用char* _memory
?
使用char*方便后面可以方便切内存使用
问题2:如何处理归归还之后的内存呢?
采用自由链表的方式。使用void* freeList
存储第一个归还的内存块的地址,然后第二个内存块(32位下必须大于4字节)的头4字节去存储第二个内存块的地址,最后一个在freeList
中的内存块指向nullptr
即可。也就是freeList
中的节点就是一个个归还回来的内存块 。
问题3:如何知道何时再分配资源?
引入成员变量_leftBytes
,统计当前内存池还剩下可用的内存块的字节数。当剩余空间小于一个申请对象的时候,这个时候就说明当前的内存块不够用了,所以就需要重新的申请空间。而当前剩下的一些内存就不要了。
问题4:如何将第一个内存块放入一个空的
_freeList
中?
我们只需要将_freeList
指向第一块内存即可。但是又有一个问题,此时这个内存块既是第一个内存块也是最后一个内存块,所以需要将内存块的前部分指向nullptr
,而空指针是4个字节,因为需要将头4个字节填上nullptr
。这里有一个技巧取用头4个字节:可以先将obj强转成(int*)
,然后再解引用就可以拿到4个字节了。(使用不同类型的指针访问内存是一个技巧)。
问题5:32位上程序是没有问题的,但是64位上程序就不对了。
因为32位下的指针大小是4字节,64位下的指针是8字节,那么为了保证开辟的空间大小正确,可以开辟一个指针的大小同时也可以转成一个指针,所以就可以强转成(void**)
(解释:将void*
看成一个整体,那么我们就需要开辟一个void*
大小的空间,此时指针void*
就可以随着平台的不同而产生变化也就可以满足我们的需求),然后再解引用,即*(void**)obj = nullptr
。当然如果麻烦一点的话,就可以直接判断当前平台下一个指针的大小,根据一个指针的大小使用if
判断开多大的空间。
问题6:删除操作的简便写法。
我们在处理归还的费第一个节点时,采用头插法的效率最高。那么其实就可以直接所有的插入操作都写成头插,这样也不用特殊处理第一个节点了。
问题7:在分配空间的最开始,应该考虑
_freeList
是否有可用的空间
在分配空间的时候,需要先考虑回收的自由链表中是否存在可用的内存。如果自由链表中存在可用的内存,那么就不用向系统再申请内存空间了。
问题8:如果归还的内存块不满4/8个字节也就是不能存放一个指针的大小,也就无法保存下一个空间的地址,怎么办?
为了让一个内存块一定可以保存一个指针,所以在分配内存空间的时候,我们需要判断一个内存块的大小,如果大于一个指针的大小,那么可以直接分配;如果小于一个指针的大小,就可以分配一个指针的大小。这样就可以保证每一个内存块都一定可以保存一个指针的大小。
问题9:需要主动处理内存块中对象的内容
当分配空间的时候,需要使用定位new去主动的调用T
对象的构造函数。在将内存块回收的时候,需要主动调用T
对象的析构函数。
- placement new 有两个作用
- 1.在使用
operator new
分配好内存空间后,可以使用定位去驱主动的调用构造函数 - 2.使用定位new可以返回指向这个对象的地址
- 1.在使用
问题10:如果我们想要使得我们制作的内存池更加纯粹的话,那么申请空间的时候就不使用
malloc
而是直接向系统申请内存。
malloc
是一个内存池,所以为了使得申请内存资源的操作更加纯粹的话,可以直接使用相关的系统接口,以页为单位向系统直接申请系统内存。
如果想要直接向系统申请内存的话,在windows
下可以使用VirtualAlloc
,在linux
下可以使用brk()
或者mmap()
。
mmap
可以将文件的内容映射进进程的虚拟地址空间,这样就可以不用read
和write
对文件进行操作。brk
是将数据段的最高地址指针_edata
指针往高地址推
#include <unistd.h>
int brk(void* addr);
- 作用
- 将
brk
指针指向addr
的位置上
- 将
- 参数
addr
:将brk
推到addr
的位置上
- 返回值
- 成功返回0,失败返回-1
#include <unistd.h>
void* sbrk(intptr_t increment);
- 作用
- 推动
brk
指针,增加increment
大小的内存
- 推动
- 参数
increment
:增加的内存大小
- 返回值
- 返回旧的
brk
指向的位置
- 返回旧的
**使用技巧:**使用sbrk
可以更方便地分配指定的内存空间,因为在释放空间的时候必须要重新定位指针的位置。使用brk
可以更方便地释放内存,因为不能确定brk
指针的位置。
所以设置一个brk
指针的锚点,使用sbrk
动态分配内存,而brk
可以以锚点为基础回收内存。
#pragma once
#include <iostream>
using std::cout;
using std::endl;
#ifdef _WIN32
// 因为是在vs下变成,所以使用windows系统分配内存的接口
#include <Windows.h>
#else
// 如果是Linux就要使用Linux下直接分配内存的接口
#include <sys/mman.h>
#endif
// 按页分配,一页是8k
// (1 << 13)就是8*1024
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage*(1 << 13), MEM_COMMIT | MEM_RESERVE, PAGE_READONLY);
#else
// linux下mmap接口
void* ptr = mmap(0,//首地址,0代表内核指定
kpage * (1 << 13), // 开辟K页内存
PROT_READ|PROT_WRITE,//权限
MAP_PRIVATE|MAP_ANONYMOUS,//私有匿名 针对
0,0);//文件描述符
#endif
}
// 定长内存池
// 非类型模板参数直接确定内存池的大小
//template<size_t N>
//class ObjectPool
//{};
// 但是为了后面的项目准备,所以这里写成class T,而T对象的大小也是固定的,也是可以当做一个常数使用的
template<class T>
class ObjectPool
{
public:
T* New()
{
T* obj = nullptr;
// 问题7
if (_freeList != nullptr)
{
void* next = *(void**)_freeList;
obj = (T*)_freeList;
_freeList = next;
}
else
{
// 问题3
// 剩余内存不够一个对象大小是,重新开空间
if (_leftBytes < sizeof(T))
{
// 问题10
_leftBytes = 128 * 1024;
// _memory = (char*)malloc(_leftBytes);
_memory = (char*)SystemAlloc(_leftBytes >> 13); // 16页
if (_memory == nullptr)
{
throw std::bad_alloc();
}
}
obj = (T*)_memory;
// 问题8
// _memory += sizeof(T);
// _leftBytes -= sizeof(T);
size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objSize;
_leftBytes -= objSize;
}
// 问题9
new(obj)T;
return obj;
}
void Delete(T* obj)
{
/**
// 问题4
if (nullptr == _freeList)
{
_freeList = obj;
// 问题5
// *(int*)obj = nullptr;
*(void**)obj = nullptr;
}
else // 头插
{
*(void**)obj = _freeList;
_freeList = obj;
}
*/
// 问题9
obj->~T();
// 问题6 && 问题8
*(void**)obj = _freeList;
_freeList = obj;
}
private:
// 可以直接给缺省值,就不用写构造函数了
// 指向大块内存的指针
char* _memory = nullptr; // 问题1
// 大块内存中剩余字节数
size_t _leftBytes = 0;
// 还回来的内存形成的单链表
void* _freeList = nullptr; // 问题2
};
4.高并发内存池整体框架设计
malloc
本身已经很优秀了,但是本项目中tcmalloc
在多线程高并发的场景下更胜一筹,所以实现的内存池需要考虑一下问题:
- 性能问题
- 多线程环境下,锁竞争问题
- 内存碎片问题
ConcurrentMemoryPool
主要有以下的3个部分组成:
thread cache
:线程缓存是每个线程独有(后面会讲实现) 的,用于小于256KB的内存分配,线程从这个申请内存是不需要加锁的,每一个线程独享一个cache
,这就是并发线程池高效的问题。central cache
:中心缓存是所有线程共享的。thread cache
按需从central cache
中获取对象的。central cache
在适合的时机(后面会讲实现) 回收thread cache
中的对象,避免一个线程会占用太多的资源,而其他的线程会资源紧缺,达到了内存分配在多个线程中更均衡的按需调度的目的。central cache
在资源调度的时候,是存在资源竞争的,所以 取内存对象的时候需要加锁。但是这个采用的时候桶锁,所以只要当多个线程竞争同一个桶中的资源的时候才会加锁,而且是由threal cache
没有内存对象的时候才会申请资源,所以这个内存申请资源不会很激烈。page cache
:页缓存是在central cache
缓存上面的一层缓存,存储的内存是以页为单位存储以及分配的。 当central
没有缓存的时候,从page cache
中分配出一定数量的page
并且并且切割成定长大小的小块内存,分配给central cache
。当central cache
中一个span
的几个跨度页的对象都回收回来之后,page cache
会回收central cache
中满足条件的span
对象并且会合并成相邻的页,组成更大的页,缓解了内存碎片的问题。
5.thread cache
整体设计
前面定长内存池使用自由链表的结构来分配内存,但是链表中的节点都是定长的。为了适应不同长度的内存块分配情况,可以使用多个连接着不同字节大小的内存块的链表。
但是thread cache
中最大的内存块是256KB
,如果我们为了精确分配的内存的话,需要使用256*1024
个链表(256KB=256×1024B)的话就太浪费了。所以我们可以使用8B
,16B
,24B
…256KB
这样粗略地分一下即可,在申请资源的时候是要去大于等于当前申请内存的最小内存块即可 (按照一定大小进行内存对齐)。
这样设计缺点在于可能会有很多的空间浪费,造成内存碎片,并且是内碎片。
- 外碎片:分配空间在归还之后,导致内存空间不连续,不能连续分配。
- 内碎片:在分配内存给对象之后,由于内存对齐等缘故导致内存块中有一个空间不可能使用到,但是已经分配过内存了。
另外thread cache
采用哈希桶结构,每一个桶中是按桶的大小去映射的,即桶中的自由链表的内存块对象大小等于桶大小,使用哈希映射可以快速得到线程星想要得到的内存块的大小。这样设计使得每一个线程都有一个一个thread cache
对象,每一个线程获取对象和释放对象时是无锁的。
问题1:处理哈希桶中自由链表问题。
由于每一个哈表桶中都需要挂一个自由链表,所以可以将自由链表封装成一个类专门管理小内存块。
// 统一写法,取出一块内存头部的4/8个字节存放下一个内存块的地址
void*& NextObj(void* obj)
{
return *(void**)obj;
}
// 管理切好的小块内存的自由链表
class FreeList
{
public:
// 采用头插
void Push(void* obj)
{
// 如果obj为nullptr则不能插入
assert(obj);
// 头插内存块
//*(void**)obj = _freeList;
NextObj(obj) = _freeList;
_freeList = obj;
}
// 采用头删
void* Pop()
{
// 如果_freeList为nullptr则不能删除
assert(_freeList);
// 头删内存块
void* obj = _freeList;
_freeList = NextObj(_freeList);
return obj;
}
private:
void* _freeList;
};
class ThreadCache
{
public:
void* Allocate(size_t size);
void Deallocate(void* ptr, size_t size);
private:
// 问题1
};
6.哈希桶映射对齐规则
问题1:给一个需要内存块的大小
size
,怎么将这个内存块对齐呢?
使用一个类专门来管理和计算对象大小的内存对齐的映射规则。其中至少要按8字节对齐,因为64平台下一个指针都8字节。但是如果256KB都按8字节对齐的话,需要3万多个哈希桶,所以可以进一步的改造一下,每一个字节范围内按一个字节数来对齐。
- [1, 128]字节按8bytes对齐
- freeList(桶位置)[0, 16)
- [128 + 1, 1024]字节按16bytes对齐
- freeList(桶位置)[16, 72)
- [1024 + 1, 1024 * 8]字节按128bytes对齐
- freeList(桶位置)[72, 128)
- [8 * 1024 + 1, 64 * 1024 ]字节按1024bytes对齐
- freeList(桶位置)[128, 184)
- [64 * 1024 + 1, 256 * 1024]字节按8* 1024 bytes对齐
- freeList(桶位置)[184, 208)
这样就可以控制最多10%左右的内存碎片浪费。前期的对齐数小一点,后面的对齐数变大。
// "common.h"中
// 最大的自由链表数量
static const size_t NFREE_LISTS = 208;
// threadcache中最大分配的内存块的大小
static const size_t MAX_BYTES = 256 * 1024;
class SizeClass
{
public:
static inline size_t _RoundUp(size_t size, size_t alignNum)
{
// 将size按alignNum对齐数对齐
return ((size + alignNum - 1) & ~(alignNum - 1));
// 也可以这样
//return (size + alignNum - 1) / alignNum * alignNum;
}
// 为了保证在类外可以直接调用函数,而不是使用对象调用函数
// 所以可以将函数设置成static的
static inline 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
{
// 分配的内存不能大于256KB
assert(false);
return -1;
// 其实如果超过256KB也是可以申请的,后面会讲
}
}
static inline size_t _Index(size_t size, size_t align_shift) {
// 其实就是size/2^(align_shift)上取整然后-1
return ((size + (1 << align_shift) - 1) >> align_shift) - 1;
}
// 计算自由链表所在哈希桶中的位置
static inline size_t Index(size_t size)
{
// 每一个区间中有多少的自由链表
static int group_array[4] = {
16, 56, 56, 56 };
if (size <= 128)
{
return _Index(size, 3);
}
else if (size <= 1024)
{
return _Index(size - 128, 4) + group_array[0];
}
else if (size <= 8 * 1024)
{
return _Index(size - 1024, 7) + group_array[0] + group_array[1];
}
else if (size <= 64 * 1024)
{
return _Index(size - 8 * 1024, 10) + group_array[0] + group_array[1] + group_array[2];
}
else if (size <= 256 * 1024)
{
return _Index(size - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
}
else
{
assert(false);
return -1;
}
}
};
// "ThreadCache.h"中
class ThreadCache
{
public:
void* Allocate(size_t size);
void Deallocate(void* ptr, size_t size);
// 从centralcache中获取内存
void* FetchFromCentralCache(size_t index, size_t size);
private:
// 用数组模拟哈希表,最多有NFREE_LISTS
FreeList _freeLists[NFREE_LISTS];
};
// ”ThreahCache.cpp“中
void* ThreadCache::Allocate(size_t size)
{
// threadcache最多只能分配256KB
assert(size <= MAX_BYTES);
// size对齐之后的字节数
size_t alignSize = SizeClass::RoundUp(size);
// size字节数对应的哈希桶的位置
size_t index = SizeClass::Index(size);
// 如果申请内存大小对应的哈希桶中的自由链表为空,就去centralcache中拿
// 否则直接从自由链表中获取即可
if (_freeLists[index].Empty())
{
return FetchFromCentralCache(index, alignSize);
}
else
{
return _freeLists[index].Pop();
}
}
void ThreadCache::Deallocate(void* ptr, size_t size)
{
// ...
}
7. TLS – thread local storage
问题0:什么是TLS?
TLS(线程局部存储),是一种变量的存储方法,这个变量在它所在的线程内是全局可访问的,但是不能被其他线程访问到,这样就保证了数据的线程独立性。
问题1:为什么需要
TLS
?
为了保证每一个线程都可以有自己专属的thread cache
,所以可以使用TLS
,来保证每一个线程都可以无锁地获得自己的thread cache
对象。TLS分为静态和动态的,使用静态的LTS最简单,只需要声明一个_declspec(thread)
的变量就会给每一个线程单独的一个拷贝。
问题2:"ConcurrentAlloc.h"是什么作用?
这里需要专门准备两个函数给每一个线程调用分配内存。
问题3:
.h
文件中很多的static
修饰的变量和函数是为什么?
static修饰函数,改变链接属性,一个.h文件中可以被多个.cpp文件包含,所以这里使用static保证其中的static的变量或者函数只保存一份,这样就不会再生成.obj文件的时候相互冲突了,static保证了变量或者函数只在当前文件可见 。
/