✨个人主页: 熬夜学编程的小林
💗系列专栏: 【C语言详解】 【数据结构详解】【C++详解】【Linux系统编程】【Linux网络编程】【项目详解】
目录
1、项目介绍
1.1、这个项目做的是什么
1.2、这个项目的要求的知识储备
2、什么是内存池
2.1、池化技术
2.2、内存池
2.3、内存池主要解决的问题
2.4、malloc
2.5、设计定长的内存池
2.5.1、 VirtualAlloc
2.5.1、 brk
2.5.1、 mmap
2.5.2、成员变量
2.5.3、管理释放的对象
2.5.4、释放对象
2.5.5、申请对象
2.5.6、性能测试
1、项目介绍
1.1、这个项目做的是什么
当前项目是实现一个高并发的内存池,他的原型是google的一个开源项目tcmalloc,tcmalloc全称Thread-Caching Malloc,即线程缓存的malloc,实现了高效的多线程内存管理,用于替代系统的内存分配相关的函数(malloc、free)。
我们这个项目是把tcmalloc最核心的框架简化后拿出来,模拟实现出一个自己的高并发内存池,目的就是学习tcamlloc的精华,这种方式有点类似我们之前学习STL容器的方式。但是相比STL容器部分,tcmalloc的代码量和复杂度上升了很多,大家要有心理准备。当前另一方面,难度的上升,我们的收获和成长也是在这个过程中同步上升。
另一方面tcmalloc是全球大厂google开源的,可以认为当时顶尖的C++高手写出来的,他的知名度也是非常高的,不少公司都在用它,Go语言直接用它做了自己内存分配器。所以很多程序员是熟悉这个项目的,那么有好处,也有坏处。好处就是把这个项目理解扎实了,会很受面试官的认可。坏处就是面试官可能也比较熟悉项目,对项目会问得比较深,比较细。如果你对项目掌握得不扎实,那么就容易碰钉子。
tcmalloc源代码https://gitee.com/mirrors/tcmalloc
1.2、这个项目的要求的知识储备
这个项目会用到C/C++、数据结构(链表、哈希桶)、操作系统内存管理、单例模式、多线程、互斥锁等等方面的知识。
2、什么是内存池
2.1、池化技术
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。
2.2、内存池
内存池是指程序预先从操作系统申请一块足够大内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,而是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,而是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
2.3、内存池主要解决的问题
内存池主要解决的当然是效率的问题,其次如果作为系统的内存分配器的角度,还需要解决一下内存碎片的问题。那么什么是内存碎片呢?
在内存分配和释放过程中,由于分配的内存块大小和位置的不同,导致内存空间中出现了许多不连续的小块空闲内存,而这些小块内存无法被有效利用来满足新的内存请求。内存碎片分为内部碎片和外部碎片两种。
上面我们讲的外碎片问题。
内部碎片(Internal Fragmentation)
- 外部碎片是一些空闲的连续内存区域太小,这些内存空间不连续,以至于合计的内存足够,但是不能满足一些的内存分配申请需求。
外部碎片(External Fragmentation)
- 内部碎片是由于一些对齐的需求,导致分配出去的空间中一些内存无法被利用。内碎片问题,我们后面项目就会看到,那会再进行更准确的理解。
2.4、malloc
C/C++中我们要动态申请内存都是通过malloc去申请内存,但是我们要知道,实际我们不是直接去堆获取内存的,而malloc就是一个内存池。malloc() 相当于向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用。当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”。
- malloc的实现方式有很多种,一般不同编译器平台用的都是不同的。比如windows的vs系列用的微软自己写的一套,linux gcc用的glibc中的ptmalloc。
下面有几篇关于这块的文章,大概可以去简单看看了解一下,关于ptmalloc,学完我们的项目以后,有兴趣uu们可以去看看他的实现细节。
一文了解,Linux内存管理,malloc、free 实现原理
malloc()背后的实现原理——内存池
malloc的底层实现(ptmalloc)
2.5、设计定长的内存池
作为程序员(C/C++)我们知道申请内存使用的是malloc,malloc其实就是一个通用的大众货,什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能,下面我们就先来设计一个定长内存池做个开胃菜,当然这个定长内存池在我们后面的高并发内存池中也是有价值的,所以学习它的目的有两层,先熟悉一下简单内存池是如何控制的,第二他会作为我们后面内存池的一个基础组件。
固定大小的内存申请释放需求特点:
- 1、性能达到极致
- 2、不考虑内存碎片问题等
如何实现定长?
在实现定长内存池时要做到 " 定长 " 有很多种方法,比如我们可以使用非类型模板参数,使得在该内存池中申请到的对象的大小都是N。
template<size_t N>
class ObjectPool
{};
此外,定长内存池也叫做对象池,在创建对象池时,对象池可以根据传入的对象类型的大小来实现" 定长 ",因此我们可以通过使用模板参数来实现 " 定长 ",比如创建定长内存池时传入的对象类型是int,那么该内存池就只支持4字节大小内存的申请和释放。
template<class T>
class ObjectPool
{};
如何直接向堆申请页为单位的大块内存:
既然是内存池,那么我们首先得向系统申请一块内存空间,然后对其进行管理。要想直接向堆申请内存空间,在Windows下,可以调用VirtualAlloc函数;在Linux下,可以调用brk或mmap函数。
2.5.1、 VirtualAlloc
VirtualAlloc
在进程的虚拟地址空间中分配或保留内存!
#include <windows.h>LPVOID VirtualAlloc
(LPVOID IpAddress, // 要分配的内存区域的地址SIZE_T dwSize, // 分配的大小DWORD flAllocationType,// 分配的类型DWORD flProtect // 该内存的初始保护属性
);
参数:
-
lpAddress:指定要分配的内存区域的起始地址。如果此参数为nullptr,则系统会自动决定分配内存区域的位置,并且按64KB向上取整。
-
dwSize:指定要分配或保留的区域的大小,以字节为单位。系统会根据这个大小一直分配到下页的边界。
-
flAllocationType:指定分配类型,可以是指定或合并以下标志:
- MEM_COMMIT:为指定地址空间提交物理内存。
- MEM_RESERVE:保留指定地址空间,不分配物理内存。这样可以阻止其他内存分配函数(如malloc和LocalAlloc等)再使用已保留的内存范围,直到它被释放。
- MEM_TOP_DOWN:在尽可能高的地址分配内存。
- MEM_LARGE_PAGES:分配内存时使用大页面支持。大小和对齐必须是一个大页面的最低倍数。
-
flProtect:指定被分配区域的访问保护方式。可能的值包括:
- PAGE_READWRITE:区域可以执行代码,应用程序可以读写该区域。
- PAGE_READONLY:区域为只读。如果应用程序试图访问区域中的页,将会被拒绝访问。
- PAGE_NOACCESS:任何访问该区域的操作将被拒绝。
- PAGE_GUARD:区域第一次被访问时进入一个STATUS_GUARD_PAGE异常,这个标志要和其他保护标志合并使用。
- PAGE_NOCACHE:RAM中的页映射到该区域时将不会被微处理器缓存(cached)。
返回值:
- 如果函数调用成功,则返回分配的首地址;
- 如果调用失败,则返回nullptr。可以通过GetLastError函数来获取错误信息。
2.5.1、 brk
brk
改变进程的数据段的结束地址,允许进程请求或释放由操作系统为其数据段分配的额外内存空间。
#include <unistd.h>int brk(void *addr);
参数:
addr
:这是一个指向void的指针,指定了新的数据段结束地址。如果这个地址高于当前的数据段结束地址,系统尝试为进程分配更多的内存,从而扩展数据段。如果这个地址低于当前的结束地址,系统可能会释放不再需要的内存,从而收缩数据段。
返回值:
- 成功时,
brk()
返回0。 - 失败时,返回-1,并设置
errno
以指示错误类型。
使用场景:
brk()
主要用于手动管理内存的低级系统编程中。在大多数现代应用程序中,推荐使用更高级的内存分配函数,如malloc()
、calloc()
和realloc()
,这些函数提供了更灵活的内存管理功能,并且通常更易于使用和维护。
2.5.1、 mmap
mmap
mmap()
是一种内存映射文件的机制,它允许文件的内容被映射到进程的地址空间中,从而可以像访问普通内存一样访问文件的内容。
#include <sys/mman.h>void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
参数:
-
addr:映射区域的起始地址。如果设置为
nullptr
,则由系统自动选择一个合适的、页对齐的地址作为映射区域的起始地址。 -
length:需要映射的字节数,它决定了映射区域的大小。这个值必须是页大小的整数倍,否则系统可能会将其向上取整至页大小。
-
prot:映射区域的保护方式,它指定了映射区域的内存保护属性。可以是以下几种方式的组合:
PROT_READ
:映射区域可读。PROT_WRITE
:映射区域可写。PROT_EXEC
:映射区域可执行。PROT_NONE
:映射区域不可访问。
-
flags:影响映射区域的各种特性。在调用
mmap()
时,必须定MAP_SHARED
或MAP_PRIVATE
之一。其他可选的标志位包括:MAP_SHARED
:对映射区域的写入数据会复制回文件内,而且允许其他映射该文件的进程共享。MAP_PRIVATE
:对映射区域的写入操作会产生一个映射文件的复制,即私人的“写入时复制”(copy on write)。对此区域作的任何修改都不会写回原来的文件内容。MAP_FIXED
:如果参数addr
所指的地址无法成功建立映射时,则放弃映射,不对地址做修正。通常不鼓励使用此标志位。MAP_ANONYMOUS
:建立匿名映射。此时会忽略参数fd
,不涉及文件,而且映射区域无法和其他进程共享。MAP_DENYWRITE
:只允许对映射区域的写入操作,其他对文件直接写入的操作将会被拒绝。MAP_LOCKED
:将映射区域锁定住,这表示该区域不会被置换(swap)。
-
fd:要映射到内存中的文件描述符。如果使用匿名内存映射时(即
flags
中设置了MAP_ANONYMOUS
),fd
设为-1
。 -
offset:文件映射的偏移量,它表示从文件的哪个字节开始映射。通常设置为
0
,代表从文件最前方开始对应。offset
必须是分页大小的整数倍。
返回值:
- 成功时,
mmap()
返回指向映射区域首地址的指针。 - 失败时,返回
MAP_FAILED
(其值通常为(void*)-1
),并设置errno
以指示错误类型。
在堆上按页申请空间(Windows环境)
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else// linux下brk mmap等
#endifif (ptr == nullptr)throw std::bad_alloc();return ptr;
}
此处是在windows环境下运行,因此我们也可以给头文件添加条件编译!
#ifdef _WIN32
#include <windows.h>
#else
//
#endif
2.5.2、成员变量
1、对于向堆申请到的大块内存,我们可以用一个指针来对其进行管理,但仅用一个指针肯定是不够的,我们还需要用一个变量来记录这块内存的长度。
2、由于此后我们需要将这块内存进行切分,为了方便切分操作,指向这块内存的指针最好是字符指针,因为指针的类型决定了指针向前或向后走一步有多大距离,对于字符指针来说,当我们需要向后移动n个字节时,直接对字符指针进行加n操作即可。
3、为了方便判断内存空间是否足够,使用一个变量记录剩余内存大小。
4、释放回来的定长内存块也需要被管理,我们可以将这些释放回来的定长内存块链接成一个链表,这里我们将管理释放回来的内存块的链表叫做自由链表,为了能找到这个自由链表,我们还需要一个指向自由链表的指针。
因此,定长内存池当中包含三个成员变量:
- _memory:指向大块内存的指针。
- _remainBytes:大块内存切分过程中剩余字节数。
- _freeList:内存还回来过程中链接的自由链表的头指针。
template<class T>
class ObjectPool
{
private:char* _memory = nullptr; // 指向一块大内存的指针size_t _remainBytes = 0;// 大块内存切分过程中剩余的内存void* _freeList = nullptr; // 内存还回来过程中链接的自由链表的头指针
};
2.5.3、管理释放的对象
定长内存池如何管理释放的空间?
- 对于还回来的定长内存块,我们可以用自由链表将其链接起来,但我们并不需要为其专门定义链式结构,我们可以让内存块的前4个字节(32位平台)或8个字节(64位平台)作为指针,存储后面内存块的起始地址即可。
- 因此在向自由链表插入被释放的内存块时,先让该内存块的前4个字节或8个字节存储自由链表中第一个内存块的地址,然后再让_freeList指向该内存块即可,也就是一个简单的链表头插操作。
此处有一个问题:如何让一个指针在32位平台下解引用后能向后访问4个字节,在64位平台下解引用后能向后访问8个字节?
此处可以使用一个很妙的方法,使用二级指针!
- 当我们需要访问一个内存块的前4/8个字节时,我们就可以先将该内存块的地址先强转为二级指针,由于二级指针存储的是一级指针的地址,二级指针解引用能向后访问一个指针的大小,因此在32位平台下访问的就是4个字节,在64位平台下访问的就是8个字节,此时我们访问到了该内存块的前4/8个字节。
// 获取地址
static void*& Next(void* obj)
{return *(void**)obj;
}
2.5.4、释放对象
在释放对象时,我们应该显示调用该对象的析构函数清理该对象(释放自定义类型内部结构),因为该对象可能还管理着其他某些资源,如果不对其进行清理那么这些资源将无法被释放,就会导致内存泄漏。
void Delete(T* obj)
{// 显示调用析构,为了自定义类型,释放内部结构obj->~T();// 将内存头插到链表上,obj的前一个指针变量的大小存放下一个数据的地址*(void**)obj = _freeList;// obj 前面一个指针大小存放链表头结点地址// 更新头结点_freeList = obj;
}
2.5.5、申请对象
1、当我们申请对象时,内存池应该优先把还回来的内存块对象再次重复利用,因此如果自由链表当中有内存块的话,就直接从自由链表头删一个内存块进行返回即可。
2、如果自由链表当中没有内存块,那么我们就在大块内存中切出定长的内存块进行返回,当内存块切出后及时更新
_memory
指针的指向,以及_remainBytes
的值即可。
注意:
- 由于当内存块释放时我们需要将内存块链接到自由链表当中,因此我们必须保证切出来的对象至少能够存储得下一个地址,所以当对象的大小小于当前所在平台指针的大小时,需要按指针的大小进行内存块的切分。
- 当大块内存已经不足以切分出一个对象时,我们就应该调用我们封装的SystemAlloc函数,再次向堆申请一块内存空间,此时也要注意及时更新_memory指针的指向,以及_remainBytes的值。
- 与释放对象时需要显示调用该对象的析构函数一样,当内存块切分出来后,我们也应该使用定位new,显示调用该对象的构造函数对其进行初始化。
T* New()
{T* obj = nullptr;// 0.优先使用还回来的内存if (_freeList){// next存放指向下一个对象的地址void* next = *(void**)_freeList;obj = (T*)_freeList;// 迭代到下一个_freeList = next;}else{// 1.空间不够申请一大块内存if (_remainBytes < sizeof(T)){_remainBytes = 128 * 1024;// 使用C语言开辟空间接口//_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;
}
2.5.6、性能测试
下面我们将实现的定长内存池和malloc/free进行性能对比,测试代码如下:
struct TreeNode
{int _val;TreeNode* _left;TreeNode* _right;TreeNode():_val(0), _left(nullptr), _right(nullptr){}
};void TestObjectPool()
{// 申请释放的轮次const size_t Rounds = 5;// 每轮申请释放多少次const size_t N = 1000000;std::vector<TreeNode*> v1;v1.reserve(N);size_t begin1 = clock();for (size_t j = 0; j < Rounds; ++j){for (int i = 0; i < N; ++i){v1.push_back(new TreeNode);}for (int i = 0; i < N; ++i){delete v1[i];}v1.clear();}size_t end1 = clock();std::vector<TreeNode*> v2;v2.reserve(N);ObjectPool<TreeNode> TNPool;size_t begin2 = clock();for (size_t j = 0; j < Rounds; ++j){for (int i = 0; i < N; ++i){v2.push_back(TNPool.New());}for (int i = 0; i < N; ++i){TNPool.Delete(v2[i]);}v2.clear();}size_t end2 = clock();cout << "new cost time:" << end1 - begin1 << endl;cout << "object pool cost time:" << end2 - begin2 << endl;
}
在代码中,我们先用new申请若干个TreeNode对象,然后再用delete将这些对象再释放,通过clock函数得到整个过程消耗的时间。(new和delete底层就是封装的malloc和free)
从上面的测试结果可以看到,定长内存池消耗的时间比malloc/free消耗的时间要短。这就是因为malloc是一个通用的内存池,而定长内存池是专门针对申请定长对象而设计的,因此在这种特殊场景下定长内存池的效率更高。