目录
项目介绍
池化技术
内存池
内存碎片
malloc工作原理
定长内存池
申请内存
释放内存
定位new
VirtualAlloc函数
封装VirtualAlloc
定长内存池的最终代码
项目介绍
项目原型:goole的开源项目tcmalloc(Thread-Caching Malloc)
项目目标:实现高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc等)
涉及技术栈:C/C++、数据结构(链表、哈希桶)、操作系统的内存管理、单例模式、多线程、互斥锁、慢调节算法
池化技术
基本概念:程序提前向系统申请过量的资源,然后自行管理,从而减少每次申请资源时的开销,提高程序运行效率(比如线程池的主要思想就是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中的某个睡眠的线程,让它来处理客户端的请求,当处理完请求后,该线程继续进入睡眠状态)
内存池
基本概念:与线程池的原理一样,内存池是程序预先从操作系统中申请一块足够大的内存,然后当程序中需要申请内存时,不是直接向操作系统申请,而是直接从内存池中获取;当程序释放内存时,并不是真正将内存返回给操作系统,而是返回给内存池,当程序退出或到达特定时间时,内存池才将之前申请的内存真正释放
补充:内存池除了要解决内存申请的效率问题,还要解决内存碎片问题
内存碎片
基本概念:内存碎片分为内碎片和外碎片,内碎片指系统分配的但没用完的内存,外碎片指系统还可分配的内存
问题分析:如下图所示,在申请一块300Byte的连续地址空间时,由于返还所产生的两个外碎片的地址空间并不相连所以会导致申请失败,同时对于内碎片而言只会使用20Byte但系统分配了1000Byte那么就会造成大量的浪费
总结:①外碎片过多会导致虽然总内存足够,但内存空间可能不连续,不能满足一些较大的内存分配申请;②内部碎片过多会导致分配出去的内存浪费
malloc工作原理
基本概念:C/C++中动态申请内存都是通过malloc去申请内存,但实际上malloc就是一个内存池,调用malloc就相当于向操作系统“批发”一大批内存空间,然后“零售”给程序使用,当全部“售完”或程序有更大的内存需求时,再根据需求向操作系统“进货”,各个平台的malloc的实现方式都是不同的
定长内存池
基本概念:提前开辟一块固定大小的内存块,基于自由链表实现对该大块内存的使用和释放,同时放弃使用malloc向操作系统申请内存的方式
申请内存
1、起始时_memory指向的大块内存为空,需要申请(这里我们规定申请128Kb),然后每次为T类型对象分配所需要的内存后,向后移动_memory指向的位置,并返回一个指向申请到的内存的指针
class ObjectPool
{
public:
//申请内存
T* New()
{
T* obj = nullptr;
if(_memory == nullptr)
{
_memory = (char*)malloc(128 * 1024);
if(_memory == nullptr)
{
throw std::bad_alloc();//申请失败就抛异常
}
}
obj = (T*)_memory;
_memory += sizeof(T);
return obj;
}
private:
char *_memory = nullptr;//指向申请的大块内存的指针
}
2、提前申请的128Kb大小的内存块被用完时,再次申请时_memory+=sizeof(T)就会越界访问,所以当剩余内存_remainBytes < sizeof(T)时就需要重新申请新大块内存
class ObjectPool
{
public:
//申请内存
T* New()
{
T* obj = nullptr;
//剩余内存不够一个T对象大小时,重新开大块空间
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;//初始设定_remainBytes为128Kb大小,其实也是设定了每次要重新申请的大块内存的大小为128Kb
_memory = (char*)malloc(_remainBytes);
if(_memory == nullptr)
{
throw std::bad_alloc();//申请失败就抛异常
}
}
obj = (T*)_memory;
_memory += sizeof(T);
_remainBytes -= sizeo(T);//每次分配后重新结算剩余字节数
return obj;
}
private:
char *_memory = nullptr;//指向申请的大块内存的指针
size_t _remainBytes = 0;//大块内存剩余的字节数,缺省值设置为0是为了保证第一次申请时可以直接进入if (_remainBytes < sizeof(T))中去开辟内存
void* _freelist = nullptr;//指向自由链表
}
3、我们不能逮着一个内存块狠用,也要将归还的内存块利用起来
#define MAX_TYPES 256*1024;//定义最大的内存为256Kb
class ObjectPool
{
public:
//申请内存
T* New()
{
T* obj = nullptr;
if(_freelist != nullptr)
{
//头删
void* next = *((void**)_freelist);//next指向自由链表的第二个结点
obj = _freelist;
_freelist = next;
return obj;//返回指向从自由链表中分配的结点的指针
}
else
{
//剩余内存不够大时......(后续不变)
//移动_memory....
}
}
private:
char *_memory = nullptr;//指向申请的大块内存的指针
size_t _remainBytes = 0;//大块内存剩余的字节数
void* _freelist = nullptr;//指向自由链表
}
4、若T对象占用的字节数小于存放下一个结点地址的字节数,如果还是要多少分配多少,就会导致无法链接其它结点,因此我们要保证即使T对象本身所需内存过小也能记录下一个结点的位置
//仅需要在这里新增一行判断,其余位置不变
obj = (T*)_memory;
size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);//T对象需要的内存大小小于当前环境下一个指针的大小,则最少给一个指针的大小
_memory += sizeof(T);
_remainBytes -= sizeo(T);//每次分配后重新结算剩余字节数
释放内存
1、当T类型对象使用完它所申请的内存后,需要将不用的内存返回,这些被返回的内存会被挂在自由链表上,当自由链表为空时就要先头插,同时我们试图让每个结点的前n个字节存放下一个结点的地址(即指针)
注意事项:32下指针4字节大小和64位环境下指针8字节大小且int均为4字节,如果在32位机器下使用*(int*)obj 令obj指向的内存结点的前4字节存放下个结点的地址是没问题的,但是如果是64位环境,指针占8字节解引用后仍只能获取前4个字节,即获取的地址是实际的一半就会出问题,所以我们采用解引用二级指针的方式,这样就不需要我们额外的判断当前程序运行时所处的环境了(解引用得到的都是一级指针,32位下一级指针表示4字节就让前4字节为空,64位下一级指针表示8字节就让前8字节为空)
class ObjectPool
{
public:
//申请内存
T* New(){省略.....};
//回收内存
void Delete(T* obj)//传入指向要回收的对象的指针
{
/*可以不考虑链表是否为空的情况,直接头插即可,因为_freelist起始为空(不信自行带入测试)
if(_freelist == nullptr)//链表为空就先头插
{
_freelist = obj;
//*(int*)obj = nullptr;//淘汰
*(void**)obj = nullptr;
}
else//头插
{
*(void**)obj = _freelist;
_freelist = obj;
}
*/
//修改后
*(void**)obj = _freelist;
_freelist = obj;
}
private:
char* _memory = nullptr;
size_t _remainBytes = 0;
void* _freelist = nullptr;//指向自由链表的指针
}
定位new
功能:在已分配好的一块内存空间中调用某对象的构造函数初始化一个该对象,在实际应用中,定位new一般是配合内存池使用的,因为内存池分配出来的空间没有初始化,因此如果需要在这块内存池分配出来的空间上构造自定义类型的对象,需要使用定位new显式调用构造函数构造目标对象
格式:
格式一:new (place_address) type
格式二:new (palce_address) type (initializer_list)
- place_address:指向待构造对象的指针
- type:待构造对象的类型
- initializer_list:待构造对象的初始化列表
注意事项:
-
需要手动管理内存:使用定位new时,程序员必须先分配内存,并确保这块内存足够大,能够容纳将要构造的对象。此外,还需要负责这块内存的释放。
-
不进行内存分配:定位new只调用对象的构造函数,不会像new运算符那样分配内存。因此,如果提供的内存不足,会引发未定义行为。
-
不能使用默认构造函数:如果没有为定位new提供的内存地址提供一个合适的构造函数,编译器将无法调用默认构造函数,除非该构造函数已经在类定义中显式声明。
-
需要显式调用析构函数(重要):由于定位new不包括分配和释放内存的代码,因此必须显式地调用对象的析构函数来销毁对象,以避免内存泄漏。
-
处理数组:如果使用定位new来创建一个对象数组,那么构造每个对象时都需要分别调用定位new,同时在数组被销毁时,需要为每个对象分别调用析构函数
VirtualAlloc函数
基本概念:为了使得定长内存池不使用malloc,我们可以使用Windows和Linux均有提供的直接向系统申请以页为单位的大块内存的接口,Windows是VirtualAlloc,Linux是brk()和mmap()
参考链接:VirtualAlloc 函数 (memoryapi.h) - Win32 apps | Microsoft Learn
函数原型:
LPVOID VirtualAlloc(
[in, optional] LPVOID lpAddress,
[in] SIZE_T dwSize,
[in] DWORD flAllocationType,
[in] DWORD flProtect
);
lpAddress
:可选参数,指定希望分配的虚拟内存的起始地址。若传入 NULL,系统自动分配dwSize
:指定要分配的内存区域大小,单位为字节flAllocationType
:标志位(可多个),我们这里使用了MEM_COMMIT | MEM_RESERVE这两个标志位结合,这表示VirtualAlloc函数会尝试为调用进程分配一块指定大小的内存区域,并立即为这块内存分配物理存储器。这样做的好处是确保了内存区域既不会被其他分配占用,也可以立即被访问flProtect
:指定分配的内存页面的保护属性,我们这里选择PAGE_READWRITE表示
可读写访问
封装VirtualAlloc
基本概念:通过对VirtualAlloc函数进行封装,我们就可以写出一个避开malloc直接向操作系统申请内存的自定义函数,就可以将后续使用malloc的场景直接替换为SystemAlloc函数
//这里使用Windows开发环境
inline static void* SystemAlloc(size_t kpage)//kpage表示页数
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#endif
if (ptr == nullptr)
throw std::bad_alloc();//抛异常
return ptr;
}
- static inline的解释:
SystemAlloc
函数被建议内联展开,并且它是一个文件内部的静态函数,它的作用域被限定在了定义它的文件内 - VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE):在进程的虚拟地址空间中申请一块大小为
kpage * 8192
字节的区域,这块内存既被预留也被提交,并且具有可读写的属性
定长内存池的最终代码
template<class T>//模板参数T
class ObjectPool
{
public:
//封装VirtualAlloc跳过malloc直接向操作系统申请以页为单位的内存
inline static void* SystemAlloc(size_t kpage)//kpage表示页数
{
#ifdef _WIN32//使用Windows开发环境时可以使用Windows提供的VirtualAlloc函数
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#endif
if (ptr == nullptr)
throw std::bad_alloc();//抛异常
return ptr;
}
//为T对象构造一大块内存空间
T* New()
{
T* obj = nullptr;
if (_freelist != nullptr)
{
//头删
void* next = *((void**)_freelist);//next指向自由链表的第二个结点
obj = _freelist;
_freelist = next;
return obj;//返回指向从自由链表中分配的结点的指针
}
else//自由链表没东西才会去用大块内存
{
//剩余内存不够一个T对象大小时,重新开大块空间
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;//初始设定_remainBytes为128Kb大小,其实也是设定了每次要重新申请的大块内存的大小为128Kb
_memory = (char*)SystemAlloc(_remainBytes >> 13);//向SystemAlloc函数传递的是要向操作系统申请的页数而不是整体的字节数(在SystemAlloc函数中会再次转换为具体字节数)
if (_memory == nullptr)
{
throw std::bad_alloc();//申请失败就抛异常
}
}
obj = (T*)_memory;
size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);//无论T对象需要的内存大小有多大,则每次分配的内存应该大于等于当前环境下一个指针的大小,从而保证可以顺利存放下一个结点的地址
_memory += sizeof(T);
_remainBytes -= sizeo(T);//每次分配后重新结算剩余字节数
}
//定位new,显示调用T的构造函数初始化
new(obj)T;
return obj;
}
//回收内存
void Delete(T* obj)//传入指向要回收的对象的指针
{
//显示调用析构函数清理对象
obj->~T();
/*可以不考虑链表是否为空的情况,直接头插即可,因为_freelist起始为空(不信自行带入测试)
if(_freelist == nullptr)//链表为空就先头插
{
_freelist = obj;
//*(int*)obj = nullptr;//淘汰
*(void**)obj = nullptr;
}
else//头插
{
*(void**)obj = _freelist;
_freelist = obj;
}
*/
//修改后
*(void**)obj = _freelist;
_freelist = obj;
}
private:
char* _memory = nullptr;//指向大块内存的指针
size_t _remainBytes = 0;//大块内存在切分过程中剩余字节数
void* _freelist = nullptr;//自由链表,因为借用内存的对象的类型是不确定的所以要使用void*
};
~over~