记一次内存泄露调试

首先介绍一下相关背景。最近在测试一个程序时发现,在任务执行完成之后,从任务管理器上来看,内存并没有下降到理论值上。程序在启动完成之后会占用一定的内存,在执行任务的时候,会动态创建一些内存,用于存储任务的执行状态,比如扫描了哪些页面,在扫描过程中一些收发包的记录等等信息。这些中间信息在任务结束之后会被清理掉。任务结束之后,程序只会保存执行过的任务列表,从理论上讲,任务结束之后,程序此时所占内存应该与程序刚启动时占用内存接近,但是实际观察的结果就是任务结束之后,与刚启动之时内存占用差距在100M以上,这很明显不正常,当时我的第一反应是有内存泄露

内存泄露排查

既然有内存泄露,那么下一步就是开始排查,由于程序是采用MFC编写的,那么自然就得找MFC的内存泄露排查手段。
根据网上找到的资料,MFC在DEBUG模式中可以很方便的集成内存泄露检查机制的。
首先在 stdafx.h 的头文件中加入

1
2
#define _CRTDBG_MAP_ALLO
#include <crtdbg.h>

再在程序退出的地方加入代码

1
_CrtDumpMemoryLeaks();

如果发生内存泄露的话,在调试运行结束之后,观察VS的输出情况可以看到如下内容

1
2
3
4
5
Detected memory leaks!
Dumping objects ->
.\MatriXayTest.cpp(38) : {1301} normal block at 0x0000000005584D30, 40 bytes long.
Data: < > CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD
Object dump complete.

在输出中会提示有内存泄露,下面则是泄露的具体内容,MatriXayTest.cpp 是发生泄露的代码文件,括号中的38代表代码所在行数,大括号中1301代表这是程序的第1301次分配内存,或者说第1301次执行malloc操作,再往后就是内存泄露的地址,以及泄露的大小,这个地址是进程启动之后随机分配的地址,参考意义不大。下面一行表示,当前内存中的具体值,从值上来看应该是分配了内存但是没有初始化。
根据这个线索,我们来排查,找到第38行所在位置

1
2
3
int *p = new int[10];
_CrtDumpMemoryLeaks();
return nRetCode;

内存泄露正是出现在new了10个int类型的数据,但是后面没有进行delete操作,正是这个操作导致了内存泄露。

到此为止,检测工具也找到了,下面就是加上这段代码,运行发生泄露的程序,查看结果

再漫长的等待任务执行完成并自动停止之后,我发现居然没有发现内存泄露!!!

我又重复运行任务多次,发现结果是一样的,这个时候我开始怀疑是不是这个库不准,于是我在数据节点的类中添加构造函数,统计任务执行过程中创建了多少个节点,再在析构中统计析构了多少个节点,最终发现这两个数据完全相同。也就是说真的没有发生内存泄露。

在这里我也疑惑了,难道是任务管理器有问题?带着这个疑问,我自己写了一段代码,在程序中不定时打印内存占用情况,结果发现虽然与任务管理器有差异,但是结果是类似的,释放之后内存并没有大幅度的下降。

我带着疑问查找资料的过程的漫长过程中,发现任务管理器的显示内存占用居然降下去了,我统计了一下时间,应该是在任务结束之后的30分钟到40分钟之间。带着这个疑问,我又重新发起任务,在任务结束,并等待一定时间之后,内存占用果然降下去了。

这里我得出结论 程序中执行delete操作之后,系统并不会真的立即回收操作,而是保留这个内存一定时间,以便后续该进程在需要分配时直接使用

结论验证

科学一般来说需要大胆假设,小心求证,既然上面根据现象做了一些猜想,下面就需要对这个猜想进行验证。

首先来验证操作系统在程序调用delete之后不会真的执行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
//定义一个占1M内存的结构
struct data{
char c[1024 * 1024];
}

data* pa[1024] = {0};
for (int i = 0; i < 1024; i++)
{
pa[i] = new data;
//这里执行一下数据清零操作,以便操作系统真正为程序分配内存
//有时候调用new或者malloc操作,操作系统只是保留对应地址,但是并未真正分配物理内存
//操作会等到进程真正开始操作这块内存或者进程需要分配的内存总量达到一个标准时才真正进行分配
memset(pa[i], 0x00, sizeof(data));
}

