0


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

   在C/C++程序中(线程)栈空间是有限的,大部分变量使用的都是动态分配来的堆内存,这些动态申请来的堆内存是需要开发者通过代码去自行管理的。如何管理好这些动态申请来的内存,是C/C++开发中的一个重点难点问题。之前看到很多人写过相关的文章,今天我就从一个多年的C++开发老兵的角度来详细讲述一下C/C++中动态内存管理方面的内容。

   了解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动态申请的内存,都是堆内存。

这个地方需要注意一下,一般情况下我们将的内存地址,都是数据段的地址,要和代码段地址区分开来。

数据段地址指的是程序中数据变量的内存区的地址,代码段的地址是指加载到进程空间的二进制代码的地址,两者是两个不同类型的地址,不要混淆!

   栈内存是隶属于线程的,在创建线程时会给线程分配指定大小的栈空间,可以在调用创建线程的接口时通过参数来指定线程栈空间的大小。**如果创建线程时不指定线程的栈空间大小,系统会给线程指定一个默认的线程栈大小,比如Windows系统会给线程指定1MB的默认栈空间,Linux系统则会指定8MB的线程栈空间。**

   任意时刻的函数调用堆栈中的所有函数占用的栈空间,都不能超过所在线程的栈空间上限的,一旦超过线程的栈空间上限就会导致Stack Overflow线程栈溢出的异常,导致程序出现崩溃。

导致线程栈溢出主要有以下几种场景:

f8b5ea470d744015ad9ac677c47d6f54.png

1)函数的递归调用层次过深,导致函数中占用的栈空间一直未被释放。对于递归调用,只有最底层次的函数调用返回后,上面层次的函数才会逐一返回,每个层次的函数占用的栈空间才会释放。如果函数调用一直还没走到最底下的那一层,递归调用中的所有函数的栈空间一直不会释放,线程所在线程的栈空间会被占用的越来越多。解决办法是减小递归调用的层次,或者修改变量的存储类型。

2)函数定义局部变量的结构体定义比较大(结构体比较庞大,包含了很多字段或者嵌套了很多其他的结构体),超过了当前线程的栈空间的上限。解决办法是,结构体变量不要定义成函数的局部变量,选择new或malloc去申请内存,即变量在堆上申请内存。

3)因为某些机制的存在,导致两个函数不断相互调用,陷入函数的死循环调用。导致函数占用的栈内存始终没有机会释放,导致所在线程的栈空间被消耗完了,达到了上限。解决办法是,掐断这种死循环调用机制。

4)switch语句中的case分支过多。可能这些case分支是用来处理服务器给过来的多个消息,每个case分支对应一个消息处理分支,我们会在case分支中定义生命周期在此case分支中的局部变量。虽然代码执行到case分支中这些变量才有“生命”,但其实这些变量已经在所在函数入口处就分配好栈内存了。可以编写C++测试代码进入调试状态查看一下汇编代码,就能看出来的,这点我特别验证过!

上述四种类型的线程栈溢出问题,我们在项目中都遇到过,有的甚至多次遇到过。

   所以,线程的栈空间是有限的,而程序中需要存放到内存中的数据可能有几百MB甚至上GB,并且很多数据的生命周期会持续的比较长,将所有的变量都定义成局部变量显然是行不通的。也不能将很多变量都定义成全局变量,因为全局变量占用的内存是直接和物理内存挂钩的,全局变量越多占用的物理内存也就越多。所以**我们是需要使用堆内存,通过malloc或new去动态申请堆内存,C/C++程序中的大部分数据都是存放在堆内存上的。**

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

6.5、使用malloc或new去动态申请内存可能会申请失败

   malloc申请内存失败时,会返回NULL,根据返回值就能判断申请内存是否成功。而new在申请内存失败时,可能会抛出异常,如果不处理则可能会产生崩溃。

   当我们执行如下的代码:
char* p = new char[10240];

使用new去申请一段内存时,很多人以为当内存申请失败时new操作会返回NULL,实际运行时并不是这样,new内部会抛出一个bad_alloc异常。

   new操作申请堆内存失败可能是以下几个原因引起的:

1)申请的内存过大,进程中没有这么大内存可用了

   可能受一些异常数据的影响,申请了很大尺寸的内存。比如前段时间排查一个崩溃问题,当时因为数据有异常,一次性申请了9999*9999*4*2=762MB的堆内存,进程中没有这么大可用的堆内存了,所以申请失败了,new操作抛出了一个异常,而程序没有对异常处理,直接导致程序崩溃了。

2)用户态的内存已经达到了上限,申请不到内存了

   有可能是虚拟内存占用太多,也有可能代码中有内存泄露,导致用户态的内存被消耗完了。对于一个32程序,一个进程分配了4GB的虚拟地址空间,而用户态和内核态内存各占一半,即用户态的内存只有2GB,如果程序占用的虚拟内存比较大,比如接近2GB的用户虚拟内存了,在申请大的内存就会申请失败了。或者程序中有内存泄露,快要把用户态的2GB的虚拟内存给占用完了,在申请内存可能会申请失败的。

3)进程中的内存碎片过多

   如果进程中在大量的new和delete,产生了大量的小块内存碎片,可用的内存大多是一小块一小块的小内存块,而要申请的是一块长度很长的内存,因为到处是内存碎片,没有这么一大块连续的可用内存,可能就会导致内存申请失败的。

