目录
系列文章
项目介绍
项目原型:goole的开源项目tcmalloc(Thread-Caching Malloc)
项目目标:实现高效的多线程内存管理,替代系统提供的内存分配相关的函数(malloc等)
涉及技术栈:C/C++、数据结构(链表、哈希桶)、操作系统的内存管理、单例模式、多线程、互斥锁、慢调节算法、池化技术
池化技术: 程序提前向系统申请过量的资源,然后自行管理,从而减少每次申请资源时的开销,提高程序运行效率(线程池的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中的某个睡眠的线程,让它来处理客户端的请求,当处理完请求后,该线程继续进入睡眠状态)
定长内存池
基本概念:预先从OS中申请一块足够大的内存,后续的内存申请和内存释放都是基于这块足够大的内存,而不是向堆申请新的内存,提高了内存申请和释放的效率,直到程序全部结束这块大内存才会释放
内碎片和外碎片
基本概念:内碎片指系统分配的但没用完的内存,外碎片指系统还可分配的内存
问题分析:如下图所示,在申请一块300Byte的空间时,由于两个大块外碎片的空间并不相连所以申请失败,而如果申请了20Byte的空间但系统分配了1000Byte,内碎片的浪费率就为98%太大了
结论:①外碎片过多会导致总内存足够,但内存空间可能不连续,不能满足一些较大的内存申请;②内部碎片过多会导致内存浪费
malloc的工作原理
基本概念:malloc本质上就是一个大的内存池,调用malloc就相当于向操作系统“批发”一大批内存空间,然后“零售”给程序使用,当全部“售完”或程序有更大的内存需求时,再根据需求向操作系统“进货”,各个平台的malloc的实现方式都是不同的(windows下是对系统调用接口VirtualAlloc的封装,linux下是对系统调用接口brk的封装)
实现过程
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时就会越界,所以当剩余内存不满足一个T类型对象的大小时(_remainBytes < sizeof(T)),需要重新申请大块内存
class ObjectPool
{
public:
//申请内存
T* New()
{
T* obj = nullptr;
//剩余内存不够一个T类型对象的大小时,重新申请大块内存
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;//保证每次重新申请的大块内存均为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;//大块内存中剩余可分配字节数
}
3、也不能逮着一个内存块狠用,也要将程序使用完归还的内存块利用起来,而利用的前提是得有地方存放,这个地方就是自由链表,其中的每个结点的前4/8个字节用于存放下一个结点的地址,新结点以头插的方式放进自由链表
class ObjectPool
{
public:
//申请内存
T* New()
{
...
}
//回收内存
void Delete(T* obj)//obj指向要回收的对象的指针
{
//头插
*(void**)obj = _freelist;
_freelist = obj;
}
private:
char *_memory = nullptr;//指向申请的大块内存的指针
size_t _remainBytes = 0;//大块内存中剩余可分配字节数
void* _freelist = nullptr;//指向存放归还回来内存结点的自由链表
}
4、因为32位下一个指针为4字节和64位环境下为8字节,而int始终为4字节,在32位机器下使用*(int*)obj 的方式使obj指向的内存结点的前4字节存放下个结点的地址是没问题的,但是如果是64位环境,解引用后只能获取前4个字节,获取的地址是实际的一半,所以我们采用解引用二级指针的方式,这样就不需要我担心当前程序的运行环境了(解引用二级指针得到的都是一级指针,32位下一级指针表示4字节就让前4字节为空,64位下一级指针表示8字节就让前8字节为空)
5、有了自由链表,我们就可以将不用一直逮着大块内存池薅了,如果有内存申请且当前的自由链表中有结点,就将该结点使用头删释放出去
class ObjectPool
{
public:
//申请内存
T* New()
{
T* obj = nullptr;
if(_freelist != nullptr)
{
//头删
void* next = *((void**)_freelist);//next指向自由链表的第二个结点
obj = _freelist;
_freelist = next;
return obj;//返回指向从自由链表中分配的结点的指针
}
else
{
....
}
}
//回收内存
void Delete(T* obj)//obj指向要回收的对象的指针
{
...
}
private:
char *_memory = nullptr;//指向申请的大块内存的指针
size_t _remainBytes = 0;//大块内存中剩余可分配字节数
void* _freelist = nullptr;//指向存放归还回来内存结点的自由链表
}
6、若T类型的对象占用的字节数小于当前环境中一个指针的大小,就无法链接其它结点,因此我们要保证即使T类型对象本身所需内存过小也能记录下一个结点的位置
size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objsize ;
_remainBytes -= objsize ;
定位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,直接向堆申请以页为单位的内存
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;
}
//为T类型的对象构造一大块内存空间
T* New()
{
T* obj = nullptr;
if (_freelist != nullptr)
{
//头删
void* next = *((void**)_freelist);
obj = _freelist;
_freelist = next;
return obj;
}
else//自由链表没东西才会去用大块内存
{
//剩余内存不够一个T对象大小时,重新开大块空间
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;
_memory = (char*)SystemAlloc(_remainBytes >> 13);//SystemAlloc替换了原来这里的malloc
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;
}
//回收内存
void Delete(T* obj)//obj指向要回收的对象的指针
{
//显示调用析构函数清理对象
obj->~T();
//头插
*(void**)obj = _freelist;
_freelist = obj;
}
private:
char *_memory = nullptr;//指向申请的大块内存的指针
size_t _remainBytes = 0;//大块内存中剩余可分配字节数
void* _freelist = nullptr;//指向存放归还回来内存结点的自由链表
};
~over~