JS內存泄漏排查方法——Chrome Profiles

 

1、概述

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 對象。

2、Heap Profiling

打開工具

打開 Chrome 瀏覽器(版本 25.0.1364.152 m),打開要監視的網站(這裏以遊戲大廳爲例),按下 F12 調出調試工具,點擊「 Profiles 」標籤。能夠看到下圖:

<ignore_js_op> 圖片1.jpg

能夠看到,該面板能夠監控 CPU CSS 和內存,選中「 Take Heap Snapshot 」,點擊「 Start 」按鈕,就能夠拍下當前 JS heap 快照,以下圖所示:

<ignore_js_op> 圖片2.jpg

右邊視圖列出了 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 不會)。把鼠標停留在某一個對象上,會顯示出該對象的內部屬性和當時的值。

<ignore_js_op> 圖片3.png

這個視圖有助於咱們辨別這是哪一個對象。但該視圖跟蹤不了是被誰引用了。


查看對象的引用關係

點擊其中一個對象,能看到對象的引用層級關係,以下圖:

<ignore_js_op> 圖片4.png

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

1. 一個 cls 對象的 _txtContent 變量;

2. 一個閉包函數的 context 變量;

3. 同一個閉包函數的 self 變量;

4. 一個數組對象的 0 位置;

5. 一個 Quark.Tween 對象的 target 變量。


看到 context self 這兩個引用,能夠知道這個 Quark.Text 對象使用了 JS 經常使用的上下文綁定機制,被一個閉包裏的變量引用着,至關於該 Quark.Text 對象多了兩個引用,這種狀況比較容易出現內存泄漏,若是閉包函數不釋放,這個 Quark.Text 對象也釋放不了。

展開 _textContent ,能夠看到下一級的引用:

<ignore_js_op> 圖片5.png

把這個樹狀圖反過來看,能夠看到,該對象 (ID @70035) 其中的一條引用鏈是這樣的:

GameListV       _curV       _gameListV     省略 ...
                  \         |        /
                    \       |       /
                  _noticeWidget
                           |
                     _noticeC
                           |
                     _noticeV
                           |
                  _txtContent
                           ||
             Quark.Text @70035

內存快照的對比經過快照對比的功能,能夠知道程序在運行期間哪些對象變動了。

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

<ignore_js_op> 圖片6.png

點擊圖中的黑色實心圓圈按鈕,便可獲得第二個內存快照:

<ignore_js_op> 圖片7.png

而後點擊圖中的「 Snapshot 2 」,視圖纔會切換到第二次拍的快照。

<ignore_js_op> 圖片8.png

點擊圖中的「 Summary 」,可彈出一個列表,選擇「 Comparison 」選項,結果以下圖:

<ignore_js_op> 圖片9.png

這個視圖列出了當前視圖與上一個視圖的對象差別。列名字段解釋:# New --  新建了多少個對象# Deleted --  回收了多少個對象# Delta --  對象變化值,即新建的對象個數減去回收了的對象個數Size Delta --  變化的內存大小 ( 字節 )注意 Delta 字段,尤爲是值大於 0 的對象。下面以 Quark.Tween 爲例子,展開該對象,可看到以下圖所示:

<ignore_js_op> 圖片10.png

在「 # New 」列裏,若是有「 . 」,則表示是新建的對象。

在「 # Deleted 」列裏,若是有「 . 」,則表示是回收了的對象。

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


3、內存泄漏的排查

JS 程序的內存溢出後,會使某一段函數體永遠失效(取決於當時的 JS 代碼運行到哪個函數),一般表現爲程序忽然卡死或程序出現異常。

這時咱們就要對該 JS 程序進行內存泄漏的排查,找出哪些對象所佔用的內存沒有釋放。這些對象一般都是開發者覺得釋放掉了 ,但事實上仍被某個閉包引用着,或者放在某個數組裏面。


觀察者模式引發的內存泄漏

有時咱們須要在程序中加入觀察者模式( Observer )來解藕一些模塊,但若是使用不當,也會帶來內存泄漏的問題。

排查這類型的內存泄漏問題,主要重點關注被引用的對象類型是閉包( closure )和數組 Array 的對象。

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

<ignore_js_op> 圖片11.png

<ignore_js_op> 圖片12.png

測試人員發現德州撲克遊戲存在內存溢出的問題,重現步驟:進入遊戲 -- 退出到分區 -- 再進入遊戲 -- 再退出到分區,如此反覆幾回便出現遊戲卡死的問題。

排查的步驟以下:

1.打開遊戲;

2.進入第一個分區(快速場 5/10 );

3.進入後,拍下內存快照;

4.退出到剛纔的分區界面;

5.再次進入同一個分區;

6.進入後,再次拍下內存快照;

7.重複步驟 2 6 ,直到拍下 5 組內存快照;

8.將每組的視圖都轉換到 Comparison 對比視圖;

9.進行內存對比分析。

通過上面的步驟後,能夠獲得下圖結果:

<ignore_js_op> 圖片13.png

先看最後一個快照,能夠看到閉包( closure +1 ,這是須要重點關注的部分。( string )、( system )和( compiled code )類型能夠無論,由於提供的信息很少。

<ignore_js_op> 圖片14.png

接着點擊倒數第二個快照,看到閉包( closure )類型也是 +1

<ignore_js_op> 圖片15.png

接着再看上一個快照,閉包仍是 +1

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

展開( closure ),能夠看到很是多的 function 對象:

<ignore_js_op> 圖片16.png

建新的閉包數量是 49 個,回收的閉包數量是 48 個,便是說此次操做有 48 個閉包正確釋放了,有一個忘記釋放了。每一個新建和回收的 function 對象的 ID 都不同,找不到任何的關聯性,沒法定位是哪個閉包函數出了問題。

接下來打開 Object's retaining tree 視圖,查找引用裏是否存在不斷增大的數組。

以下圖,展開「 Snapshot 5 」每一個 function 對象的引用:

<ignore_js_op> 圖片17.png

其中有個 function 對象的引用 deleFunc 存放在一個數組裏,下標是 4 ,數組的對象 ID @45599

繼續查找「 Snapshot 4 」的 function 對象:

<ignore_js_op> 圖片18.png

發現這裏有一個 function 的引用名稱也是 deleFunc ,也存放在 ID @45599 的數組裏,下標是 3 。這個對象極有多是沒有釋放掉的閉包。

繼續查看「 Snapshot 3 」裏的 function 對象:

<ignore_js_op> 圖片19.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 。看一下內存快照:

<ignore_js_op> 圖片20.png

能夠看到有兩個 ClassA 對象,這與咱們的本意不相符,咱們釋放了 a ,應該只存在一個 ClassA 對象 b 纔對。

<ignore_js_op> 圖片21.png

從上面兩個圖能夠看出這兩個對象中,一個是 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>

 


再看看內存:

<ignore_js_op> 圖片22.png

能夠看到只剩下一個 ClassA 對象 b 了, a 已被釋放掉了。


4、結語

JS 的靈活性既是優勢也是缺點,平時寫代碼時要注意內存泄漏的問題。當代碼量很是龐大的時候,就不能僅靠複查代碼來排查問題,必需要有一些監控對比工具來協助排查。

以前排查內存泄漏問題的時候,總結出如下幾種常見的狀況:

1.閉包上下文綁定後沒有釋放;

2. 觀察者模式在添加通知後,沒有及時清理掉;

3. 定時器的處理函數沒有及時釋放,沒有調用 clearInterval 方法;

4. 視圖層有些控件重複添加,沒有移除。
相關文章
相關標籤/搜索