1. 默认内存管理函数的不足(为什么使用内存池)
利用默认的内存管理操作符 new/delete 和函数 malloc()/free() 在堆上分配和释放内存会有一些额外的开销。
系统在接收到分配一定大小内存的请求时,首先查找内部维护的内存空闲块表,并且需要根据一定的算法(例如分配最先找到的不小于申请大小的内存块给请求者,或者分配最适于申请大小的内存块,或者分配最大空闲的内存块等)找到合适大小的空闲内存块。如果该空闲内存块过大,还需要切割成已分配的部分和较小的空闲块。然后系统更新内存空闲块表,完成一次内存分配。类似地,在释放内存时,系统把释放的内存块重新加入到空闲内存块表中。如果有可能的话,可以把相邻的空闲块合并成较大的空闲块。默认的内存管理函数还考虑到多线程的应用,需要在每次分配和释放内存时加锁,同样增加了开销。
可见,如果应用程序频繁地在堆上分配和释放内存,会导致性能的损失。并且会使系统中出现大量的内存碎片,降低内存的利用率。默认的分配和释放内存算法自然也考虑了性能,然而这些内存管理算法的通用版本为了应付更复杂、更广泛的情况,需要做更多的额外工作。而对于某一个具体的应用程序来说,适合自身特定的内存分配释放模式的自定义内存池可以获得更好的性能。
2. 内存池简介
2.1 内存池的定义
池化技术是一种降低频繁操作导致开销过大的方法,如内存池、线程池、进程池和对象池等。
内存池(Memory Pool)是一种内存分配方式。通常我们习惯直接使用new、malloc等API申请内存,这样做的缺点在于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。
2.2 内存池的实现原理
内存池则是在真正使用内存之前,预先申请分配一定数量、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够再继续申请新的内存。
(用malloc申请一大块内存,当要分配的时候,从这一大块内存中一点一点的分配,当一大块内存分配的差不多的时候,再用malloc再申请一大块内存,然后再一点一点的分配给你)
2.3 内存池的优点
减少malloc的次数,减少malloc()调用次数就意味着减少对内存的浪费,使得内存分配效率得到提升。
2.4 内存池的分类
应用程序自定义的内存池根据不同的适用场景又有不同的类型。从线程安全的角度来分,内存池可以分为单线程内存池和多线程内存池。单线程内存池整个生命周期只被一个线程使用,因而不需要考虑互斥访问的问题;多线程内存池有可能被多个线程共享,因此需要在每次分配和释放内存时加锁。相对而言,单线程内存池性能更高,而多线程内存池适用范围更加广泛。
从内存池可分配内存单元大小来分,可以分为固定内存池和可变内存池。所谓固定内存池是指应用程序每次从内存池中分配出来的内存单元大小事先已经确定,是固定不变的;而可变内存池则每次分配的内存单元大小可以按需变化,应用范围更广,而性能比固定内存池要低。
3. 内存池的实现v1.0
3.1 程序源码
通过#define MYMEMPOOL 1,可以使用无内存的申请空间操作。如果注释掉宏定义,将使用普通的申请空间操作。
#include <iostream>
using namespace std;
#include <ctime>
#define MYMEMPOOL 1
class A
{
public:
static void *operator new(size_t size);
static void operator delete(void *phead);
static int m_iCout; //分配计数统计,每new一次,就统计一次
static int m_iMallocCount; //每malloc一次,就统计一次
private:
A *next;
static A* m_FreePosi; //总是指向一块可以分配出去的内存的首地址
static int m_sTrunkCout; //一次分配多少倍的该类内存
};
int A::m_iCout = 0;
int A::m_iMallocCount = 0;
A *A::m_FreePosi = nullptr;
int A::m_sTrunkCout = 5; //一次分配5倍的该类内存作为内存池子的大小
void *A::operator new(size_t size)
{
#ifndef MYMEMPOOL
A *ppoint = (A*)malloc(size);
return ppoint;
#endif
A *tmplink;
if (m_FreePosi == nullptr)
{
//为空,我要申请内存,要申请一大块内存
size_t realsize = m_sTrunkCout * size; //申请m_sTrunkCout这么多倍的内存
m_FreePosi = reinterpret_cast<A*>(new char[realsize]); //传统new,调用的系统底层的malloc
tmplink = m_FreePosi;
//把分配出来的这一大块内存(5小块),彼此要链起来,供后续使用
for (; tmplink != &m_FreePosi[m_sTrunkCout - 1]; ++tmplink)
{
tmplink->next = tmplink + 1;
}
tmplink->next = nullptr;
++m_iMallocCount;
}
tmplink = m_FreePosi;
m_FreePosi = m_FreePosi->next;
++m_iCout;
return tmplink;
}
void A::operator delete(void *phead)
{
#ifndef MYMEMPOOL
free(phead);
return;
#endif
(static_cast<A*>(phead))->next = m_FreePosi;
m_FreePosi = static_cast<A*>(phead);
}
void func()
{
clock_t start, end; //包含头文件 #include <ctime>
start = clock();
//for (int i = 0; i < 500'0000; i++)
for (int i = 0; i < 15; i++)
{
A *pa = new A();
printf("%p\n", pa);
}
end = clock();
cout << "申请分配内存的次数为:" << A::m_iCout << " 实际malloc的次数为:" << A::m_iMallocCount << " 用时(毫秒): " << end - start << endl;
}
int main()
{
func();
return 1;
}
3.2 实现过程分析
这个C++程序实现了一个简单的内存池(Memory Pool)。内存池是一种用于管理内存分配的数据结构,它通过预先分配大块的内存,然后以较小的单位进行释放,以减少频繁的内存分配和释放导致的开销。
以下是程序的主要步骤和功能:
1.定义了一个名为A的类,该类具有以下成员:
- operator new和operator delete:这两个成员函数用于分配和释放内存。
- m_iCout:一个静态成员变量,用于统计new操作的数量。
- m_iMallocCount:一个静态成员变量,用于统计malloc操作的数量。
- m_FreePosi:一个静态成员指针,指向一块可以分配出去的内存的首地址。
- m_sTrunkCout:一个静态成员变量,表示一次要分配多少倍该类内存。
2.在主函数中,调用了func()函数。在func()函数中,执行了以下操作:
- 记录开始时间。
- 执行一个循环,循环15次,每次创建一个A类型的对象(通过调用new A())。
- 记录结束时间。
- 输出申请分配内存的次数(即new A()的次数)、实际进行malloc的次数以及执行时间。
3.A::operator new:这个成员函数用于分配内存。首先检查是否有可用的内存(即检查m_FreePosi是否为空)。如果为空,则通过调用new char[realsize]来分配一块大小为m_sTrunkCout * size的内存,并将这块内存的首地址转换为A*类型赋值给m_FreePosi。然后,将这块内存分割成若干个小块,并链起来供后续使用。如果已经有可用的内存,则从链表的头部取出一个小块,并更新相关的计数。
4.A::operator delete:这个成员函数用于释放内存。首先将传入的指针的下一个节点设置为m_FreePosi,然后将m_FreePosi更新为传入的指针。
通过以上步骤,程序实现了一个简单的内存池。在程序中,创建和删除对象的操作都通过内存池来进行,减少了频繁的内存分配和释放操作,提高了程序的性能。
3.3 运行结果
可以发现当使用内存池创建15个对象,我们实际上只需要申请三次空间,时间需要82ms
当不使用内存池时,运行结果如下:
通过普通方法创建15个对象,我们需要申请15次空间,但时间需要51ms
总结:单次申请一大块连续的内存相比于每次申请小块内存,内存碎片大大减少,同时减少了malloc的次数,降低了内存的开销(用来监视malloc分配的信息的内存大大减少)。
3.4 不足点
我们通过上面的运行结果可以看到使用内存池虽然分配空间的次数大大减少,但是消耗的时间却变多了。
但随着调用次数的增多,内存池的优势就显现出来了,如下图我们创建500‘000对象
4. 内存池的实现v2.0(嵌入式指针)
4.1 工作原理
借用A对象所占用的内存空间中的前4个字节,这4个字节用来链住这些空闲的内存块;
一旦某一块被分配出去,那么这个块的前4个字节就不再需要,此时这4个字节可以被正常使用;
4.2 使用前提
一般应用在内存池相关的代码中,成功使用嵌入式指针有个前提条件:类A对象的sizeof必须不小于4个字节(这里和前面的四个字节为32位系统中指针的大小;如果64位系统,大小则为8字节)
4.3 嵌入式指针应用举例
class TestEP
{
public:
int m_i;
int m_j;
public:
struct obj //结构
{
//成员,是个指针
struct obj *next; //这个next就是个嵌入式指针
//自己是一个obj结构对象,那么把自己这个对象的next指针指向另外一个obj结构对象,
//最终,把多个自己这种类型的对象通过链串起来;
};
};
void func()
{
TestEP mytest;
cout << sizeof(mytest) << endl; //8
TestEP::obj *ptemp; //定义一个指针
ptemp = (TestEP::obj *)&mytest; //把对象mytest首地址给了这个指针ptemp,这个指针ptemp指向对象mytest首地址;
cout << sizeof(ptemp->next) << endl; //4
cout << sizeof(TestEP::obj) << endl; //4
ptemp->next = nullptr;
}
这里的流程的意思是:将生成的 mytest 对象通过指针转换变成 obj的地址类型, 同时生成一个新的obj指针用来存放它,转换类型以后mytest对象的前半部分则为obj对象,此时则可以调用它的next 对象指向其他的 obj类型地址。
4.4 改进内存池实现(嵌入式指针)
#include <iostream>
using namespace std;
namespace _nmsp4 {
class myallocator {
public:
void *allocate(size_t size) {
obj *tmplink;
if (m_FreePosi == nullptr) {
size_t realsize = m_sTrunkCout * size; //申请m_TrunkCout倍内存
m_FreePosi = reinterpret_cast<obj *>(malloc(realsize)); //这里的new是系统的new
tmplink = m_FreePosi;
//把分配出来的这块内存,彼此要连起来,供后续使用
for (int i = 0; i< m_sTrunkCout - 1; ++i) {
tmplink->next = reinterpret_cast<obj *>(reinterpret_cast<char *>(tmplink) + size);
tmplink = tmplink->next;
}
tmplink->next = nullptr;
}
tmplink = m_FreePosi;
m_FreePosi = m_FreePosi->next;
return tmplink;
}
void deallocate(void *phead) {
reinterpret_cast<obj *>(phead)->next = m_FreePosi;
m_FreePosi = reinterpret_cast<obj *>(phead);
}
private:
struct obj {
struct obj *next;
};
obj* m_FreePosi;
int m_sTrunkCout = 5;//一次分配多少该类内存
};
class A {
public:
int m_i;
int m_j;
static myallocator myalloc;
static void *operator new(size_t size) {
return myalloc.allocate(size);
}
static void operator delete(void *phead) {
myalloc.deallocate(phead);
}
};
myallocator A::myalloc;
void func() {
A *mypa[100];
for (int i = 0; i < 15; ++i) {
mypa[i] = new A();
printf("%p\n", mypa[i]);
}
}
}
int main()
{
_nmsp4::func();
return 0;
}
运行结果
嵌入式指针可参考: