1. 程式人生 > >JavaScript記憶體洩漏的排查方法

JavaScript記憶體洩漏的排查方法

概述

Google Chrome瀏覽器提供了非常強大的JS除錯工具,Heap Profiling便是其中一個。Heap Profiling可以記錄當前的堆記憶體(heap)快照,並生成物件的描述檔案,該描述檔案給出了當時JS執行所用到的所有物件,以及這些物件所佔用的記憶體大小、引用的層級關係等等。這些描述檔案為記憶體洩漏的排查提供了非常有用的資訊。

注意:本文裡的所有例子均基於Google Chrome瀏覽器。

什麼是heap

JS執行的時候,會有棧記憶體(stack)和堆記憶體(heap),當我們用new例項化一個類的時候,這個new出來的物件就儲存在heap裡面,而這個物件的引用則儲存在stack裡。程式通過stack裡的引用找到這個物件。例如var a = [1,2,3];,a是儲存在stack裡的引用,heap裡儲存著內容為[1,2,3]的Array物件。

Heap Profiling

開啟工具

開啟Chrome瀏覽器(版本25.0.1364.152 m),開啟要監視的網站(這裡以遊戲大廳為例),按下F12調出除錯工具,點選“Profiles”標籤。可以看到下圖:
這裡寫圖片描述
可以看到,該面板可以監控CPU、CSS和記憶體,選中“Take Heap Snapshot”,點選“Start”按鈕,就可以拍下當前JS的heap快照,如下圖所示:

img.png

右邊檢視列出了heap裡的物件列表。由於遊戲大廳使用了Quark遊戲庫,所以這裡可以清楚地看到Quark.XXX之類的類名稱(即Function物件的引用名稱)。

注意:每次拍快照前,都會先自動執行一次GC,所以在視圖裡的物件都是可及的。

檢視解釋

列欄位解釋:

Constructor — 類名Distance — 估計是物件到根的引用層級距離
Objects Count — 給出了當前有多少個該類的物件
Shallow Size — 物件所佔記憶體(不包含內部引用的其它物件所佔的記憶體)(單位:位元組)
Retained Size — 物件所佔總記憶體(包含內部引用的其它物件所佔的記憶體)(單位:位元組)
下面解釋一下部分類名稱所代表的意思:

(compiled code) — 未知,估計是程式程式碼區
(closure) — 閉包(array) — 未知
Object — JS物件型別(system) — 未知
(string) — 字串型別,有時物件裡添加了新屬性,屬性的名稱也會出現在這裡
Array — JS陣列型別cls — 遊戲大廳特有的繼承類
Window — JS的window物件
Quark.DisplayObjectContainer — Quark引擎的顯示容器類
Quark.ImageContainer — Quark引擎的圖片類
Quark.Text — Quark引擎的文字類
Quark.ToggleButton — Quark引擎的開關按鈕類
對於cls這個類名,是由於遊戲大廳的繼承機制裡會使用“cls”這個引用名稱,指向新建的繼承類,所以凡是使用了該繼承機制的類例項化出來的物件,都放在這裡。例如程式中有一個類ClassA,繼承了Quark.Text,則new出來的物件是放在cls裡,不是放在Quark.Text裡。

檢視物件內容

點選類名左邊的三角形,可以看到所有該類的物件。物件後面的“@70035”表示的是該物件的ID(有人會錯認為是記憶體地址,GC執行後,記憶體地址是會變的,但物件ID不會)。把滑鼠停留在某一個物件上,會顯示出該物件的內部屬性和當時的值。

img.png

這個檢視有助於我們辨別這是哪個物件。但該檢視跟蹤不了是被誰引用了。

檢視物件的引用關係

點選其中一個物件,能看到物件的引用層級關係,如下圖:

這裡寫圖片描述

Object’s retaining tree檢視顯示出了該物件被哪些物件引用了,以及這個引用的名稱。圖中的這個物件被5個物件引用了,分別是:

