内存泄漏漫谈

时间:2022-04-27
本文章向大家介绍内存泄漏漫谈,主要内容包括一、常见的内存泄漏姿势、二、如何避免内存泄漏、三、内存泄漏的检测技术、四、工具的选择、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

对于C/C++来说,内存泄漏问题一直是个很让人头痛的问题,因为对于没有GC的语言,内存泄漏的概率要比有GC的语言大得多,同时,一旦发生问题,也严重的多,而且,内存泄漏的排查往往十分困难。对于内存泄漏,维基百科的定义是:在计算机科学中,内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。内存泄漏的原因通常情况下只能由程序源代码分析出来。如果一个程序存在内存泄漏并且它的内存使用量稳定增长,通常不会有很快的症状。每个物理系统都有一个较大的内存量,如果内存泄漏没有被中止的话,它迟早会造成问题。

广义的内存泄漏还包括资源类的泄漏,比如Windows下的GDI对象、内核对象等,本文主要讨论普通的堆内存泄漏问题。

一、常见的内存泄漏姿势

1、内存管理关键字或函数使用不当

内存分配和释放是相互配对出现的,配对使用这些关键字或函数是内存动态分配使用的最基本原则,对于调用了内存分配函数却没有调用释放函数或者调用了不匹配的释放函数,那么内存就会出现泄漏,甚至引发堆破坏等严重问题。

作为C++特有的关键字,new和delete负责C++程序中内存的申请和释放操作,当然,鉴于C++对C的兼容性,能想到,new/delete和malloc/free一定存在联系。简而言之(不考虑operator new/delete重载和placement new),对于C++中使用new/delete的对象不同,其处理方式如下:

1、对于普通类型,例如基本数据类型(int float等)以及没有构造函数的结构体,new的操作仅仅是计算好需要分配的内存大小,然后调用malloc来完成内存的分配,delete操作也是使用free来释放分配的内存。new[]/delete[]也是一样的道理,对于普通类型,使用new[]的内存用delete或者delete[]都是OK的,不会有任何问题;

2、对于有构造和析构函数的对象,new在用malloc分配内存的同时,还需要对对象的构造函数进行调用,delete则需要对对象的析构函数进行调用然后再释放内存。对于new[]/delete[],由于需要调用对象的构造和析构函数,在分配时还需要记录数组的长度(在VC下会使用分配的内存的前4字节来记录),所以,这种情况下new[]和delete[]必须配对使用。

最简单的例子,new了没有delete或者new Object[]后使用delete而不是delete[],在使用STL容器(比如vector)保存了指针的时候,在清空容器前对保存的指针未进行相应的释放操作等。

2、代码逻辑缺陷

当然,有时候,事情往往没有眼看起来那么简单,代码中分配/释放看起来配对用的很好,但不代表就不会出现内存泄漏的问题。比如一段代码:

void func()
{
    Object obj = new Object;
    ....    
    if (condition)  return;
    ....
    dosomething(); // may throw exception
    ....
    delete obj;
}

函数在开始时分配了内存,但是在最后释放之前,函数体内的代码提前返回,或者出现了异常,那么这段未释放的内存就泄漏了,如果这个函数的逻辑非常复杂,或者异常情况不是必现,那么这种情况就更难去排查。

3、C++类设计不当

典型的,对于C++在子类中的动态分配的指针,析构函数执行释放操作,如果基类析构函数不是virtual,泄漏也会发生:

class BaseClass
{
public:
    BaseClass() {}
    ~BaseClass() {} // 没有设置为virtual 
};
class MyClass : public BaseClass
{
protected:    
    int *m_pValue;public: //  MyClass看起来内存管理的很好
    MyClass()
    {
        m_pValue = new int;
        *m_pValue = 1;        
        std::cout << "new m_pValue" << std::endl;
    }
    ~MyClass()
    { 
        delete m_pValue;
        m_pValue = NULL;        
        std::cout << "delete m_pValue" << std::endl;
    }
};

main中用子类对象初始化父类型指针,看起来没有什么不对

BaseClass* myObj = new MyClass();
delete myObj;

运行结果,pValue根本没有被释放:

还有如果缺少或错误的拷贝构造函数(包括赋值运算符重载)造成的对象浅拷贝问题,封装时函数返回动态分配的对象留下内存泄漏隐患等等。

4、多线程相关

