这篇文章主要为大家详细介绍了C++中跨模块释放内存的相关知识,文中的示例代码讲解详细,具有一定的借鉴价值,有需要的小伙伴可以了解下
目录
- 一、MT改MD
- 二、DLL提供释放接口
- 三、使用进程堆申请内存
在开发主程序和动态库时,首要原则就是:避免跨模块申请和释放内存。这一点,我们在很多开源库或者平常项目中也都碰到过,对于动态库中的堆内存申请与释放,动态库总是会提供两个接口分别实现new和delete操作,而不会让调用方自己去操作。但有时候如果违背了这个原则呢,在linux平台上不会存在这样的忧虑,因为在linux下,每个进程只有一个heap,在任何一个动态库模块so中通过new或者malloc来分配内存的时候都是从这个唯一的heap中分配的,那么自然你在其它随便什么地方释放都是没问题的。这个模型是简单的。而windows下就变得复杂了,下面主要介绍一下windows下的主程序和dll之间跨模块内存释放的问题。
windows允许一个进程中有多个heap,那么当需要在堆上分配内存时就要指明在哪个heap上分配,win32提供了HeapAlloc函数可以在指定的堆上分配内存。这样的设计虽然比较灵活,但是问题在于,每次分配内存的时候就必须要显式的指定一个heap,对于crt中的new/malloc,显然需要特殊处理。那么如何处理就取决于crt的实现了。vc的crt是创建了一个单独的heap,叫做__crtheap,它对于用户是看不见的,但是在new/malloc的实现中,都是用HeapAlloc在这个__crtheap上分配的,也就是说malloc(size)基本上可以认为等同于HeapAlloc(__crtheap, size)(当然实际上crt内部还要维护一些内存管理的数据结构,所以并不是每次malloc都必然会触发HeapAlloc),这样new/malloc就和windows的heap机制吻合了。
如果一个进程需要动态库支持,系统在加载dll的时候,在dll的启动代码_DllMainCRTStartup中,会创建这个__crtheap,所以理论上有多少个dll,就有多少个__crtheap。最后主进程的mainCRTStartup 中还会创建一个为主进程服务的__crtheap。(由于顺序总是先加载dll,然后才启动main进程,所以你可以看到各个dll的__crtheap地址比较小,而主进程的__crtheap比较大,当然排在最前面的堆是每个进程的主heap。)
由此可见,对于crt来说,由于每个dll都有自己的heap,所以每个dll通过new/malloc分配的内存都是在自己dll内部的那个heap上用HeapAlloc来分配的,而如果你想在其它模块中释放,那么在释放的时候HeapFree就会失败了,因为各个模块的__crtheap是不一样的。
那么如果有非要用到跨模块释放的场景呢,可以使用以下几种方式来解决:
一、MT改MD
一个进程的地址空间是由一个可执行模块和多个DLL模块构成的,这些模块中,有些可能会链接到C/C++运行库的静态版本,有些可能会链接到C/C++运行库的DLL版本。当使用运行库的DLL版本时,由于dll加载到进程中只会在地址空间中存有一份,因此共用的是同一个堆。所以将可执行模块和DLL模块统一修改为MD编译,则可以直接实现跨模块之间的内存申请和释放,而不会存在任何问题。
二、DLL提供释放接口
DLL提供统一的对外接口,供外部模块(可执行模块或其它DLL模块)调用,由该DLL内部来进行内存的释放。简单实现如下:
void __stdcall MyFree(void *ptr)
{
if (ptr)
{
free(ptr);
}
}
void __stdcall MyDelete(void *ptr)
{
if (ptr)
{
delete ptr;
}
}
void __stdcall MyDeleteArray(void *ptr)
{
if (ptr)
{
delete[] ptr;
}
}
三、使用进程堆申请内存
在一个进程中,可执行模块和DLL模块都属于同一个进程地址空间,而每个进程又都有一个为主进程服务的堆(一般也称为进程的默认堆),当我们需要跨模块进行内存申请和释放时,可以在进程主堆上进行申请,同样地,释放时,也直接在进程主堆上进行释放,这样就可以不用考虑MT导致的跨进程释放的问题。API的使用此处不讲解,直接附上简易代码:
在DLL中:
void* __stdcall Test(int *len)
{
void* pData = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 100);
if (pData == NULL)
return NULL;
//使用的是HEAP_ZERO_MEMORY,会自动把内存块的内容都清零
//下面这行代码可以不要的
memset(pData, 0, 100);
char pBuf[] = "十点十分十分十分";
memcpy(pData, pBuf, sizeof(pBuf));
*len = 100;
return pData;
}
在可执行模块中:
int main()
{
HMODULE hLib = LoadLibraryA("Dll1.dll");
if (nullptr == hLib)
{
std::cout << "LoadLibraryA fail, error:" << GetLastError() << std::endl;
return 0;
}
Fun fun = (Fun)GetProcAddress(hLib, "Test");
if (nullptr == fun)
{
std::cout << "GetProcAddress fail, error:" << GetLastError() << std::endl;
return 0;
}
int nLen = 0;
char *pData = (char*)fun(&nLen);
std::string strTemp(pData, nLen);
HeapFree(GetProcessHeap(), 0, pData);
std::cout << strTemp << std::endl;
return 0;
}
使用默认的进程堆来申请内存还需要注意,很多Windows系统函数都用到了进程的默认堆,而且应用程序会可能有多个线程同时要调用各种windows函数,因此系统保证不管在什么时候,一次只让一个线程从默认堆中分配或者释放内存快。当两个线程同时想要从默认堆中分配一块内存,那么只有一个线程能够分配,另一个线程必须等待第一个线程的分配完成。这种依次访问对性能会有轻微影响,在一般的应用程序中可以忽略不计,对性能要求较高的程序需要注意。
四、知识补充
DLL和进程的地址空间
DLL是Windows开发人员经常使用到的一种技术,比如我们经常会把相同功能的代码封装到一个模块中,然后供其他需要使用该模块的程序共同调用,可以降低代码的复用性,使用起来非常方便;而且,当我们需要对外部提供自己公司的接口时,也会考虑到使用dll,它可以将我们内部实现的代码进行封装保护,而不会暴露给使用者。
下面主要讨论DLL和调用DLL的进程的地址空间的关系,避免使用DLL的过程中,造成难以预测的内存泄漏问题,以及程序崩溃问题。
MT和MD的区别
大家在使用windows编程时,都会发现有一个运行库的编译选项,MT和MD(MTD和MDD只是对应的debug调试模式)。我们以c/c++运行库为例,如果我们的应用程序选择连接到C/C++运行库的静态版本,那么诸如_tcscpy,malloc之类的函数会在内存中出现多次;但是如果连接到C/C++运行库的DLL版本,那么这些函数就只是在内存中出现一次,这意味着内存的使用率非常高,而这个也是windows操作系统从诞生之初就推出DLL的主要原因。
1、使用MD的场景:
(1)程序就不需要静态链接运行时库,可以减小软件的大小;
(2)所有的模块都采用/MD,使用的是同一个堆,不存在A堆申请,B堆释放的问题;
(3)用户机器可能缺少我们编译时使用的动态运行时库。(补充:如果我们软件有多个DLL,采用/MT体积增加太多,则可以考虑/MD + 自带系统运行时库)
2、使用MT的场景:
(1)有些系统可能没有程序所需要版本的运行时库,程序必须把运行时库静态链接上。
(2)减少模块对外界的依赖。
显示链接与隐式链接
在应用程序能够调用一个DLL中的函数之前,必须将该DLL的文件映像映射到调用进程的地址空间中。我们可以通过两种方法来达到这一目的:隐式载入时链接或显示运行时链接。
1、显示链接
通过WIN32 API来实现的一种链接方式。
(1)LoadLibraryA将对应DLL映射到当前调用进程的地址空间中
(2)GetProcAddress获取DLL中的导出函数地址,通过函数地址调用函数。
(3)FreeLibrary从调用进程的地址空间中撤销对DLL的映射。
2、隐式链接
当编写DLL工程代码时,如果链接器检测到DLL的源文件输出了至少一个变量或函数时时,那么链接器或会生成一个.lib文件,这个.lib文件非常小,这是因为它并不包含任何函数或者变量,它只是列出了所有被导出的函数和变量的符号名。
把DLL隐式链接到调用进程时,,我们需要用到三个文件,DLL相关的.h文件,.lib文件和.dll文件,前两个文件需要通过配置工程项目属性,当编译的时候链接器把导出函数和变量相关信息加入到调用进程的可执行模块的导入段中,当可执行模块运行时,根据导入段的信息去加载对应的DLL。
DLL和进程的地址空间
一旦系统将一个DLL的文件映像映射到调用进程的地址空间后,进程中的所有线程都可以调用该DLL中的函数了。此时,该DLL中的代码和数据全部存放在进程的地址空间中,且DLL中的函数创建的任何对象都为调用线程或调用进程所拥有-DLL绝对不会拥有任何对象。
举个例子,如果DLL中的一个函数调用了VirtualAlloc,系统就会从调用进程的地址空间中预定地址空间区域(即申请内存)。如果稍后从进程的地址空间中撤销对DLL的映射,那么这块地址区域仍然被保持为预定状态(DLL被从调用进程中取消映射, 并不会主动释放动态分配的内存)。被预定的空间区域的拥有者是进程,只有当线程调用了VirtualFree函数或者当进程终止时,该区域才会被释放。
也就是说,当一个DLL被加载到调用进程的地址空间内,DLL内所有申请的内存空间都是属于调用进程的,静态变量和全局变量都会是一份全新的实例。对于DLL中申请的内存要记得释放掉,并且严格遵循“谁申请谁释放”的原则,尽量不要dll中申请的内存,然后在调用线程中直接通过操作符或者API函数进行删除(当dll和调用进程使用的运行库编译选项不是同时为MD时,会导致程序崩溃)
写了一个简单例子,来验证每次DLL映射到进程地址空间中时,DLL中的全局变量的实例都是重新创建的。
DLL代码很简单,就是一个全局变量
调用进程代码,就是不停地加载DLL,卸载DLL,加载DLL…
初始运行时,程序的内存大小如图所示:
10秒时间不到,内存增长到30多兆
因此,在编写DLL工程时,对于new出来的全局变量,一定要在DLL从调用进程中撤销映射时,进行释放,避免引起内存泄漏。
粉丝福利, 免费领取C/C++ 开发学习资料包、技术视频/项目代码,1000道大厂面试题,内容包括(C++基础,网络编程,数据库,中间件,后端开发/音视频开发/Qt开发/游戏开发/Linuxn内核等进阶学习资料和最佳学习路线)↓↓↓↓↓↓见下面↓↓文章底部点击免费领取↓↓