概述
在c/c++中,内存分配(如malloc或new)会使用很多时间。
一个程序会随着长时间的运行和内存的申请释放而变得越来越慢,内存也会随着时间逐渐碎片化。特别是高频率的进行小内存申请释放,此问题变得尤其严重。
相关视频讲解:
你或许不知道高性能服务器为什么需要内存池?内存如何分配? 如何设计内存 ?
Linux后端开发网络底层原理知识学习提升,完善技术栈,内容知识点包括Linux,Nginx,ZeroMQ,MySQL,Redis,线程池,MongoDB,ZK,Linux内核,CDN,P2P,epoll,Docker,TCP/IP,协程,DPDK等等。
点击观看:c/c++Linux后台服务器开发高级架构师学习视频资料
解决方案:定制内存池
为解决上述问题,一个(可能的)的解决方案就是使用内存池。
“内存池”在初始化时,分配一个大块内存(称 原始内存块),并且将此内存分割为一些小的内存块。当你需要请求分配内存时,则从内存池中取出事先分配好的内存,而不是向OS申请。内存池最大的优势在于:
1、极少的(甚至没有)堆碎片整理
2、较之普通内存分配(如malloc,new),有着更快的速度
额外的,你还将获得如下好处:
1、检测任意的指针是否指向内存池内
2、生成"heap-dump"
3、各种 内存泄漏 检测:当你没有释放之前申请的内存,内存池将抛出断言
如何工作?
让我们看看内存池的UML模型图:
图中简要的描述了CMemoryPool class,更多的细节请查看源码中class声明。
那么,CMemoryPool如何实际工作?
关于 MemoryChunks
正如你在UML图中所看到的,内存池维护着一个SMemoryChunk链表,并管理着三个指向SMemoryChunk结构的指针(m_ptrFirstChunk, m_ptrLastChunk, and m_ptrCursorChunk)。这些指针指向SMemoryChunk链表的不同位置。让我们更深入的观察SMemoryChunk:(在内存池实现中,SMemoryChunk封装了原始内存块的各个部分 -- 译者注)
-
typedef
struct SMemoryChunk
-
{
-
TByte *Data ;
// 常规数据指针
-
std::
size_t DataSize ;
// 内存块容量
-
std::
size_t UsedSize ;
// 内存块当前使用大小
-
bool IsAllocationChunk ;
// 为true时, 内存块已被分配,可用free之类的函数释放
-
SMemoryChunk *Next ;
// 指向内存块链表中的下一个内存块,可能为null
第一步:预分配内存
-
当你调用CMemoryPool的构造函数,内存池会向OS申请原始内存块。
-
/******************
-
Constructor
-
******************/
-
CMemoryPool::CMemoryPool(
const
std::
size_t &sInitialMemoryPoolSize,
-
const
std::
size_t &sMemoryChunkSize,
-
const
std::
size_t &sMinimalMemorySizeToAllocate,
-
bool bSetMemoryData)
-
{
-
m_ptrFirstChunk =
NULL ;
-
m_ptrLastChunk =
NULL ;
-
m_ptrCursorChunk =
NULL ;
-
m_sTotalMemoryPoolSize =
0 ;
-
m_sUsedMemoryPoolSize =
0 ;
-
m_sFreeMemoryPoolSize =
0 ;
-
m_sMemoryChunkSize = sMemoryChunkSize ;
-
m_uiMemoryChunkCount =
0 ;
-
m_uiObjectCount =
0 ;
-
m_bSetMemoryData = bSetMemoryData ;
-
m_sMinimalMemorySizeToAllocate = sMinimalMemorySizeToAllocate ;
-
// Allocate the Initial amount of Memory from the Operating-System...
-
AllocateMemory(sInitialMemoryPoolSize) ;
-
}
-
所有的成员的函数初始化在此完成,最后AllocateMemory将完成向OS申请原始内存块的任务。
-
/******************
-
AllocateMemory
-
******************/
-
<CODE>
bool CMemoryPool::AllocateMemory(const std::size_t &sMemorySize)
-
{
-
std::
size_t sBestMemBlockSize = CalculateBestMemoryBlockSize(sMemorySize) ;
-
// allocate from Operating System
-
TByte *ptrNewMemBlock = (TByte *)
malloc(sBestMemBlockSize) ;
-
...
-
那么,内存池如何来管理这些数据呢?
第二步:内存分块
回忆前述,内存池管理使用SMemoryChunk链表来管理数据。在向OS申请原始内存块后,
我们还没有在其上建立SMemoryChunk。
图中所示的为初始化分配后的内存池。
-
我们需要分配一组SMemoryChunk,用于管理原始内存块:
-
//(AllocateMemory() continued) :
-
...
-
unsigned
int uiNeededChunks = CalculateNeededChunks(sMemorySize) ;
-
// allocate Chunk-Array to Manage the Memory
-
SMemoryChunk *ptrNewChunks =
-
(SMemoryChunk *)
malloc((uiNeededChunks *
sizeof(SMemoryChunk))) ;
-
assert(((ptrNewMemBlock) && (ptrNewChunks))
-
&&
"Error : System ran out of Memory") ;
-
...
CalculateNeededChunks函数用于计算需要分配的SMemoryChunk的数量。分配后,ptrNewChunks指向这组SMemoryChunk。注意,SMemoryChunk中目前只是持有垃圾数据,我们还没有为SMemoryChunk的成员关联至原始内存块。
文章福利 Linux后端开发网络底层原理知识学习提升 点击 学习资料 获取,完善技术栈,内容知识点包括Linux,Nginx,ZeroMQ,MySQL,Redis,线程池,MongoDB,ZK,Linux内核,CDN,P2P,epoll,Docker,TCP/IP,协程,DPDK等等。
最后,AllocateMemory函数将为所有的SMemoryChunk关联至原始内存块。
-
//(AllocateMemory() continued) :
-
...
-
// Associate the allocated Memory-Block with the Linked-List of MemoryChunks
-
return LinkChunksToData(ptrNewChunks, uiNeededChunks, ptrNewMemBlock) ;
-
-
让我们进入LinkChunksToData中一窥究竟:
-
/******************
-
LinkChunksToData
-
******************/
-
bool CMemoryPool::LinkChunksToData(SMemoryChunk *ptrNewChunks,
-
unsigned
int uiChunkCount, TByte *ptrNewMemBlock)
-
{
-
SMemoryChunk *ptrNewChunk =
NULL ;
-
unsigned
int uiMemOffSet =
0 ;
-
bool bAllocationChunkAssigned =
false ;
-
for(
unsigned
int i =
0; i < uiChunkCount; i++)
-
{
-
if(!m_ptrFirstChunk)
-
{
-
m_ptrFirstChunk = SetChunkDefaults(&(ptrNewChunks[
0])) ;
-
m_ptrLastChunk = m_ptrFirstChunk ;
-
m_ptrCursorChunk = m_ptrFirstChunk ;
-
}
-
else
-
{
-
ptrNewChunk = SetChunkDefaults(&(ptrNewChunks[i])) ;
-
m_ptrLastChunk->Next = ptrNewChunk ;
-
m_ptrLastChunk = ptrNewChunk ;
-
}
-
-
uiMemOffSet = (i * ((
unsigned
int) m_sMemoryChunkSize)) ;
-
m_ptrLastChunk->Data = &(ptrNewMemBlock[uiMemOffSet]) ;
-
// 第一个SMemoryChunk被称为“AllocationChunk”。
-
// 这意味着,它持有原始内存块的指针并能够利用它释放原始内存块
-
if(!bAllocationChunkAssigned)
-
{
-
m_ptrLastChunk->IsAllocationChunk =
true ;
-
bAllocationChunkAssigned =
true ;
-
}
-
}
-
return RecalcChunkMemorySize(m_ptrFirstChunk, m_uiMemoryChunkCount) ;
-
}
-
让我们一步步的来看这个重要的函数:第一行检查在SMemoryChunk链表中是否已经有了可用的
-
SMemoryChunk:
-
...
-
if(!m_ptrFirstChunk)
-
...
-
在最初始进入循环,此条件不成立。那么,我们为一些内部的成员进行关联。
-
-
...
-
m_ptrFirstChunk = SetChunkDefaults(&(ptrNewChunks[
0])) ;
-
m_ptrLastChunk = m_ptrFirstChunk ;
-
m_ptrCursorChunk = m_ptrFirstChunk ;
-
...
-
m_ptrFirstChunk这时指向SMemoryChunk中的第一个元素。每一个SMemoryChunk管理的内存块大小由m_sMemoryChunkSize指定。这些内存块来自于原始内存块,偏移量
-
uiMemOffSet指示着每一个SMemoryChunk所管理的内存起始点处于原始内存块的何处。
-
uiMemOffSet = (i * ((
unsigned
int) m_sMemoryChunkSize)) ;
-
m_ptrLastChunk->Data = &(ptrNewMemBlock[uiMemOffSet]) ;
-
额外的,每个新的SMemoryChunk都将被指定为新的m_ptrLastChunk。
-
...
-
m_ptrLastChunk->Next = ptrNewChunk ;
-
m_ptrLastChunk = ptrNewChunk ;
-
...
-
经过循环之后,内存池中的SMemoryChunk链表将被成功的与原始内存块关联。
最终,我们重新计算每一个SMemoryChunk能管理到的内存尺寸。这个步骤相当耗时,并且必须在每次从OS附加新的内存到内存池后调用。所有被计算出的尺寸,将被DataSize成员持有。
-
/******************
-
RecalcChunkMemorySize
-
******************/
-
bool CMemoryPool::RecalcChunkMemorySize(SMemoryChunk *ptrChunk,
-
unsigned
int uiChunkCount)
-
{
-
unsigned
int uiMemOffSet =
0 ;
-
for(
unsigned
int i =
0; i < uiChunkCount; i++)
-
{
-
if(ptrChunk)
-
{
-
uiMemOffSet = (i * ((
unsigned
int) m_sMemoryChunkSize)) ;
-
ptrChunk->DataSize =
-
(((
unsigned
int) m_sTotalMemoryPoolSize) - uiMemOffSet) ;
-
ptrChunk = ptrChunk->Next ;
-
}
-
else
-
{
-
assert(
false &&
"Error : ptrChunk == NULL") ;
-
return
false ;
-
}
-
}
-
return
true ;
-
}
在RecalcChunkMemorySize之后,每一个SMemoryChunk将知道自己需要释放多大的内存。因此,这使得 确定某个SMemoryChunk能否持有一个指定大小的内存 将变得非常容易:当DataSize成员大于或等于请求的内存尺寸并且UsedSize成员值为0,这时此SMemoryChunk将能够满足用户的需要。让我们来看一个具体的例子来加深对这个机制的理解,假设内存池为600字节,并且每个SMemoryChunk为100字节。
第三步:向内存池请求内存
现在,如果用户向内存池请求内存,那会发生什么呢?最开始,所有的SMemoryChunk在内存池中都是闲置可用状态:
让我们看看GetMemory函数吧:
-
/******************
-
GetMemory
-
******************/
-
void *CMemoryPool::GetMemory(const std::size_t &sMemorySize)
-
{
-
std::
size_t sBestMemBlockSize = CalculateBestMemoryBlockSize(sMemorySize) ;
-
SMemoryChunk *ptrChunk =
NULL ;
-
while(!ptrChunk)
-
{
-
// 搜索是否有符合条件的SMemoryChunk?
-
ptrChunk = FindChunkSuitableToHoldMemory(sBestMemBlockSize) ;
-
if(!ptrChunk)
-
{
-
// 没有SMemoryChunk符合条件
-
// 内存池太小了,需要向OS申请新的内存
-
sBestMemBlockSize = MaxValue(sBestMemBlockSize, CalculateBestMemoryBlockSize(m_sMinimalMemorySizeToAllocate)) ;
-
AllocateMemory(sBestMemBlockSize) ;
-
}
-
}
-
// 一个合适的SMemoryChunk被找到
-
// 校正其 TotalSize/UsedSize 成员的值
-
m_sUsedMemoryPoolSize += sBestMemBlockSize ;
-
m_sFreeMemoryPoolSize -= sBestMemBlockSize ;
-
m_uiObjectCount++ ;
-
SetMemoryChunkValues(ptrChunk, sBestMemBlockSize) ;
-
// 最终将内存指针返回给用户
-
return ((
void *) ptrChunk->Data) ;
-
}
当用户向内存池发出请求,内存池搜索SMemoryChunk链表,并在其中找到满足条件的SMemoryChunk,“满足条件”意味着:
1、DataSize必须大于或等于请求的大小
2、UsedSize必须为0
FindChunkSuitableToHoldMemory如果其返回NULL,那么就表示在内存池中没有可用的内存。这将会引发AllocateMemory函数的调用(前述),此函数会向OS申请更多的内存。
如果返回非NULL,那么便找到了可用的SMemoryChunk。
示例
假设,用户向内存池申请250字节:
如你所见,每一个SMemoryChunk管理100字节,所以,250字节并不是100的整数倍。这会引发什么情况呢?GetMemory将会返回指向第一个SMemoryChunk的指针,并设置其的UsedSize成员为300字节,因为300是100的整数倍数值中最小的,并且其大于250。多出的50字节称为"memory overhead".
当FindChunkSuitableToHoldMemory寻找可用的SMemoryChunk时,它将只会从一个闲置的SMemoryChunk跳到另一个闲置的SMemoryChunk。这意味着,如果又有申请内存的请求达到,例子中的第四个SMemoryChunk将是寻找的起始点。
如何使用代码
代码的使用简单而直接:
只需要在你的程序中包含"CMemoryPool.h",并附加源码文件至你的IDE/makefile:
- CMemoryPool.h
- CMemoryPool.cpp
- IMemoryBlock.h
- SMemoryChunk.h
你需要创建一个CMemoryPool实例,并从中分配内存。所有的内存池配置都在CMemoryPool的构造函数中被完成。
使用示例
-
MemPool::CMemoryPool *g_ptrMemPool = new MemPool::CMemoryPool() ;
-
char *ptrCharArray = (
char *) g_ptrMemPool->GetMemory(
100) ;
-
...
-
g_ptrMemPool->FreeMemory(ptrCharArray,
100) ;
-
delete g_ptrMemPool ;
兴趣点
内存诊断
-
你可以调用WriteMemoryDumpToFile函数来输出内存诊断信息文件。让我们看下源码附带的MyTestClass_OPOverload类的构造函数。(此类重载了
new和
delete操作,使用了内存池操作)
-
MyTestClass_OPOverload()
-
{
-
m_cMyArray[
0] =
'H' ;
-
m_cMyArray[
1] =
'e' ;
-
m_cMyArray[
2] =
'l' ;
-
m_cMyArray[
3] =
'l' ;
-
m_cMyArray[
4] =
'o' ;
-
m_cMyArray[
5] =
NULL ;
-
m_strMyString =
"This is a small Test-String" ;
-
m_iMyInt =
12345 ;
-
m_fFloatValue =
23456.7890f ;
-
m_fDoubleValue =
6789.012345 ;
-
Next =
this ;
-
}
-
-
MyTestClass *ptrTestClass =
new MyTestClass ;
-
g_ptrMemPool->WriteMemoryDumpToFile(
"MemoryDump.bin") ;
让我们看看内存诊断的内容:
如你所见,这是MyTestClass_OPOverload所有的成员在内存中的表示。
速度测试
-
我在windows下完成了一个简单的速度测试(使用timeGetTime()),结果显示内存池的使用可以大大增加程序的速度。所有的测试均使用vs2003,debug模式编译(测试机器:Intel Pentium IV Processor (
32 bit),
1GB RAM, MS Windows XP Professional)
-
-
//Array-test (Memory Pool):
-
for(
unsigned
int j =
0; j < TestCount; j++)
-
{
-
// ArraySize = 1000
-
char *ptrArray = (
char *) g_ptrMemPool->GetMemory(ArraySize) ;
-
g_ptrMemPool->FreeMemory(ptrArray, ArraySize) ;
-
}
-
//Array-test (Heap):
-
for(
unsigned
int j =
0; j < TestCount; j++)
-
{
-
// ArraySize = 1000
-
char *ptrArray = (
char *) malloc(ArraySize) ;
-
free(ptrArray) ;
-
}
-
//Class-Test for MemoryPool and Heap (重载了new与delete)
-
for(
unsigned
int j =
0; j < TestCount; j++)
-
{
-
MyTestClass *ptrTestClass =
new MyTestClass ;
-
delete ptrTestClass ;
-
}
关于代码
代码在ms windows与linux的如下c++编译器通过测试:
- Microsoft Visual C++ 6.0
- Microsoft Visual C++ .NET 2003
- MinGW (GCC) 3.4.4 (Windows)
- GCC 4.0.X (Debian GNU Linux)
vc6.0的项目文件与vs2003的项目文件已经包含在源码中。在64位的环境下使用应该没有问题。
注意:此内存池并非线程安全的。
待办事项
此内存池实现远远不够完善,待办事项如下:
1、对于海量的内存,memory overhead可能很大
2、一些CalculateNeededChunks函数的调用可以通过重构某些函数来被剥离,之后速度可能会更快。
3、更多的稳定性测试(尤其是对长时间运行的程序)
4、线程安全的实现
转载:https://blog.csdn.net/Linuxhus/article/details/116174827