QT应用程序内存泄漏问题排查方式

1. 良好的编程习惯

程序员在开发过程中由于项目时间紧、压力大或编程习惯等原因,经常提交一些“坏味道”的代码;有些代码可以在编程期间得到开发工具的warning提示,但通常都会被我们忽略掉,只要不是error就好;更糟糕的是我们编写出来的逻辑似乎总是运行的没有异常,但会变得越来越慢;或者程序似乎会在某个特定的条件下崩溃,而且没有任何日志。

C++不像jvm和.net clr运行库拥有GC机制,所有手动申请的内存及new出来的对象都需要在特定的逻辑中对其进行释放,不然程序所占的内存会越来越多,最后影响正常运行,所以C++在编程方面对开发人员的编程技能有着更高的要求。

最难捕获的就是编译期间一切正常,运行一段时间后出现问题。此处以程序运行中出现的内存泄漏,内存溢出问题对良好的编程习惯进行讨论。

1.1 使用new关键字创建的对象是在内存堆上创建的指针对象

C++没有托管堆的概念,需要在析构函数或者自定义的程序逻辑中对其进行释放。如下:

1
2
3
4
5
6
7
int* p = new int[3];
p[0] = 12;
p[1] = 20;
p[2] = 30;

delete [] p;

1.2 使用malloc申请的内存空间与free释放问题

此种方式较多出现在C语言代码程序中,C++程序对象多继承自QObject,可由构造函数传入parent父指针对象,这个QObject对象会自动添加到其父对象的children()列表。利用qt的对象树管理机制,当父对象析构的时候,这个列表中的所有对象也会被析构。

1.3 Qt的内存半自动管理

  • QObject及其派生类的对象,如果其parent非0,那么其parent析构时会析构该对象。

  • QWidget及其派生类的对象,可以设置 Qt::WA_DeleteOnClose 标志位(当close时会析构该对象)。

  • QAbstractAnimation派生类的对象,可以设置QAbstractAnimation::DeleteWhenStopped。

  • QRunnable::setAutoDelete()、MediaSource::setAutoDelete()。

2. 静态代码质量分析工具CppCheck

Cppcheck 是一种 C/C++ 代码缺陷静态检查工具。不同于 C/C++ 编译器及很多其它分析工具,它不检查代码中的语法错误。Cppcheck 只检查编译器检查不出来的 bug 类型,其目的是检查代码中真正的错误。

对如下代码进行分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void f1(struct fred_t *p)
{
// dereference p and then check if it's NULL
int x = p->x;
if (p)
do_something(x);
}

void f2()
{
const char *p = NULL;
for (int i = 0; str[i] != '\0'; i++)
{
if (str[i] == ' ')
{
p = str + i;
break;
}
}

// p is NULL if str doesn't have a space. If str always has a
// a space then the condition (str[i] != '\0') would be redundant
return p[1];
}

void f3(int a)
{
struct fred_t *p = NULL;
if (a == 1)
p = fred1;

// if a is not 1 then p is NULL
p->x = 0;
}

得到结果:

提示可能的空指针警告。
另外CppCheck也支持作为Visual Studio IDE的插件进行配合使用。

同样使用SonarQube也可以提供静态代码分析功能,gitee.com中提供此类型收费服务,可以直接分析已经提交到仓库中的项目代码。

3.针对怀疑内存泄漏的代码段进行植入式逻辑的排查方式

可以采用在文件头定义宏的方式,将分配内存与释放两个成对的操作定义为宏,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
void *_malloc(size_t size, const char *file, int line) {
void* p = malloc(size);
printf("malloc[+] %p,%s:%d\n",p,file,line);
return p;
}
void _free(void* p, const char *file,int line) {
free(p);
printf("free[-] %p,%s:%d\n",p,file,line);
}

#define malloc(size) _malloc(size,__FILE__,__LINE__);
#define free(p) _free(p,__FILE__,__LINE__);

调用方式:

1
2
3
4
5
6
int* p1 = (int*)malloc(20);
int* p2 = (int*)malloc(20);
int* p3 = (int*)malloc(20);

free(p1);
free(p2);

输出结果:

可以明显看到指针地址为:0000026BBC3FA130的对象只有申请,并未释放。因windows平台无法使用mcheck.h头文件,所以只能采取类似mtrace的内存分配与释放配对排查方式。