多线程下的内存泄漏也是非常难排查的问题,比如,很多面试官喜欢问的CreateThread()和_beginthread(),_beginthread()在内部先为线程创建一个线程特有的tiddata结构,然后调用CreateThread()。如果直接使用CreateThread()的话,某些CRT函数(比如fopen、ctime、str相关函数)发现请求的tiddata为NULL,就会在现场为该线程创建该结构,静态链接CRT或者强行结束线程的话,该结构无法正确释放从而泄漏:

DWORD __stdcall threadproc(void *p)
{
    .....    
    char* r = strtok( "test", "a" ); // CRT函数调用
    .....    
    return 0;
}

int main(int argc, char* argv[])
{
    while(1)
    {
         ::CreateThread(0, 0, threadproc, 0, 0, 0);
         Sleep(5);
    }    
    return 0;
}

上述代码如果使用静态链接CRT (/MT,/MTd),tiddata没办法得到正确释放,内存占用会一直上涨。

Windows下对于创建的线程或进程,如果CloseHandle没有正确调用,也会造成内存泄漏。还有忽视线程安全造成的问题,典型的使用引用计数策略来释放内存时没有考虑线程安全造成的问题。

5、隐式内存“泄漏”

这一类严格的来说不算是内存泄漏,但是它的表现跟内存泄漏却是一致的。比如程序中使用了某个全局的容器(比如内存池),运行中,程序不断地生成对象放到这个容器中,当且仅当程序退出时,这个容器才会对其中的对象进行释放,但是实际上很多对象在程序中可能只需要引用一次,也就是说容器中实际存储的是大量的垃圾对象,如果程序在运行过程中不断地为了这些垃圾对象耗费内存,最后的表现就好像是发生了内存泄漏一样。这种问题用内存工具是检测不出来的,因为最终程序会正确地释放这些内存,并没有任何泄漏一说。其实这是程序对存储策略设计不当造成的,释放时机不对而造成了内存的浪费。

二、如何避免内存泄漏

首先要明确,这个问题绝对不是两三句能够说的清楚的,因为实际生产中,出现内存泄漏的情形多种多样,但是针对上节说到的几种情形,我们还是有一些针对的方法来避免内存泄漏的发生。

首先,在编码时,一定要有“有借有还”的意识,保持良好的编码习惯,对于动态分配的内存,一定要注意释放操作;对于复杂的逻辑,或者有异常处理的场景,尽量不要使用裸露的指针,这里不得不提到RAII(Resource Acquisition Is Initialization)即“资源获取就是初始化”技术,它是由C++之父Bjarne Stroustrup提出的一种资源管理方法,它的核心思想是将资源抽象为类,用局部对象来表示资源(内存是资源的一种),把管理资源的任务转化为管理局部对象的任务。比如上边func的代码,如果加上释放操作后是这样:

void func()
{
    Object obj = new Object;
    ....    
    if (condition)
    {          
        delete obj;        
        return;
    }
    ....    
    try
    {
        dosomething(); // may throw exception
    }    
    catch(...)
    {        
        delete obj;        
        return;
    }
    ....    
    delete obj;    
    return;
}

Object的泄漏问题解决了,但是这样写,如果函数逻辑分支复杂,或者管理的指针很多,异常情况的处理会使得代码臃肿不堪,利用RAII可以很好解决这个问题:

class Object {...};  
class SimpleRAII // 这只是个简单的例子演示 实际生产中 需考虑的问题还有很多
{  
public:  
    SimpleRAII(Object* obj):_obj(obj){} //获取资源      
    ~SimpleRAII() { delete _obj; } //释放资源      
    Object* get() { return _obj; } //访问资源  
private:  
    Object * _obj;  
};  

那么原来的代码就可以这样写:

void func()
{
    Object obj = new Object;    
    SimpleRAII raii(obj); // 使用局部对象管理指针
    ....    
    if (condition)  return;
    ....    
    // 这里即使没有try catch 也不会出现问题 raii的析构仍然会被正确调用
    try
    {
        dosomething(); // may throw exception
    }    
    catch(...) 
    {        
        return;
    }
    ....    
    return;
}

RAII典型的实践有shared_ptr、auto_ptr等(在boost库中实现,C++11开始纳入到标准库中)。

