如何用windbg分析内存泄露_调试之路

如何定位内存泄漏

介绍

本文主要介绍一种通过windbg分析内存泄漏的方法,方法也适用linux。

这个内存泄漏问题比较经典,我个人认为是自己这么多年bug定位中一个非常好的bug,并且在分析的过程中,也有许多需要思考的地方。通过该问题的分析,你可以了解到分析内存的基本方法和思路。

现象

后台检测程序在某天上午上报了内存警告,大概就是某程序的提交内存达到了1.0G。

这里需要解释下:在windows下位应用程序如果提交内存大于某个阈值,比如我正常程序运行时提交内存最多应该只有500M,当检测程序发现该程序提交内存突然大于1.0G了,说明程序可能出现了内存泄漏。----当时就是这个进程的提交内存大于1.0G并发生了告警。

登陆后台查看,了解到如下信息:

  • 该进程已经连续运行了天
  • 提交内存每天都在持续上涨,从启动到目前为止大概累计上升了800M。
  • 句柄、线程数等资源均正常

基本上可以确定程序存在内存泄漏,让运维通过工具保存了fulldump,并重启进程(否则内存告警会一直提示)。

这时候对于有经验的人员,这个问题因为并不没有对生产环境造成影响,且等到问题发生异常时还有比较长时间,所以可以不需要立刻恢复现场,否则当问题无法定位时而现场被破坏,将很难解决问题。

分析思路

  • 代码review:通过比较上个版本和上上个版本之间的差异,找到内存泄漏的地方。

的确是可以,但存在几个问题,因为本身每天内存泄漏的非常少,且之前版本大都一个月不到就升级了,不能确定这个问题是否是之前一个版本引入的,也可能是很多个版本前引入的?

其次:这个进程处理的消息类型很多,可能有问题的消息处理早就存在,只是最近一段时间其他服务升级,导致有bug的消息处理模块被触发。所以以上原因通过review近几个版本并不一定能找到。

还有,review可能能找到多个泄漏点,但可能存在遗漏的情况,并不是该问题的本质原因,修改后问题还可能存在。

但这个方法对于有人力富于的公司还是可以的,就让一个同事review代码,还是有效果的。

  • 静态代码检测工具:

公司没有基础,临时部署时间来不及。

  • 构建复现环境:由于问题出现原因不知,而复现时间太长,找不到快速复现的方法。

在平时工作中,通过复现注释代码缩小可疑模块,是我们大都会用的有效方法,但这个场景很难找到复现方法。

  • 规避问题:通过每周半夜重启程序,规避该问题。

这个方法在很多公司都存在,因为疑难问题的解决的确非常耗时,所以一般会有一个看门狗程序,在客户不知不觉时重启进程,快速恢复,也是非常常用的方法。这对于我来说,是下下策,不到万不得已,不会使用,印象中自己没怎么用过。

  • 通过技能查找问题的根本原因。

umdh:通过在A时间点获取一个进程内存镜像,然后一段时间出现内存泄漏后,在B时间点再获取一个进程内存镜像,通过比较找到之间的差异。理论可行,但对于这个问题意义不大,本身进程是一个高并发进程,每秒都要处理上百个消息,内存有上百次的申请和释放,A和B比较后差异会非常大,很难找到真实的内存泄漏模块。

通过以上思考,在有限人力下,通过windbg分析dump的内存,查找真实内存泄漏是快速并有效的方法,下面我就针对该问题给大家介绍下我的分析思路,最后问题的解决大致花费了半个工作日的时间。

准备工作

