目录
1、从程序的完整启动过程去看程序的内存分区
2、为什么要去动态申请堆内存?
3、动态内存的申请与释放
3.1、C语言中使用malloc等函数申请内存,使用free函数释放内存
3.2、C++中使用new申请内存,使用delete释放内存
4、动态内存主要使用指针去进行操作
5、new和delete既是C++中的关键字,也是操作符
6、常见的动态内存异常
6.1、malloc和free、new和delete要成对出现,不能交叉混用
6.2、对同一段堆内存执行两次delete操作产生崩溃
6.3、直接对空指针或者野指针执行delete操作导致崩溃
6.4、使用malloc或new动态申请的堆内存,没有释放,导致内存泄漏
7、最后
在C/C++程序中(线程)栈空间是有限的,大部分变量使用的都是动态分配来的堆内存,这些动态申请来的堆内存是需要开发者通过代码去自行管理的。如何管理好这些动态申请来的内存,是C/C++开发中的一个重点难点问题。之前看到很多人写过相关的文章,今天我就从一个多年的C++开发老兵的角度来详细讲述一下C/C++中动态内存管理方面的内容。
C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...) https://blog.csdn.net/chenlycly/article/details/125529931
了解C/C++动态内存管理方面内容是很有必要的,在掌握这些知识之后,不仅能在我们编码的过程中及时感知一些潜在的问题,也能在我们排查内存问题时提供一些分析思路与方向。
1、从程序的完整启动过程去看程序的内存分区
在讲述C/C++的动态内存管理的内容之前,我们先来了解一下进程中的内存分区,如上所示。我们可以通过查看程序的完整启动过程去看这些内存分区。
以启动Windows系统中的exe程序为例,当我们双击某个exe程序或者通过桌面等快捷方式去启动一个exe程序时,就进入exe程序的启动过程。
程序启动时,系统首先会将exe主程序依赖的所有的dll库文件(包括exe程序自带的dll以及exe程序依赖的系统dll)都加载到进程空间中,这些dll二进制文件中存放的是可执行的二进制机器代码(即汇编代码,机器码与汇编代码等价的,汇编代码是机器码的助记符),都加载到进程的代码段的内存区中。
待所有依赖的dll模块都加载到进程空间后,最后才会将exe主程序加载到进程空间中。然后去启动C/C++的运行时库,紧接着去给全局变量分配内存并执行全局变量的初始化操作,此处对应的就是全局内存区。然后才会进入到main函数,程序才能真正的启动并运行起来。
进入到函数中,就会从所在线程的栈内存上给函数的局部变量分配栈内存,这就是我们讲的栈内存。当执行到malloc或new等代码时,申请的内存就是堆内存。
2、为什么要去动态申请堆内存?
从C/C++程序的数据内存分区来看,主要分全局内存区,栈内存区和堆内存区。全局内存区主要用来存放全局变量和静态变量的。对于栈内存,函数调用时传递的参数是通过栈内存传递的,函数中局部变量也是在栈内存上分配的。通过malloc或new动态申请的内存,都是堆内存。
这个地方需要注意一下,一般情况下我们将的内存地址,都是数据段的地址,要和代码段地址区分开来。
数据段地址指的是程序中数据变量的内存区的地址,代码段的地址是指加载到进程空间的二进制代码的地址,两者是两个不同类型的地址,不要混淆!
3、动态内存的申请与释放
在C/C++中,动态申请的内存是需要开发人员自己去管理的,即使用完毕后需要手动去释放,这个和Java中的内存自动回收机制有很大的不同。
3.1、C语言中使用malloc等函数申请内存,使用free函数释放内存
在C语言中主要使用malloc去申请内存,在内存使用完毕后调用free将堆内存释放掉,比如:
// 调用malloc申请一段内存char* buffer = malloc(100); // ...... // 使用malloc动态申请的内存,此处代码略过// 调用free将动态内存释放掉if ( buffer == NULL ){ free(buffer);}
malloc和free是C运行时库中的的系统函数,malloc函数的声明如下:
void * __cdecl malloc(size_t _Size);
该函数传入的参数就是要动态申请的内存的字节数,通过函数的返回值去判断内存是否申请成功。如果内存申请成功,则函数返回申请到的堆内存首地址;如果内存申请失败,则函数会返回NULL。
C语言中申请堆内存的函数除了malloc之外,还有calloc和realloc函数。calloc 函数的功能和 malloc 十分相似,但 calloc 函数比 malloc 函数多了一个操作,会将申请的空间里面数据全部初始化为0。
realloc函数的出现让动态内存管理更加灵活,realloc函数的声明如下:
void * __cdecl realloc( void * _Memory, size_t _NewSize );
可以在已经申请的内存(对应函数中第一个参数void* _Memory)基础上,再次申请不同尺寸的内存。realloc 函数可以根据实际需要,对当前使用的内存大小进行调整。当realloc函数的第一个参数为NULL时,realloc等同于malloc函数,调用realloc函数的示例代码如下:
void CStdString::Assign( LPCTSTR pstr, int cchMax ){ if( pstr == NULL ) {pstr = _T("");} cchMax = (cchMax < 0 ? (int) _tcslen(pstr) : cchMax); m_pstr = static_cast<LPTSTR>(realloc(m_pstr, (cchMax + 1) * sizeof(TCHAR))); _tcsncpy(m_pstr, pstr, cchMax); m_pstr[cchMax] = '\0';}
如果要申请更大的内存,realloc函数内部要分两种情况进行处理:(进程堆内存中空闲可用的堆内存块可能是一小段一小段的,不连续的)
1)情况1:原有内存后面有足够的空闲内存空间可用,那么扩展内存时会在原有内存之后直接追加空间,原来内存中的数据不发生变化。
2)情况2:原有内存后面没有足够大的内存空间可用,这时 realloc 函数会在堆空间上另找一个合适大小的连续空间来使用,函数返回这个新的内存地址;并且realloc 函数会将原来内存中的数据自动拷贝到新的内存空间中。
3.2、C++中使用new申请内存,使用delete释放内存
在C++中,在支持C语言中的malloc和free去动态申请内存的基础上,新增了new和delete两种操作。new除了可以其申请int等一些基本类型的内存,new主要是用来new一个C++对象,即在堆内存上申请C++对象需要的内存,当new出来的C++对象不再使用时需要调用delete将C++对象销毁掉。
new一个C++对象时,不仅仅会去申请C+对象的所需要的内存(C++对象的数据成员占用的内存总和),还会去执行C++对象的构造函数,在C++类的构造函数中可以去执行一些C++对象数据成员变量的初始化工作,也可以去执行一些其他的操作。具体地是,先申请C++对象需要的内存,然后再去执行C++类的构造函数。
同样地,在对一个C++对象执行delete操作时,会先去执行C++对象的析构函数,然后再将new时申请的堆内存给释放掉。所以可以在C++类的析构函数中做一些清理的操作,比如如下的设备管理类DeviceManage相关代码:
// 1、设备信息结构体struct TDeviceInfo{ char szDeviceId[64]; // 设备id char szDeviceName[64]; // 设备名称 int nDevType; // 设备类型};// 存放设备信息的列表vector<TDeviceInfo*> vtDevList; // 2、将设备信息保存到列表中void DeviceManage::InsertDevIntoList(char* lpszDeviceId, char* lpszDeviceName, int nDevType){ // new出一个TDeviceInfo结构体对象,然后将对象地址保存到列表中 TDeviceInfo* pDevInfo = new TDeviceInfo; strcpy(pDevInfo->szDeviceId, lpszDeviceId); strcpy(pDevInfo->szDeviceName, lpszDeviceName); pDevInfo->nDevType = nDevType; vtDevList.push_back(pDevInfo);}// 3、设备管理类DeviceManage的析构函数void DeviceManage::~DeviceManage(){ // 遍历列表,将列表中存放的结构体对象占用的内存都释放掉 TDeviceInfo* pDevInfo = NULL; vector<TDeviceInfo*>::vector it = vtDevList.begin(); for ( ; it != vtDevList.end(); it++ ) { pDevInfo = *it; delete pDevInfo; }}
在InsertDevIntoList接口中将设备信息保存到一个new出来的TDeviceInfo结构体对象中,然后将该结构体对象的地址保存到vtDevList列表中,在DeviceManage析构函数中vtDevList列表中的结构体对象的内存给delete释放掉(在析构函数中去清理一些资源)。
new会触发C++类对象的构造函数的执行,delete会触发C++类对象的析构函数的执行,这是new/delete与malloc/free之间的一个很重要的区别。
4、动态内存主要使用指针去进行操作
动态申请的内存都是指针变量去访问去操作的,也是通过操作指针变量去释放内存的。指针是C/C++中的核心概念,是使用最频繁的一种类型之一,指针指向的动态内存是开发人员自行通过代码去管理的。这点和Java有着显著的区别,Java中的内存是自动回收的。这也是很多编程语言初学者认为C++比较难学的一个很重要的原因,其实也没很多人想象的那样难学,只是初学者把自己的思想给禁锢了,完全可以进来深入学习一把的。
C++和Java都是很受欢迎的主流开发语言,至于该选择哪门语言去深入学习,可以参考我之前写的一篇文章,文章中有详细的说明:
学C++还是学Java?做软件研发还需掌握哪些知识和技能?https://blog.csdn.net/chenlycly/article/details/125129167
5、new和delete既是C++中的关键字,也是操作符
C语言中的malloc和free是C运行时库中的函数,C++中的new和delete则既是C++中的关键字,也是一种类似于+、-、++、--等的操作符。我们可以使用operator关键字去重载这些+、-、++、--这些操作符,重新定义这些操作符的含义,比如微软MFC框架中自带的CString类重载了多个操作符(VC6.0中的CString类版本),如下所示:
// ref-counted copy from another CStringCls const CString& operator=(const CUIString& stringSrc); // set string content to single character const CString& operator=(TCHAR ch);#ifdef _UNICODE const CString& operator=(char ch);#endif // copy string content from ANSI string (converts to TCHAR) const CString& operator=(LPCSTR lpsz); // copy string content from UNICODE string (converts to TCHAR) const CString& operator=(LPCWSTR lpsz); // copy string content from unsigned chars const CString& operator=(const unsigned char* psz); // string concatenation // concatenate from another CStringCls const CString& operator+=(const CUIString& string); // concatenate a single character const CString& operator+=(TCHAR ch);#ifdef _UNICODE // concatenate an ANSI character after converting it to TCHAR const CString& operator+=(char ch);#endif // concatenate a UNICODE character after converting it to TCHAR const CString& operator+=(LPCTSTR lpsz);
new和delete也是操作符,所以我们也可以去重载这两个操作符,去重新定义这两个操作符的行为,比如Windows系统的GDI+库中的GdiplusBase类就重载了这两个操作符,如下所示:
class GdiplusBase{public: void (operator delete)(void* in_pVoid) { DllExports::GdipFree(in_pVoid); } void* (operator new)(size_t in_size) { return DllExports::GdipAlloc(in_size); } void (operator delete[])(void* in_pVoid) { DllExports::GdipFree(in_pVoid); } void* (operator new[])(size_t in_size) { return DllExports::GdipAlloc(in_size); }};
6、常见的动态内存异常
动态内存方面的异常,是C/C++中最常见一类软件异常,下面我们就来详细讲述一下动态内存异常的多个场景。
6.1、malloc和free、new和delete要成对出现,不能交叉混用
使用malloc申请的堆内存,不用时需要调用free去释放,不能使用delete去释放。
使用new申请的堆内存,不用时需要调用delete去释放,不能使用free去释放,因为delete时不仅仅是释放堆内存,还会触发析构函数的调用,析构函数中可能会有一些清理的操作。
6.2、对同一段堆内存执行两次delete操作产生崩溃
同一段堆内存只能delete一次,如果对已经释放的堆内存再次执行delete操作,则会导致崩溃。一般出现这种情况,可能是执行delete操作后没有将指针变量置为NULL,导致后续又走进了delete同一块内存的代码行,如下:
if ( p != NULL ){ delete p;}
6.3、直接对空指针或者野指针执行delete操作导致崩溃
所谓的空指针是指指针变量的值为空(NULL),所谓野指针是指指针变量的指向的堆内存已经被释放,并且指针变量没有置空(指针变量中保存的还是之前指向的内存首地址,内存已经被释放),对空指针和野指针操作都会引发异常。
6.4、使用malloc或new动态申请的堆内存,没有释放,导致内存泄漏
使用malloc或new动态申请的堆内存,在使用完成后没有调用free或delete将之释放掉,就会产生内存泄漏。如果产生内存泄漏的代码被频繁地执行,会导致程序的内存占用的越来越多,直到将程序进程的内存耗尽,产生Out of Memory的崩溃。在参与研发的多个项目中,多次遇到内存泄漏的问题,在处理此类问题中积累了一定的经验。
对于一个32位进程,系统会在进程启动时给进程的数据段分配4GB的虚拟内存,用户态占2GB,内核态占2GB,而我们程序的业务代码主要运行在用户态上,如果因为泄漏导致用户态的2GB虚拟内存被消耗完,就会导致Out of Memory的崩溃。
对于内存泄漏的排查,在Windows平台上主要用Windbg工具,在Linux平台则主要使用Valgrind内存分析工具。对于如何使用windbg去分析C++软件中的内存泄漏,可以参见我之前写的一篇文章:
使用Windbg定位C++程序中的内存泄露https://blog.csdn.net/chenlycly/article/details/121295720
7、最后
本文详细讲述了C/C++动态内存管理方面的内容,并具体阐述了几类常见的动态内存异常,希望这些内容能给大家带来一定的帮助。