对上述实现宏逻辑部分的改进,直接将未释放的对象写入到文件,在怀疑有内存泄漏的代码中将特定的宏开关打开,则运行申请内存与释放内存的逻辑的代码时,若不存在配对现象,则运行时在离开代码上下文时,可以在对应的路径中找到没有释放的内存对象所在的代码文件及具体的行数。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
void *_malloc(size_t size, const char *file, int line) {
void* p = malloc(size);
//printf("malloc[+] %p,%s:%d\n",p,file,line);

char buff[128] = { 0 };
sprintf(buff, "%p.mem", p);

FILE* fp = fopen(buff, "w");
fprintf(fp, "[+%s:%d] --> addr: %p, size: %ld\n", file, line, p, size);
fflush(fp);
fclose(fp);

return p;
}
void _free(void* p, const char* file, int line) {
char buff[128] = { 0 };
sprintf(buff, "%p.mem", p);

if (unlink(buff) < 0) {
printf("double free: %p\n", p);
return;
}

free(p);
//printf("free[-] %p,%s:%d\n",p,file,line);
}

#define malloc(size) _malloc(size,__FILE__,__LINE__);
#define free(p) _free(p,__FILE__,__LINE__);

void func() {
int* p1 = (int*)malloc(20);
int* p2 = (int*)malloc(20);
int* p3 = (int*)malloc(20);

free(p1);
free(p2);
}

int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);

func();
return a.exec();
}

运行后如果存在申请对象并未释放的情形,则会在当前路径打印.mem结尾的文件,可以通过文件内容定位到具体的问题代码行。

现有QT编程中已很少使用C语言的malloc方式,转而使用new与delete配对申请与释放资源的方式,上述方法是做一个指引,可以在怀疑有内存泄漏的代码逻辑中重写operator new操作符,同样达到上述目的,做法如下:

  1. 首先定义一个分配指标的结构AllocationMetrics
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    struct AllocationMetrics {

    int TotalAllocated = 0;
    int TotalFreed = 0;

    int CurrentUsage() {
    return TotalAllocated - TotalFreed;
    }


    static AllocationMetrics& instance()
    {
    static AllocationMetrics instance_;
    return instance_;
    }

    AllocationMetrics(const AllocationMetrics&) = delete; // no copy
    AllocationMetrics& operator=(const AllocationMetrics&) = delete; // no assignment

    void PrintUsage() {

    int usage = CurrentUsage();
    qDebug() << "Memory Usage: " << QString::number(usage);
    }

    private:
    AllocationMetrics() {};
    };

此结构的的目的是分别记录对象使用new与delete操作申请与释放的内存大小,可以在申请内存之前和释放之后分别进行打印,如果释放之后打印的数字为0,则代表对象正常被释放掉,不会有内存泄漏问题。
2. 重载怀疑有内存泄漏问题的对象的new与delete操作符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Person::Person(QObject *parent)
: QObject(parent)
{
}

Person::~Person()
{
}

void Person::clean() {
if (name != NULL) {
free(name);
}
}

void* operator new(size_t size) {
//cout << "Allcating" << size << " bytes\n";

AllocationMetrics::instance().TotalAllocated += size;
return malloc(size);
}

void operator delete(void* memory, size_t size) {
//cout << "Allcating" << size << " bytes\n";

AllocationMetrics::instance().TotalFreed += size;
free(memory);
}

在new与delete操作中分别对AllocationMetrics结构的TotalAllocated与TotalFreed进行了相应的增加。
3. 使用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);

AllocationMetrics::instance().PrintUsage();

Person *p = new Person;
p->name = (char*)malloc(strlen("malloc"));
p->age = 30;
p->sex = "Male";

AllocationMetrics::instance().PrintUsage();

return a.exec();
}

上述代码中只new了指针p,并没有做释放操作,则前后的Usage分别打印为:

可以看到堆中有未释放的bytes。

完整逻辑见《Qt_MemoryLeakTest

4. 使用crtdbg工具植入代码排查

1
#include <crtdbg.h> //设置宏

详见“引用3”。实际测试代码如下:

双击泄漏提示即可跳转到具体代码行。如果取消注释上图中的delete p,则不会有70行泄漏的打印。

引用

  1. 总结一下Qt内存泄漏检测与处理策略
  2. Track MEMORY ALLOCATIONS the Easy Way in C++
  3. C++内存泄漏检测工具