当时的dump我保存到了百度网盘。

  • [下载地址](https://pan.baidu.com/s/1vUjAr7edFTxxcKGnGEaatQ "下载地址")(提取码:11bg)
  • 设置好系统的pdb
e:\mylocalsymbols;SRV*e:\mylocalsymbols*http://msdl.microsoft.com/download/symbols

分析方法

C++的release版程序,内存携带的信息是非常有限的,大致就是三个维度:

  • 内存大小:每次malloc申请的大小,通过大小,我们可以找到对应的结构体、类
  • 内存地址内容:通过查看内存地址内容,比如有字符串、有特殊的值,找到申请的模块
  • 内存申请次数:通过每小时申请的频率,可以找到具体的消息类型

下面就是通过这三个维度找到具体的原因。

查找内存大小

打印所有堆块信息

!heap -s

显示如下

0:> !heap -s
HEAPEXT: Unable to read ntdll!RtlpDisableHeapLookaside
Heap Flags Reserv Commit Virt Free List UCR Virt Lock Fast
(k) (k) (k) (k) length blocks cont. heap
-----------------------------------------------------------------------------
006f0000    a LFH
   0 LFH
External fragmentation  % (7 free blocks)
  
   LFH
  
006a0000   
044f0000    LFH
119d0000    c8 LFH
   bad
141d0000    bad
17f20000    bad
   bad
191b0000    bad
   bad
   bad
155f0000    bad
-----------------------------------------------------------------------------

通过观察,我们知道了是006f0000堆块占用了大量内存

HEAPEXT: Unable to read ntdll!RtlpDisableHeapLookaside
Heap Flags Reserv Commit Virt Free List UCR Virt Lock Fast
(k) (k) (k) (k) length blocks cont. heap
-----------------------------------------------------------------------------
006f0000    a LFH

查看堆块内存百分比

内存持续上涨可能是某块固定大小内存被重复申请,所以统计下该堆块中各个内存大小的分配次数

!heap -stat -h 006f0000

查找堆中各个内存大小占用的百分比

0:> !heap -stat -h 006f0000
unable to resolve ntdll!RtlpStackTraceDataBase
heap @ 006f0000
group-by: TOTSIZE max-display: 
size #blocks total ( %) (percent of total busy bytes)
 23acbbe - 2c97ead8 ()
a4 2ba0c - 1bf2fb0 ( 8f5 - 8f5000 ()
1a4 3b9c - 61cbf0 ()
20c 15fb - 2cfdc4 ( b77d - 1a8511 ( 3ba0 - 174a80 ( 75ae - 108c78 ()
11c e4a - fda18 ()
84c  - b89b0 ( - 5c800 ( -  ()
1c 2c2e - 4d508 ()
1c0  - 46c40 ()
c00 4b -  ( 1a12 -  ()
3bc ce -  ( 8da - 2c420 ( 4c -  ()
2ba d2 - 23c94 ()

size #blocks total ( %) (percent of total busy bytes)
 23acbbe - 2c97ead8 ()

TOP 中显示,最多的一个大小为 0x014 的分配次数为 0x23acbbe 次, 总共大概有700M左右。基本接近内存泄漏的总数。

所以这里得出几个结论:

  • 每次内存泄漏的大小是字节。
  • 总共分配了0x23acbbe次,运行了天,也就是每小时次/小时

定位内存来源

找到了大量的内存是0x014字节大小的,但是根据这个条件我们也找不到具体的代码啊?下面是几个思路

  • 根据大小

根据内存大小(0x14)去代码中查找大小为(0x14)的类、结构体、宏等等相关代码,然后找到原因。有几个问题:

1)、进程包含了很多其他组的dll,有的我没代码权限,无法遍历

2)、结构体、类太多了,人眼遍历太难了(针对这个问题我后来开发了一个工具,通过pdb文件可以找到程序中指定大小的所有结构体和类,后续章节讲解)

  • 内存内容

显示所有大小为(0x14)内存的地址,看它的地址内容有没有什么特点,比如是否有特殊的字符串、固定的二进制头??? 显示所有分配大小为 0x14的内存

命令
!heap -flt s :> !heap -flt s 
unable to resolve ntdll!RtlpStackTraceDataBase
_HEAP @ 6f0000
HEAP_ENTRY Size Prev Flags UserPtr UserSize - state
0071c038  [] 0071c040  - (busy)
0071c2e8  [] 0071c2f0  - (busy)
0071e498  [] 0071e4a0  - (busy)
0071e4f8  [] 0071e500  - (busy)
0071e518  [] 0071e520  - (busy)
0071e5f8  [] 0071e600  - (busy)
0071e638  [] 0071e640  - (busy)
0071e658  [] 0071e660  - (busy)
0071e798  [] 0071e7a0  - (busy)
007374f0  [] 007374f8  - (busy)
  []  - (busy)
  []  - (busy)
  []  - (busy)
  []  - (busy)
  []  - (busy)
007375b0  [] 007375b8  - (busy)
007375d0  [] 007375d8  - (busy)
007375f0  [] 007375f8  - (busy)
  []  - (busy)
  []  - (busy)
  []  - (busy)
  []  - (busy)
  []  - (busy)
..............
..............

随机抽查几个地址,看下地址内存,都是

大都是这样的值,实在是看不出规律。

建议

一般公司都会封装malloc、new函数,并分配一个模块号,每个内存地址头部都会携带id号,如下:

xxx_malloc(int nModleID,size_t size);

这样通过地址空间头也可以找到分配的模块。

  • 分配次数

大小0x14的内存在天时间内总共分配了23acbbe 次, 0x23acbbe = /((天)*(小时) ≈ 次/小时。 这个内存几乎每小时被申请次。进程有个统计功能:每个小时会统计处理的消息类型次数,那分析下数量级在1w~3w左右的消息即可,大概是4个消息类型,然后通过对这四个代码review才发现内存泄漏点。

if(total_fee){
LPADD_FEE pAddFee = new ADD_FEE;
ZeroMemory(pAddFee, sizeof(ADD_FEE));
pAddFee->nFee = total_fee;
gdt.nTotalFee = total_fee;
}

结构体 ADD_FEE ,刚好是字节

typedef struct _tagADD_FEE{
int nFee;
int nReserved[4];
}ADD_FEE, *LPADD_FEE;

完全符合!! 问题解决

总结

这是一个低级错误导致的。为了避免类视问题,引入代码静态检测

1)、cppcheck

2)、pclint

最后选了pclint。配合jenkins,每天凌晨进行代码静态检查,并输出和上个版本的diff文件,下次就不会出现这么低级的问题。

在大公司里面都会有非常多的检测工具、流程、方法论,都是前人经验的积累,虽然有点冗余繁琐,但却非常有效。当你离开这个平台后,缺少了这些流程,一旦遇到疑难问题你才发现自己能用的手段真的很少。

原文链接:,转发请注明来源!