对于多线程,除非能保证线程函数中没有使用任何CRT函数,否则就不要使用CreateThread函数来创建线程,不要轻易显式使用ExitThread和TerminateThread,对于后续不需要使用的线程或进程句柄,及时使用CloseHandle关闭掉;多线程的场景下,一定要注意线程安全问题,没有把握的情况下,不要自己造轮子,尽量使用稳定的库来实现自己的需求。尽量避免使用static,关注全局对象对内存的占用情况,必要时优化程序对内存的使用策略。

三、内存泄漏的检测技术

并不是所有的程序员都能乖乖守规矩,总有犯错的时候,对于公司级产品,人肉排查内存泄漏耗时费力,所以需要借助工具,目前内存泄漏的检测,大体可分为静态扫描和动态检测两大类别,其中动态检测在代码层面又可分为侵入式和非侵入式两种。

1、静态扫描

对于分配/释放函数没有配对使用的情形,这种低级错误静态代码扫描可以马上发现,当然,一般商用的扫描工具会有强大的代码分析功能,基于词法、语法、控制流、数据流等分析点也能找到一些隐藏的错误。

这种类型的商业化工具很多,一般这类工具不光能检测内存泄漏,也能检测出代码层面的一些问题,比如编码风格、安全性等。比较有名的有Klockwork、Coverity等,这些工具一般能够发现常发性或一次性的内存泄漏,在程序没有运行(或者没有编译出来)之前就可以定位问题,类似于代码review的工作,大大提高了发现问题或风险的效率。

2、动态检测

动态检测技术在程序运行时对内存泄漏问题进行检测,能发现很多静态扫描不能发现的问题,侵入式的检测方式一般需要对源代码进行修改,比如重载operator new等,这种方式对于程序性能影响较小,定位问题也比较准确,缺点也显而易见,需要代码修改,有些方法在Release下无效,对于第三方库和没有源码的程序无能为力。这类型的工具(或者说是代码库)需要在程序编码阶段引入,比如Windows平台下面Visual Studio 调试器和CRT库为我们提供了检测和识别内存泄漏的有效方法,原理大致如下:内存分配要通过CRT在运行时实现,只要在分配内存和释放内存时分别做好记录,程序结束时对比分配内存和释放内存的记录就可以确定是不是有内存泄漏。通过包括 crtdbg.h,将 malloc 和 free 函数映射到它们的调试版本,即 _malloc_dbg 和 _free_dbg,这两个函数将跟踪内存分配和释放,然后使用_CrtDumpMemoryLeaks();就能转储出内存泄漏信息。

非侵入式的方法一般采用Hook或替换内存分配/释放函数来实现,比如使用微软研究院的detours拦截相应的API,这种方式克服了侵入式检测方式对于源代码的依赖,但是对于程序的运行性能会有一定影响(需要考虑多线程问题以及记录上的开销),对于有防注入机制的程序也会失效,而且在没有符号文件(如Windows下的PDB文件)的时候,非侵入式的检测输出信息可能没有侵入式友好,不利于问题的定位。

对于Hook目标,参照C/C++的运行库实现,对于Windows来说,调用层次结构如下:

new delete

malloc free

Windows Heap API:HeapCreate/HeapDestroy HeapAlloc/HeapFree等

Windows Virtual Memory APIVirtualAlloc/VirtualFree VirtualAllocEx/VirtualFreeEx 等

Windows 内核

物理内存

对于Windows下的普通程序,Windows Virtual Memory API这些函数是Windows API中,我们能够接触到的,内存分配的最核心的API了。一般情况下,非侵入式的Hook主要就是针对以上相关API。这类的工具非常多,比如Application Verifier、DebugDiag、Bounds Checker(后被收购集成到Devpartner Studio中)、Parallel Inspector等。当然,也有工具会同时使用侵入式和非侵入式技术,比如VLD(Visual Leak Detector)等。

四、工具的选择

综合这些现有工具,个人认为,结合静态扫描和动态检测是一种比较可行的方法,选择动态检测工具时,根据产品的特点来决定使用哪种类型的工具,如果代码改动量不大,接入侵入式工具还来的及的话,修改现有代码不失为一种好的解决方案;对于无法使用非侵入式的产品,这甚至可能是唯一的选择。非侵入式的工具接入成本相对较低,但是需要评估工具与程序的兼容性情况,工具本身使用时需要的人力成本,是否可以很容易地在现有平台上部署,还要考虑能否得到可分析性强的输出结果。