一個cls物件的 _txtContent 變數;
一個閉包函式的context變數;
同一個閉包函式的self變數;
一個數組物件的0位置;
一個Quark.Tween物件的target變數。
看到context和self這兩個引用,可以知道這個Quark.Text物件使用了JS常用的上下文繫結機制,被一個閉包裡的變數引用著,相當於該Quark.Text物件多了兩個引用,這種情況比較容易出現記憶體洩漏,如果閉包函式不釋放,這個Quark.Text物件也釋放不了。

展開_textContent,可以看到下一級的引用:

img.png

把這個樹狀圖反過來看,可以看到,該物件(ID @70035)其中的一條引用鏈是這樣的:

GameListV _curV _gameListV 省略…
\ | /
\ | /
_noticeWidget
|
_noticeC
|
_noticeV
|
_txtContent
||
Quark.Text @70035
記憶體快照的對比通過快照對比的功能,可以知道程式在執行期間哪些物件變更了。

剛才已經拍下了一個快照,接下來再拍一次,如下圖:

img.png

點選圖中的黑色實心圓圈按鈕,即可得到第二個記憶體快照:

img.png

然後點選圖中的“Snapshot 2”,檢視才會切換到第二次拍的快照。

點選圖中的“Summary”,可彈出一個列表,選擇“Comparison”選項,結果如下圖:

img.png

這個檢視列出了當前檢視與上一個檢視的物件差異。列名欄位解釋:# New — 新建了多少個物件# Deleted — 回收了多少個物件# Delta — 物件變化值,即新建的物件個數減去回收了的物件個數Size Delta — 變化的記憶體大小(位元組)注意Delta欄位,尤其是值大於0的物件。下面以Quark.Tween為例子,展開該物件,可看到如下圖所示:

img.png

在“# New”列裡,如果有“.”,則表示是新建的物件。

在“# Deleted”列裡,如果有“.”,則表示是回收了的物件。

平時排查問題的時候,應該多拍幾次快照進行對比,這樣有利於找出其中的規律。

記憶體洩漏的排查

JS程式的記憶體溢位後,會使某一段函式體永遠失效(取決於當時的JS程式碼執行到哪一個函式),通常表現為程式突然卡死或程式出現異常。

這時我們就要對該JS程式進行記憶體洩漏的排查,找出哪些物件所佔用的記憶體沒有釋放。這些物件通常都是開發者以為釋放掉了,但事實上仍被某個閉包引用著,或者放在某個數組裡面。

觀察者模式引起的記憶體洩漏

有時我們需要在程式中加入觀察者模式(Observer)來解藕一些模組,但如果使用不當,也會帶來記憶體洩漏的問題。

排查這型別的記憶體洩漏問題,主要重點關注被引用的物件型別是閉包(closure)和陣列Array的物件。

下面以德州撲克遊戲為例:

測試人員發現德州撲克遊戲存在記憶體溢位的問題,重現步驟:進入遊戲–退出到分割槽–再進入遊戲–再退出到分割槽,如此反覆幾次便出現遊戲卡死的問題。

排查的步驟如下:

開啟遊戲;
進入第一個分割槽(快速場5/10);
進入後,拍下記憶體快照;
退出到剛才的分割槽介面;
再次進入同一個分割槽;
進入後,再次拍下記憶體快照;
重複步驟2到6,直到拍下5組記憶體快照;
將每組的檢視都轉換到Comparison對比檢視;
進行記憶體對比分析。
經過上面的步驟後,可以得到下圖結果:

img.png

先看最後一個快照,可以看到閉包(closure)+1,這是需要重點關注的部分。(string)、(system)和(compiled code)型別可以不管,因為提供的資訊不多。

img.png

接著點選倒數第二個快照,看到閉包(closure)型別也是+1。

img.png

接著再看上一個快照,閉包還是+1。

這說明每次進入遊戲都會建立這個閉包函式,並且退出到分割槽的時候沒有銷燬。

展開(closure),可以看到非常多的function物件:

img.png

建新的閉包數量是49個,回收的閉包數量是48個,即是說這次操作有48個閉包正確釋放了,有一個忘記釋放了。每個新建和回收的function物件的ID都不一樣,找不到任何的關聯性,無法定位是哪一個閉包函數出了問題。

