介紹
本文主要介紹一種通過windbg分析內存泄漏的方法,方法也適用linux。
這個內存泄漏問題比較經典,我個人認為是自己這么多年bug定位中一個非常好的bug,并且在分析的過程中,也有許多需要思考的地方。通過該問題的分析,你可以了解到分析內存的基本方法和思路。
現象
后臺檢測程序在某天上午上報了內存警告,大概就是某程序的提交內存達到了1.0G。
這里需要解釋下:在windows下32位應用程序如果提交內存大于某個閾值,比如我正常程序運行時提交內存最多應該只有500M,當檢測程序發現該程序提交內存突然大于1.0G了,說明程序可能出現了內存泄漏。----當時就是這個進程的提交內存大于1.0G并發生了告警。
登陸后臺查看,了解到如下信息:
- 該進程已經連續運行了90天
- 提交內存每天都在持續上漲,從啟動到目前為止大概累計上升了800M。
- 句柄、線程數等資源均正常

原圖沒有了,查看90天的提交內存大致如上
基本上可以確定程序存在內存泄漏,讓運維通過工具保存了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:000> !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 00000002 1246976 1241928 1246976 982 236 81 0 a LFH
00190000 00001002 3136 1564 3136 390 7 3 0 0 LFH
External fragmentation 24 % (7 free blocks)
00110000 00001002 256 4 256 1 1 1 0 0
02050000 00001002 256 176 256 1 18 1 0 0 LFH
02240000 00001002 256 4 256 2 1 1 0 0
006a0000 00001002 64 12 64 4 2 1 0 0
044f0000 00001002 256 216 256 7 4 1 0 0 LFH
119d0000 00001002 7424 5820 7424 134 133 4 0 c8 LFH
14290000 00001003 256 4 256 2 1 1 0 bad
141d0000 00001003 256 4 256 2 1 1 0 bad
17f20000 00001003 256 4 256 2 1 1 0 bad
19030000 00001003 256 4 256 2 1 1 0 bad
191b0000 00001003 256 4 256 2 1 1 0 bad
19380000 00001003 256 4 256 2 1 1 0 bad
19300000 00001003 256 4 256 2 1 1 0 bad
155f0000 00001003 256 4 256 2 1 1 0 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 00000002 1246976 1241928 1246976 982 236 81 0 a LFH
查看堆塊內存百分比
內存持續上漲可能是某塊固定大小內存被重復申請,所以統計下該堆塊中各個內存大小的分配次數
!heap -stat -h 006f0000
查找堆中各個內存大小占用的百分比
0:000> !heap -stat -h 006f0000
unable to resolve ntdll!RtlpStackTraceDataBase
heap @ 006f0000
group-by: TOTSIZE max-display: 20
size #blocks total ( %) (percent of total busy bytes)
14 23acbbe - 2c97ead8 (92.78)
a4 2ba0c - 1bf2fb0 (3.63)
1000 8f5 - 8f5000 (1.16)
1a4 3b9c - 61cbf0 (0.79)
20c 15fb - 2cfdc4 (0.37)
25 b77d - 1a8511 (0.22)
64 3ba0 - 174a80 (0.19)
24 75ae - 108c78 (0.13)
11c e4a - fda18 (0.13)
84c 164 - b89b0 (0.09)
400 172 - 5c800 (0.05)
234 265 - 54684 (0.04)
1c 2c2e - 4d508 (0.04)
1c0 287 - 46c40 (0.04)
c00 4b - 38400 (0.03)
20 1a12 - 34240 (0.03)
3bc ce - 30148 (0.02)
50 8da - 2c420 (0.02)
800 4c - 26000 (0.02)
2ba d2 - 23c94 (0.02)
size #blocks total ( %) (percent of total busy bytes)
14 23acbbe - 2c97ead8 (92.78)
TOP 20 中顯示,最多的一個大小為 0x014 的分配次數為 0x23acbbe 次, 總共大概有700M左右。基本接近內存泄漏的總數。
所以這里得出幾個結論:
- 每次內存泄漏的大小是20字節。
- 總共分配了0x23acbbe次,運行了90天,也就是每小時17318次/小時
定位內存來源
找到了大量的內存是0x014字節大小的,但是根據這個條件我們也找不到具體的代碼啊?下面是幾個思路
- 根據大小
根據內存大小(0x14)去代碼中查找大小為(0x14)的類、結構體、宏等等相關代碼,然后找到原因。有幾個問題:
1)、進程包含了很多其他組的dll,有的我沒代碼權限,無法遍歷
2)、結構體、類太多了,人眼遍歷太難了(針對這個問題我后來開發了一個工具,通過pdb文件可以找到程序中指定大小的所有結構體和類,后續章節講解)
- 內存內容
顯示所有大小為(0x14)內存的地址,看它的地址內容有沒有什么特點,比如是否有特殊的字符串、固定的二進制頭??? 顯示所有分配大小為 0x14的內存
命令
!heap -flt s 14
0:000> !heap -flt s 14
unable to resolve ntdll!RtlpStackTraceDataBase
_HEAP @ 6f0000
HEAP_ENTRY Size Prev Flags UserPtr UserSize - state
0071c038 0004 0000 [00] 0071c040 00014 - (busy)
0071c2e8 0004 0004 [00] 0071c2f0 00014 - (busy)
0071e498 0004 0004 [00] 0071e4a0 00014 - (busy)
0071e4f8 0004 0004 [00] 0071e500 00014 - (busy)
0071e518 0004 0004 [00] 0071e520 00014 - (busy)
0071e5f8 0004 0004 [00] 0071e600 00014 - (busy)
0071e638 0004 0004 [00] 0071e640 00014 - (busy)
0071e658 0004 0004 [00] 0071e660 00014 - (busy)
0071e798 0004 0004 [00] 0071e7a0 00014 - (busy)
007374f0 0004 0004 [00] 007374f8 00014 - (busy)
00737510 0004 0004 [00] 00737518 00014 - (busy)
00737530 0004 0004 [00] 00737538 00014 - (busy)
00737550 0004 0004 [00] 00737558 00014 - (busy)
00737570 0004 0004 [00] 00737578 00014 - (busy)
00737590 0004 0004 [00] 00737598 00014 - (busy)
007375b0 0004 0004 [00] 007375b8 00014 - (busy)
007375d0 0004 0004 [00] 007375d8 00014 - (busy)
007375f0 0004 0004 [00] 007375f8 00014 - (busy)
00737610 0004 0004 [00] 00737618 00014 - (busy)
00737630 0004 0004 [00] 00737638 00014 - (busy)
00737650 0004 0004 [00] 00737658 00014 - (busy)
00737670 0004 0004 [00] 00737678 00014 - (busy)
00737690 0004 0004 [00] 00737698 00014 - (busy)
..............
..............
隨機抽查幾個地址,看下地址內存,都是00 00 00 00 00
大都是這樣的值,實在是看不出規律。
建議
一般公司都會封裝malloc、new函數,并分配一個模塊號,每個內存地址頭部都會攜帶id號,如下:
xxx_malloc(int nModleID,size_t size);
這樣通過地址空間頭也可以找到分配的模塊。
- 分配次數
大小0x14的內存在90天時間內總共分配了23acbbe 次, 0x23acbbe = 37407678/(90(天)*24(小時) ≈ 17318次/小時。 這個內存幾乎每小時被申請17318次。進程有個統計功能:每個小時會統計處理的消息類型次數,那分析下數量級在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 ,剛好是20字節
typedef struct _tagADD_FEE{
int nFee;
int nReserved[4];
}ADD_FEE, *LPADD_FEE;
完全符合!! 問題解決
總結
這是一個低級錯誤導致的。為了避免類視問題,引入代碼靜態檢測
1)、cppcheck
2)、pclint
最后選了pclint。配合jenkins,每天凌晨進行代碼靜態檢查,并輸出和上個版本的diff文件,下次就不會出現這么低級的問題。
在大公司里面都會有非常多的檢測工具、流程、方法論,都是前人經驗的積累,雖然有點冗余繁瑣,但卻非常有效。當你離開這個平臺后,缺少了這些流程,一旦遇到疑難問題你才發現自己能用的手段真的很少。