printf("内存分配完毕,按任意键开始释放内存...\n");
getchar();
for (int i = 0; i < 1024; i++)
{
delete pa[i];
}

printf("内存释放完毕,按任意键退出\n");
_CrtDumpMemoryLeaks();
char c = getchar();

通过调试这段代码,在刚开始运行,没有执行到new操作的时候,进程占用内存在2M左右,运行到第一个循环结束,分配内存后,占用内存大概为1G,在执行完delete之后,内存并没有立马下降到初始的2M,而是稳定在150M左右,过一段时间之后,程序所占用内存会将到2M左右。

接着对上面的代码做进一步修改,来测试内存使用时间长度与回收所需时间的长短的关系。这里仍然使用上面定义的结构体来做尝试

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
48
49
50
51
52
53
54
55
56
57
58
59
60
data* pa[1024] = {0};
for (int i = 0; i < 1024; i++)
{
pa[i] = new data;
memset(pa[i], 0x00, sizeof(data));
}

printf("内存分配完毕,按任意键开始写数据到内存\n");
getchar();
//写入随机字符串
srand((unsigned) time(NULL));
DWORD dwStart = GetTickCount();
DWORD dwEnd = dwStart;

printf("开始往目标内存中写入数据\n");
while ((dwEnd - dwStart) < 1 * 60 * 1000) //执行时间为1分钟
{
for (int i = 0; i < 1024; i++)
{
for (int j = 0; j < 1024; j++)
{
int flag = rand() % 3;
switch (flag)
{
case 1:
{
//生成大写字母
pa[i]->c[j] = (char)(rand() % 26) + 'A';
}
break;
case 2:
{
//生成小写字母
pa[i]->c[j] = (char)(rand() % 26) + 'a';
}
break;
case 3:
{
//生成数字
pa[i]->c[j] = (char)(rand() % 10) + '0';
}
break;
default:
break;
}
}
}
dwEnd = GetTickCount();
}

printf("数据写入完毕,按任意键开始释放内存...\n");
getchar();
for (int i = 0; i < 1024; i++)
{
delete pa[i];
}

printf("内存释放完毕,按任意键退出\n");
_CrtDumpMemoryLeaks();
char c = getchar();

后面就不放测试的结果了,我直接说结论,同一块内存使用时间越长,操作系统真正保留它的时间也会越长。短时间内差别可能不太明显,长时间运行,这个差别可以达到秒级甚至分。

我记得当初上操作系统这门课程的时候,老师说过一句话:一个在过去使用时间越长的资源,在未来的时间内会再次使用到的概率也会越高,基于这一原理,操作会保留这块内存一段时间,如果程序在后面再次申请对应结构时,操作系统会直接将之前释放的内存拿来使用。为了验证这一现象,我来一个小的测试

1
2
3
4
5
int *p1 = new int[1024];
memset(p, 0x00, sizeof(int) * 1024);
delete[] p;

int* p2= new int[1024];

通过调试发现两次返回的地址相同,也就验证了之前说的内容

总结

最后来总结一下结论,有时候遇到delete内存后任务管理器或者其他工具显示内存占用未减少或者减少的量不对时,不一定是发生了内存泄露,也可能是操作系统的内存管理策略:程序调用delete后操作系统并没有真的立即回收对应内存,它只是暂时做一个标记,后续真的需要使用相应大小的内存时会直接将对应内存拿出来以供使用。而具体什么时候真正释放,应该是由操作系统进行宏观调控。

我觉得这次暴露出来的问题还是自己基础知识掌握不扎实,如果当时我能早点回想起来当初上课时所讲的内容,可能也就不会有这次针对一个错误结论,花费这么大的精力来测试。当然这个世界上没有如果,我希望看到这篇博文的朋友,能少跟风学习新框架或者新语言,少被营销号带节奏,沉下心了,补充计算机基础知识,必将受益匪浅。