4)发生堆内存越界,导致堆内存被破坏,导致new操作产生异常(此时new不会返回NULL,会抛出异常)。

   我们可以在出问题的地方,对该处的new添加一个保护(但不可能对代码中所有new的地方都加这样的保护),我们通过添加try...catch去捕获new抛出的异常,并将异常码打印出来,如下所示:(下面的代码在循环申请内存,直到内存申请失败为止,主要用来测试用)
#include <iostream>
using namespace std;
 
int main(){
    char *p;
    int i = 0;
    try
    {
        do{
            p = new char[10*1024*1024];
            i++;
            
            Sleep(5);
        }
        while(p);
    }
    catch(const std::exception& e)
    {
        std::cout << e.what() << "\n"
                    << "分配了" << i*10 << "M" << std::endl;
 
    }
    
    return 0;   
}
   还有一种方式,在new时传如一个std::nothrow参数,让new在申请不到内存时不要抛出异常,直接返回为NULL,这样我们就可以通过返回的地址是否为NULL(空),判断是否是内存申请失败了,示例代码如下:
#include <iostream>
 
int main(){
    char *p = NULL;
    int i = 0;
    do{
        p = new(std::nothrow) char[10*1024*1024]; // 每次申请10MB
        i++;
        
        Sleep(5);
    }
    while(p);
 
    if(NULL == p){
        std::cout << "分配了 " << (i-1)*10 << " M内存"         //分配了 1890 Mn内存第 1891 次内存分配失败           
                  << "第 " << i << " 次内存分配失败";
 
    }
    return 0;
}

6.6、堆内存被越界导致堆内存被破坏

  系统在给堆内存申请者分配堆内存时,会把分配给用户使用的堆内存的首地址返回给用户,用户可以在这段堆内存上进行读写操作。实际上,系统在分配堆内存时会在给用户使用的内存区域头部前面加一段头部内存,在给用户使用的内存区域的尾部加一段尾部内存,如下所示:

   这里额外的头部内存块和尾部内存块是用来存放给用户使用的堆内存的信息的,系统正是通过这些头部和尾部信息来管理一块一块堆内存的。这个地方做了简化的说明,实际上操作系统用来管理堆内存的数据结构要复杂很多。

   我们此处讲的堆内存被破坏是指这些存放堆内存信息的头部内存区域或者尾部内存区域被篡改了,一般是被内存越界篡改的,被篡改后可能会导致两个问题:

1)程序中使用new或malloc申请新的内存块时,会因为有堆内存块信息被破坏,导致分配堆内存时出异常。

2)当我们使用delete或free去释放被破坏的堆内存时,因为堆内存信息被破坏,导致释放堆内存时出异常。

   所以,当堆内存被破坏后,程序可能会出现“胡乱”崩溃的问题,一会崩溃在这个dll库中,一会崩溃在那个dll中;或者一会崩溃在new或malloc时,一会崩溃delete或free时。

堆内存被破坏导致的异常,比栈内存被破坏要难查的多,也没有什么太好的办法。一般可以通过注释代码、注释模块去缩小排查的范围,也可以通过历史版本比对法看看是从哪天开始问题的,通过svn查看出问题前一天修改了什么代码,然后对这些代码进行针对性排查。

   **我们之前就遇到过一个堆内存头信息区被破坏的案例**,我们申请了一段堆内存,通过数组下标szTags[nCount-1]去处理这段堆内存,结果程序在测试时崩溃了,弹出了如下的错误:

使用windbg查看dump文件,发现程序就崩溃在delete[]szTags的这一行代码上。这里不存在对同一段堆内存同时delete两次的问题,以前很少遇到过delete时会产生异常的。

   于是通过和测试同事讨论,找到必现的规律,然后在Debug下对代码调试,发现在某种比较罕见的场景下,**nCount会等于0,从而导致代码中对szTags[-1]进行赋值操作**,而szTags[0]开始的堆内存块才是用户可以操作的。出问题的代码块如下所示:

所以szTags[-1]就越界到堆内存的头信息内存区了,篡改了堆内存头信息,导致系统在管理这个被破坏的内存出问题了,所以在delete这个堆内存时产生了异常。此处只是大概知道是这个原因,至于更为深层次的系统原因就比较复杂了,要研究Windows内存管理的实现细节才能搞清楚的。

6.7、栈内存被当作堆内存去释放产生异常

   在栈上分配内存的C++类对象,是不能用delete去释放内存的,delete释放的是堆内存,否则会导致异常崩溃。

   之前在使用一个框架库导出类ClassA(假设类名叫ClassA),在框架库内部的框架中会自动去delete这个类对象。但我们是在一个函数中使用该类定义一个局部变量(类对象),即:
voidFunc()
{
    ClassAclsA;

    //......
}

该类对象在栈上分配内存的,是不能用delete去释放的,应该调用接口给该对象设置不需要框架自动销毁。对于栈上分配内存的局部变量clsA,在函数退出时其占用的栈内存会自动释放。

7、最后

   本文详细讲述了C/C++动态内存管理方面的内容,并具体阐述了几类常见的动态内存异常,希望这些内容能给大家带来一定的帮助。

本文转载自: https://blog.csdn.net/chenlycly/article/details/125879661
版权归原作者 dvlinker 所有, 如有侵权,请联系我们删除。

“深入详解C/C++动态内存管理”的评论:

还没有评论