接下來開啟Object’s retaining tree檢視,查詢引用裡是否存在不斷增大的陣列。

如下圖,展開“Snapshot 5”每個function物件的引用:

img.png

其中有個function物件的引用deleFunc存放在一個數組裡,下標是4,陣列的物件ID是@45599。

繼續查詢“Snapshot 4”的function物件:

img.png

發現這裡有一個function的引用名稱也是deleFunc,也存放在ID為@45599的數組裡,下標是3。這個物件極有可能是沒有釋放掉的閉包。

繼續檢視“Snapshot 3”裡的function物件:

img.png

從圖中可以看到同一個function物件,下標是2。那麼這裡一定存在記憶體洩漏問題。

陣列下面有一個引用名稱“login_success”,在程式裡搜尋一下該關鍵字,終於定位到有問題的程式碼。因為進入遊戲的時候註冊了“login_success”通知:

ob.addListener(“login_success”, _onLoginSuc);
但退出到分割槽的時候,沒有移除該通知,下次進入遊戲的時候,又再註冊了一次,所以造成function不斷增加。改成退出到分割槽的時候移除該通知:

ob.removeListener(“login_success”, _onLoginSuc);
這樣就成功解決這個記憶體洩漏的問題了。

德州撲克這種問題多數見於觀察者設計模式中,使用一個全域性陣列儲存所有註冊的通知,如果忘記移除通知,則該陣列會不斷增大,最終造成記憶體溢位。

上下文繫結引起的記憶體洩漏

很多時候我們會用到上下文繫結函式bind(也有些人寫成delegate),無論是自己實現的bind方法還是JS原生的bind方法,都會有記憶體洩漏的隱患。

下面舉一個簡單的例子:

<script type="text/javascript">
                var ClassA = function(name){
                        this.name = name;
                        this.func = null;
                };

                var a = new ClassA("a");
                var b = new ClassA("b");

                b.func = bind(function(){
                        console.log("I am " + this.name);
                }, a);

                b.func();  //輸出 I am a

                a = null;        //釋放a
                //b = null;        //釋放b

                //模擬上下文繫結
                function bind(func, self){
                        return function(){
                                return func.apply(self);
                        };
                }; 
</script>

上面的程式碼中,bind通過閉包來儲存上下文self,使得事件b.func裡的this指向的是a,而不是b。

首先我們把b = null;註釋掉,只釋放a。看一下記憶體快照:

img.png

可以看到有兩個ClassA物件,這與我們的本意不相符,我們釋放了a,應該只存在一個ClassA物件b才對。

這裡寫圖片描述

從上面兩個圖可以看出這兩個物件中,一個是b,另一個並不是a,因為a這個引用已經置空了。第二個ClassA物件是bind裡的閉包的上下文self,self與a引用同一個物件。雖然a釋放了,但由於b沒有釋放,或者b.func沒有釋放,使得閉包裡的self也一直存在。要釋放self,可以執行b=null或者b.func=null。

把程式碼改成:

<script type="text/javascript">
                var ClassA = function(name){
                        this.name = name;
                        this.func = null;
                };

                var a = new ClassA("a");
                var b = new ClassA("b");

                b.func = bind(function(){
                        console.log("I am " + this.name);
                }, a);

                b.func();        //輸出 I am a
                a = null;        //釋放a

                b.func = null;        //釋放self

                //模擬上下文繫結
                function bind(func, self){
                        return function(){
                                return func.apply(self);
                        };
                };
</script>

再看看記憶體:

img.png

可以看到只剩下一個ClassA物件b了,a已被釋放掉了。

結語

JS的靈活性既是優點也是缺點,平時寫程式碼時要注意記憶體洩漏的問題。當代碼量非常龐大的時候,就不能僅靠複查程式碼來排查問題,必須要有一些監控對比工具來協助排查。

之前排查記憶體洩漏問題的時候,總結出以下幾種常見的情況:

閉包上下文繫結後沒有釋放;
觀察者模式在新增通知後,沒有及時清理掉;
定時器的處理函式沒有及時釋放,沒有呼叫clearInterval方法;
檢視層有些控制元件重複新增,沒有移除。