腾讯移动品质中心TMQ [腾讯 tmq] 内存泄漏漫谈

匿名 · 2016年10月26日 · 最后由 陈恒捷 回复于 2016年10月27日 · 1247 次阅读

作者:blinkjiang
对于 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 来说,调用层次结构如下:

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

四、工具的选择

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

本章完~

原文链接:enter link description here


TMQ(腾讯移动品质中心)是腾讯最早专注在移动 APP 测试的团队
我们专注于移动测试技术精华,饱含腾讯多款亿级 APP 的品质秘密,文章皆独家原创,我们不谈虚的,只谈干货!

扫码关注我们

扫一扫 关注 TMQ
精彩分享不断
共收到 2 条回复 时间 点赞

文章还是不错的。

有个小地方吐槽一下:

麻烦修改下链接文字吧?。

麻烦遵守社区规则,不要刷屏发帖,社区对于合作公司的帖子要求是二周一篇。对于其他两篇帖子,我删除了。还有帖子格式注意一下 markdown 格式,相互尊重。代码块我帮你修改了

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册