当前位置:首页 » 《随便一记》 » 正文

深入详解C/C++动态内存管理

29 人参与  2023年04月08日 08:26  分类 : 《随便一记》  评论

点击全文阅读


目录

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++动态内存管理方面的内容,并具体阐述了几类常见的动态内存异常,希望这些内容能给大家带来一定的帮助。


点击全文阅读


本文链接:http://zhangshiyu.com/post/58799.html

<< 上一篇 下一篇 >>

  • 评论(0)
  • 赞助本站

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

关于我们 | 我要投稿 | 免责申明

Copyright © 2020-2022 ZhangShiYu.com Rights Reserved.豫ICP备2022013469号-1