用未公开的MFC
类加强动态内存分配
下面是示例程序使用及未使用这个未公开MFC类的对比情况截图,是不是有点心动了呢,接着往下看。
简介
如果你经常浏览MFC的源代码,说不定就会有意想不到的惊喜发现,这不,很快就发现了一个,它是一个小工具类的集合,允许为特定类的对象定义怎样为其动态分配内存,这个类就是CFixecAlloc,通常它在大家熟悉的CString类的内部,用来分配字符串缓冲区。而我们今天要说的,就是如何在现有代码中,应用CFixecAlloc类,只花最少的修改成本来获取可观的性能提升,本文中探讨CRT动态分配的开销、针对CRT不足之处的备选方法、对CFixecAlloc类的剖析、及如何在程序中使用CFixecAlloc,最后,将在一个示例程序中证明这一切。
为什么不用malloc/free
、new/delete
呢?
众所周知,动态内存分配开销非常大,它会导致程序执行时间上的性能损耗,而且占用空间也更大,如果还不相信,可以亲自查看一下CRT源代码中提供的malloc.c文件,怎么说呢,malloc()函数有点复杂,执行起来可能要花点时间;至于空间上的开销,说起来就稍有点繁琐了,基本上编译程序时不同版本的VC++、程序运行时不同版本的Windows,都会用到不同版本的堆,但这已超出了本文的范围。如果真的对CRT如何选择堆的版本很感兴趣,可以看一下heapinit.c文件中的__heap_select()函数。在CRT的winheap.h头文件中,定义了三种不同版本堆:
__SYSTEM_HEAP
__V5_HEAP
__V6_HEAP
V5及V6版本映射的堆实现,对CRT库来说是私有的,而系统堆(system heap)直接映射到Win32堆服务(如HeapCreate()、HeapAlloc()等等)。对于V5及V6版本,查看CRT源代码之后就可完全知道其空间开销,例如,对V6堆而言,就可找到下面这行语句:
//添加8个字节的开销并求整到下一个参数大小
sizeEntry = (intSize + 2 * (int)sizeof(int) +
(BYTES_PER_PARA - 1)) & ~(BYTES_PER_PARA - 1);
BYTES_PER_PARA等同于16字节,这是一个非常大的开销,如果我们只需要12字节呢,而CRT将会为此保留32字节,这已经比所需的两倍还多了。但V5及V6堆现今已很少使用了,且也没有源代码,所以还不能确切了解HeapAlloc()的开销是多少,然而,存在一定程度上的开销总是不争的事实,微软对此在HeapCreate()文档中的声明是:
系统使用私有堆中的内存,用于存储支撑堆的结构,因此,并不是所有规定的堆大小对进程来说都是可用的。例如,如果HeapAlloc()
函数从一个最大64K
的堆中请求64k
(千字节)内存,那么会因为存在系统开销而调用失败。
其他方法的局限性
当然,也有其他人编写了内存池类用于解决CRT堆开销过多的问题,然而,大多数这类方法需要大量修改现有程序的代码,以便让所有对new、delete的调用,都转向对缓冲池类成员的调用;另外,这类方法还有一个潜在的局限性,它们缺乏线程安全,而CFixedAlloc就是为了解决这些情况而生的。
CFixedAlloc的类声明:
class CFixedAlloc
{
//构造函数
public:
CFixedAlloc(UINT nAllocSize, UINT nBlockSize = 64);
//属性
UINT GetAllocSize() { return m_nAllocSize; }
//具体操作
public:
void* Alloc(); //返回一块nAllocSize大小的内存
void Free(void* p); //释放由Alloc分配的一块内存
void FreeAll(); //释放所有此类分配的内存
//实现
public:
~CFixedAlloc();
protected:
struct CNode
{
CNode* pNext;
};
UINT m_nAllocSize; //Alloc中每个块的大小
UINT m_nBlockSize; //每次获取的块数
CPlex* m_pBlocks; //块链表(nBlocks*nAllocSize)
CNode* m_pNodeFree; //第一个要释放的节点,如果没有则为NULL
CRITICAL_SECTION m_protect;
};
可以看到,这是一个非常简单的类,而且,你也许也留意到了,这个类由临界区提供了线程安全。m_AllocSize中包含了类的对象大小,m_blockSize指定了每个固定内存块能包含的对象数,这两个成员都在构造函数中设置。唯一剩下的就是CPlex指针了,在这不打算详细讲解这个类,只需了解它才是真正进行CRT动态内存分配的类就行了,它包含了一个指针,指向另一个CPlex对象以创建一个CPlex链表,所以它的大小只能为4字节,当进行内存分配时,就需要m_allocSize*m_blockSize+sizeof(CPlex)。
现在,我们可以对比CRT与CFixedAlloc之间的差异了:使用CRT,对于每个对象都会存在CRT开销;而使用CFixedAlloc,内存开销则为CRT开销加上CPlex大小(4字节),再除以已分配对象数,当分配数目非常大时,此开销接近于零。下面,再来仔细看一下两个更重要的CFixedAlloc函数:
void* CFixedAlloc::Alloc()
{
EnterCriticalSection(&m_protect);
if (m_pNodeFree == NULL)
{
CPlex* pNewBlock = NULL;
TRY
{
//添加另一个块
pNewBlock = CPlex::Create(m_pBlocks, m_nBlockSize, m_nAllocSize);
}
CATCH_ALL(e)
{
LeaveCriticalSection(&m_protect);
THROW_LAST();
}
END_CATCH_ALL
//把它们放进要释放的列表中
CNode* pNode = (CNode*)pNewBlock->data();
//逆向进行释放以便于调试
(BYTE*&)pNode +=
(m_nAllocSize * m_nBlockSize) - m_nAllocSize;
for (int i = m_nBlockSize-1; i >= 0; i--,
(BYTE*&)pNode -= m_nAllocSize)
{
pNode->pNext = m_pNodeFree;
m_pNodeFree = pNode;
}
}
ASSERT(m_pNodeFree != NULL);
//移除释放列表中第一个节点
void* pNode = m_pNodeFree;
m_pNodeFree = m_pNodeFree->pNext;
LeaveCriticalSection(&m_protect);
return pNode;
}
void CFixedAlloc::Free(void* p)
{
if (p != NULL)
{
EnterCriticalSection(&m_protect);
//把节点返回到释放列表中
CNode* pNode = (CNode*)p;
pNode->pNext = m_pNodeFree;
m_pNodeFree = pNode;
LeaveCriticalSection(&m_protect);
}
}
在Alloc()中,代码会检查要释放的内存池是否为空,如果为空,则创建一个新的内存块(CPlex),并设置要释放节点列表,在此,因为是直接放置到每个块中,所以就没有多余的空间来放置节点信息。当然了,要正常工作,m_nAllocSize必须要大过sizeof(CNode),这也是为什么要在构造函数里进行检查的原因:ASSERT(nAllocSize >= sizeof(CNode))。
如果大家看过CRT代码,可能有点搞不清楚CFixedAlloc的代码为什么会很少,从算法的观点来看,主要是因为内存块的大小是固定的,比方说,在可进行变量分配的堆中,经过多次分配与释放之后,堆中会有碎片,过不多久就必须扫描一次堆,把邻近的小的剩余空间合并为大的空间,以提高内存使用的效率。
下面是使CFixedAlloc更便于使用的宏:
// DECLARE_FIXED_ALLOC -- used in class definition
#define DECLARE_FIXED_ALLOC(class_name) /
public: /
void* operator new(size_t size) /
{ /
ASSERT(size == s_alloc.GetAllocSize()); /
UNUSED(size); /
return s_alloc.Alloc(); /
} /
void* operator new(size_t, void* p) /
{ return p; } /
void operator delete(void* p) { s_alloc.Free(p); } /
void* operator new(size_t size, LPCSTR, int) /
{ /
ASSERT(size == s_alloc.GetAllocSize()); /
UNUSED(size); /
return s_alloc.Alloc(); /
} /
protected: /
static CFixedAlloc s_alloc /
// IMPLEMENT_FIXED_ALLOC -- used in class implementation file
#define IMPLEMENT_FIXED_ALLOC(class_name, block_size) /
CFixedAlloc class_name::s_alloc(sizeof(class_name), block_size) /
DECLARED_FIXED_ALLOC()宏担当了new与delete操作,这样,无需修改现有代码就可以应用CFixedAlloc;而IMPLEMENT_FIXED_ALLOC()则负责指定块大小。
如何使用CFixedAlloc
1、把“fixalloc.h”包含在需要修改且含有类定义的头文件中。
2、在类声明中添加DECLARE_FIXED_ALLOC()宏。
3、在含有类定义的CPP文件中添加IMPLEMENT_FIXED_ALLOC()宏。
4、重新编译。
5、微调块的大小,以获得最佳效果。
因为CFixedAlloc是一个私有类,所以还要在编译器选项中添加一个额外的include目录,其必须指向MFC源代码目录。(因此,在安装Visual Studio时就要选择安装MFC源代码了,还好这通常情况下都是默认安装的。)
大功告成,现在在自己的类中就可以使用MFC CFixedAlloc了,提醒一句:如果编译时代码中有#define _DEBUG,CFixedAlloc宏将不会展开,最终结果还是会和以前一样,就好像什么也没动过。
对上面的第五步,还要补充一下,块的大小非常重要,如果太大,就会浪费内存;如果太小,则不会得到应有的性能提升,然而,即便这个值非常小,仍会减少对CRT分配函数的调用次数。块大小的理想值应恰好等于要分配的对象数,当然了,通常这是不可能的。
关于Visual C++ 2005
的警告信息
另外,不得不提一点,如果用Visual C++ 2005编译使用了CFixedAlloc的程序时,可能会遇到如下警告信息:
./CFixedAllocDemoDlg.cpp(237) : warning C4995:
'CFixedAlloc': name was marked as #pragma deprecated
./CFixedAllocDemoDlg.cpp(240) : warning C4995:
'CFixedAlloc': name was marked as #pragma deprecated
这表明从Visual C++ 2005开始,微软已不推荐使用CFixedAlloc类,可我们一时也找不出什么可以代替它的方法,如果你觉得使用CFixedAlloc会让程序大有改观,大可以忽略这些信息,如果怕微软以后从MFC中移除CFixedAlloc类,带来程序上的兼容性问题,那建议你复制一份CFixedAlloc文件加以保存。
这也表明,微软开始慢慢疏远CFixedAlloc了,回过头来看MFC6,CFixedAlloc却用在了每个临时句柄映射对象类中,如CWnd和CGdiObject,有以下为证:
class CTempGdiObject : public CGdiObject
{
DECLARE_DYNCREATE(CTempGdiObject)
DECLARE_FIXED_ALLOC(CTempGdiObject);
};
但这些类从MFC7.1(VC++2003)中就已被移除,也许微软MFC开发小组有其自己的理由吧。
内存释放
当某个对象被删除,且“释放列表”在同一分配的CPlex中时,CFixedAlloc会返回一个节点到一个“释放列表”中,因为CPlex对象是单向链接的,所以在整个程序生命期都会保持已分配状态(或直到它们的表头被删除)。事实上,CFixedAlloc不仅仅是进行内存分配,它是一个再循环器:按需进行分配但并不释放,为后续的分配请求作保留。
通常,这在频繁地使用new/delete/new/delete情况下,会带来更好的速度提升,但对于短时间内需要大量内存的这种情况,可能会有些问题,这时,程序会一直占有这部分内存,直到CPlex表头被删除,除非用户在使用完内存后,显式地调用CFixedAlloc::FreeAll()来释放这部分内存。也就是说,如果之前已删除所有分配的对象,就可在任意时候调用CFixedAlloc::FreeAll()来自己释放内存,否则,内存会直到CFixedAlloc对象销毁时才会被释放。提示一下:全局对象是在进入WinMain()之前的启动代码中创建的,而在程序退出WinMain()后、自身卸载前才会被销毁。
CFixedAlloc
与继承
在与派生类一起使用CFixedAlloc时,还必须多一点了解,比如说以下情况:
class Base
{
DECLARE_FIXED_ALLOC(Base);
};
class Child : public Base
{
};
如果后面代码中用了Child *p = new Child,将会调用Child::operator new()而不是CFixedAlloc,这时该怎么办呢,请看下面:
class Base
{
DECLARE_FIXED_ALLOC(Base);
};
class Child : public Base
{
DECLARE_FIXED_ALLOC(Child);
};
当然了,如果类是抽象类,而我们又把Base类声明为FIXED_ALLOC,这怎么也说不过去,因此,关于在类继承中使用CFixedAlloc,必须知道delete操作符为static但不为virtual,再看以下代码:
Base *p = new Child;
delete p;
在这种情况中,将会调用Base::operator delete(),这可就麻烦大了。以下是可行的解决方案:
class Base
{
public:
virtual void Destroy() {delete this;}
DECLARE_FIXED_ALLOC(Base);
};
class Child : public Base
{
public:
virtual void Destroy() {delete this;}
DECLARE_FIXED_ALLOC(Child);
};
Base *p = new Child;
p->Destroy();
示例程序
示例程序非常简单,仅是声明两个几乎一样的类:一个使用了CFixedAlloc而另一个没用。接下来,创建了大量的对象,创建过程是计时的,完成之后,会显示结果。要计算未用CFixedAlloc的类大小开销,在此使用了__V6_HEAP,这种情况非常少见,因此计算值可能不完全准确,但一般来看,应该是比较正确了。
#define ITER_NUM 1000*1024
class A
{
public:
A( A *next ) : m_next(next) {}
A *m_next;
int dummy1;
int dummy2;
};
class B
{
public:
B( B *next ) : m_next(next) {}
B *m_next;
int dummy1;
int dummy2;
DECLARE_FIXED_ALLOC(B);
};
IMPLEMENT_FIXED_ALLOC(B,ITER_NUM);
void CCFixedAllocDemoDlg::OnTestTimebutton()
{
//可在此添加控件通知处理代码
A *curAObj, *firstAObj = NULL;
B *curBObj, *firstBObj = NULL;
DWORD startA, endA, startB, endB;
register int i;
{
CWaitCursor wait;
startA = GetTickCount();
for( i = 0; i < ITER_NUM; i++ )
{
firstAObj = new A(firstAObj);
}
while( firstAObj )
{
curAObj = firstAObj->m_next;
delete firstAObj;
firstAObj = curAObj;
}
startB = endA = GetTickCount();
for( i = 0; i < ITER_NUM; i++ )
{
firstBObj = new B(firstBObj);
}
while( firstBObj )
{
curBObj = firstBObj->m_next;
delete firstBObj;
firstBObj = curBObj;
}
endB = GetTickCount();
}
displayResult( endA-startA,endB-startB );
}
#define BYTES_PER_PARA 16
void CCFixedAllocDemoDlg::displayResult( DWORD timeA, DWORD timeB )
{
TCHAR buf[1024];
/*
* 每个A对象占用32字节。
*/
int overheadA = (32-sizeof(A))*ITER_NUM;
/*
*首先计算已分配大小,再减去所请求大小。
*/
int overheadB =
(8+sizeof(B)*ITER_NUM + sizeof(CPlex) + (BYTES_PER_PARA-1))
& ~(BYTES_PER_PARA - 1);
overheadB -= sizeof(B)*ITER_NUM;
wsprintf( buf, __TEXT("Creating and destroying %d objects/n")
__TEXT("without CFixedAlloc/t: %4d ms/n")
__TEXT("with CFixedAlloc/t: %4d ms/n")
__TEXT("You saved %d bytes with CFixedAlloc"),
ITER_NUM, timeA, timeB,
overheadA - overheadB );
MessageBox(buf,__TEXT("Results"));
}
建议
如果移除临界区,把CFixedAlloc作为单线程版本,还可进一步提高性能,这点微软也想到了,从MFC7(VC++.NET 2002)开始,就添加了对单线程的支持,最新的Visual C++ 2008当然也不例外。如果想使用单线程版本的CFixedAlloc,就可使用以下宏:
// DECLARE_FIXED_ALLOC -- used in class definition
#define DECLARE_FIXED_ALLOC_NOSYNC(class_name) /
public: /
void* operator new(size_t size) /
{ /
ASSERT(size == s_alloc.GetAllocSize()); /
UNUSED(size); /
return s_alloc.Alloc(); /
} /
void* operator new(size_t, void* p) /
{ return p; } /
void operator delete(void* p) { s_alloc.Free(p); } /
void* operator new(size_t size, LPCSTR, int) /
{ /
ASSERT(size == s_alloc.GetAllocSize()); /
UNUSED(size); /
return s_alloc.Alloc(); /
} /
protected: /
static CFixedAllocNoSync s_alloc /
// IMPLEMENT_FIXED_ALLOC_NOSYNC -- used in class
// implementation file
#define IMPLEMENT_FIXED_ALLOC_NOSYNC(class_nbame, block_size) /
CFixedAllocNoSync class_name::s_alloc(sizeof(class_name),
block_size) /
不过在IMPLEMENT_FIXED_ALLOC_NOSYNC()宏中有一个小错误,宏参数class_nbame应该为class_name(注:这个错误在Visual C++ 2008中依然存在)。看起来在MFC中并没有使用这些宏,要不然早就发现这个语法错误了,同时,这也意味着这个非同步版本可能未被MFC开发小组所测试。不管怎样,这个错误很小,而且在修正之后丝毫不影响使用,也正是因为MFC中没有使用这个宏,所以修正之后也无需再重新编译MFC。
/*
*取消第一行define语句前的注释符号就可以使用单线程版本的CFixedAlloc
*在重新编译之前,必须修正fixalloc.h中的语法错误
* IMPLEMENT_FIXED_ALLOC_NOSYNC()宏参数中的class_nbame应为class_name
*
*注意:单线程版本只可用在VC++.NET及以上版本中
*/
//#define _USE_SINGLE_THREAD
#ifndef _USE_SINGLE_THREAD
class B
{
public:
B( B *next ) : m_next(next) {}
B *m_next;
int dummy1;
int dummy2;
DECLARE_FIXED_ALLOC(B);
};
IMPLEMENT_FIXED_ALLOC(B,ITER_NUM);
#else
class B
{
public:
B( B *next ) : m_next(next) {}
B *m_next;
int dummy1;
int dummy2;
DECLARE_FIXED_ALLOC_NOSYNC(B);
};
IMPLEMENT_FIXED_ALLOC_NOSYNC(B,ITER_NUM);
#endif
测试程序表明非同步版本相对于原CFixedAlloc版本,带来了40%的速度提升,可别犯迷糊,非同步版本可不能用在多线程程序中喔。