JS內存泄漏排查方法

寫在前面
JS的內存問題每每出如今單頁應用(SPA)中,通常認爲場景特色是:前端

頁面生命週期長(用戶可能存留10分鐘、半小時甚至2小時)node

交互功能多(頁面偏功能,而不是展現)算法

重JS應用(前端有複雜的數據狀態、視圖管理)數組

內存泄漏是一個累積的過程,只有頁面生命週期略長的時候纔算是個問題(所謂「刷新一下滿血復活」)。頻繁交互可以加快累積過程,偏展現的頁面很難把這樣的問題暴露出來。最後,JS邏輯相對複雜纔有可能出現內存問題(「bug可能是由於代碼量大,我本身都hold不住」),若是隻是簡單的表單驗證提交,還沒什麼機會影響內存瀏覽器

那麼交互功能多和JS邏輯複雜的標準是什麼?到哪一種程度才比較危險?緩存

實際上,稍微有點交互功能(好比局部刷新)的簡單頁面,稍不仔細就會留下內存隱患,暴露出來就叫內存問題markdown

一.工具環境
工具:網絡

Chrome Task Manager工具閉包

Chrome DevTools Performance面板app

Chrome DevTools Memory面板

環境:

穩定,去掉網絡等變化因素(用假數據)

操做易重複,下降「累積」難度(簡化操做步驟,好比短信驗證之類的環節考慮去掉)

無干擾,排除插件影響(開隱身模式)

也就是說(Mac下):

Command + Shift + N進隱身模式

Command + Alt + I打開DevTools

輸入URL打開頁面

而後就能夠裝模做樣開始搞了

二.術語概念
先要具有基本的內存知識,瞭解DevTools提供的各項記錄含義

Mark-and-sweep
JS相關的GC算法主要是引用計數(IE的BOM、DOM對象)和標記清除(主流作法),各有優劣:

引用計數回收及時(引用數爲0當即釋放掉),但循環引用就永遠沒法釋放

標記清除不存在循環引用的問題(不可訪問就回收掉),但回收不及時須要Stop-The-World

標記清除算法步驟以下:

GC維護一個root列表,root一般是代碼中持有引用的全局變量。JS中,window對象就是一例做爲root的全局變量。window對象一直存在,因此GC認爲它及其全部孩子一直存在(非垃圾)

全部root都會被檢查並標記爲活躍(非垃圾),其全部孩子也被遞歸檢查。能經過root訪問到的全部東西都不會被當作垃圾

全部沒被標記爲活躍的內存塊都被當作垃圾,GC能夠把它們釋放掉歸還給操做系統

現代GC技術對這個算法作了各類改進,但本質都同樣:可訪問的內存塊被這樣標記出來後,剩下的就是垃圾

Shallow Size & Retained Size
能夠把內存看作由基本類型(如數字和字符串)與對象(關聯數組)構成的圖。形象一點,能夠把內存表示爲一個由多個互連的點組成的圖,以下所示:

3-->5->7
  ^      ^
 /|      |
1 |      6-->8
 \|     /^
  v    /
  2-->4

對象能夠經過兩種方式佔用內存:

直接經過對象自身佔用

經過持有對其它對象的引用隱式佔用,這種方式會阻止這些對象被垃圾回收器(簡稱GC)自動處理

在DevTools的堆內存快照分析面板會看到Shallow Size和Retained Size分別表示對象經過這兩種方式佔用的內存大小

Shallow Size
對象自身佔用內存的大小。一般,只有數組和字符串會有明顯的Shallow Size。不過,字符串和外部數組的主存儲通常位於renderer內存中,僅將一個小包裝器對象置於JavaScript堆上

renderer內存是渲染頁面進程的內存總和:原生內存 + 頁面的JS堆內存 + 頁面啓動的全部專用worker的JS堆內存。儘管如此,即便一個小對象也可能經過阻止其餘對象被自動垃圾回收進程處理的方式間接地佔用大量內存

Retained Size
對象自身及依賴它的對象(從GC root沒法再訪問到的對象)被刪掉後釋放的內存大小

有不少內部GC root,其中大部分都不須要關注。從應用角度來看,GC root有如下幾類:

Window全局對象(位於每一個iframe中)。堆快照中有一個distance字段,表示從window出發的最短保留路徑上的屬性引用數量。

文檔DOM樹,由能夠經過遍歷document訪問的全部原生DOM節點組成。並非全部的節點都有JS包裝器,不過,若是有包裝器,而且document處於活動狀態,包裝器也將處於活動狀態

有時,對象可能會被調試程序上下文和DevTools console保留(例如,在console求值計算後)。因此在建立堆快照調試時,要清除console並去掉斷點

內存圖從root開始,root能夠是瀏覽器的window對象或Node.js模塊的Global對象,咱們沒法控制root對象的垃圾回收方式

3-->5->7   9-->10
  ^      ^
 /|      |
1 |      6-->8
 \|     /^
  v    /
  2-->4

其中,1是root(根節點),7和8是基本值(葉子節點),9和10將被GC掉(孤立節點),其他的都是對象(非根非葉子節點)

Object’s retaining tree
堆是一個由互連的對象組成的網絡。在數學領域,這樣的結構被稱爲「圖」或內存圖。圖由經過邊鏈接的節點組成,二者都以給定標籤表示出來:

節點(或對象)用構造函數(用來構建節點)的名稱標記

邊用屬性名標記

distance是指與GC root之間的距離。若是某類型的絕大多數對象的distance都相同,只有少數對象的距離偏大,就有必要仔細查查

Dominator
支配對象都由樹結構組成,由於每一個對象只有一個(直接)支配者,對象的支配者可能沒有對其所支配的對象的直接引用,因此,支配者樹不是圖的生成樹

在對象引用圖中,全部指向對象B的路徑都通過對象A,就認爲A支配B。若是對象A是離對象B最近的支配對象,就認爲A是B的直接支配者

下圖中:

1     1支配2
  |     2支配3 4 6
  v
  2
/   \
v   v
4   3   3支配5
|  /|
| / |
|/  |
v   v
6   5   5支配8; 6支配7
|   |
v   v
7   8

因此7的直接支配者是6,而7的支配者是1, 2, 6

V8的JS對象表示
primitive type
3種基本類型:

數值

布爾值

字符串

它們沒法引用其它值,因此老是葉子或終端節點

數值有兩種存儲方式:

直接的31位整型值叫作小整型(SMI)

堆對象,做爲堆數值引用。堆數值用來存儲不符合SMI格式的值(例如double型),或者一個值須要被裝箱的時候,好比給它設置屬性

字符串也有兩種存儲方式:

VM堆

renderer內存(外部),建立一個wrapper對象用來訪問外部存儲空間,例如,腳本源碼和其它從Web接收到的內容都放在外部存儲空間,而不是拷貝到VM堆

新JS對象的內存分配自專用JS堆(或VM堆),這些對象由V8的GC管理,所以,只要存在一個對它們的強引用,它們就會保持活躍

Native Object
原生對象是JS堆外的全部東西。與堆對象相比,原生對象的整個生命週期不禁V8的GC管理,而且只能經過wrapper對象從JS訪問

Cons String
拼接字符串(concatenated string)由存儲並鏈接起來的成對字符串組成,只在須要時才把拼接字符串的內容鏈接起來,例如要取拼接字符串的子串時

例如,把a和b拼接起來,獲得字符串(a, b)表示鏈接結果,接着把d與這個結果拼接起來,就會獲得另外一個拼接字符串((a, b), d)

Array
數組是具備數值key的對象。在V8 VM中應用普遍,用來存儲大量數據,用做字典的鍵值對集合也採用數組形式(存儲)

典型JS對象對應兩種數組類型,用來存儲:

命名屬性

數值元素

屬性數量很是少的話,能夠放在JS對象自身內部

Map
一種描述對象種類及其佈局的對象,例如,map用來描述隱式對象層級結構實現快速屬性訪問

Object group
(對象組中)每一個原生對象由互相持有引用的對象組成,例如,DOM子樹上每一個節點都有指向其父級、下一個孩子和下一個兄弟的關聯,所以造成了一個鏈接圖。原生對象不會表示在JS堆中,因此其大小爲0。而會建立wrapper對象

每一個wrapper對象都持有對相應原生對象的引用,用來將命令重定向到自身。這樣,對象組會持有wrapper對象。但不會造成沒法回收的循環,由於GC很聰明,誰的wrapper再也不被引用了,就釋放掉對應的對象組。但忘記釋放wrapper的話,就將持有整個對象組和相關wrapper

三.工具用法
Task Manager

用來粗略地查看內存使用狀況

入口在右上角三個點 -> 更多工具 -> 任務管理器,而後右鍵表頭 -> 勾選JS使用的內存,主要關注兩列:

內存列表示原生內存。DOM節點存儲在原生內存中,若是此值正在增大,則說明正在建立DOM節點

JS使用的內存列表示JS堆。此列包含兩個值,須要關注的是實時值(括號中的數值)。實時數值表示頁面上的可訪問對象正在使用的內存量。若是該數值在增大,要麼是正在建立新對象,要麼是現有對象正在增加

Performance
用來觀察內存變化趨勢

入口在DevTools的Performance面板,而後勾選Memory,若是想看頁面首次加載過程內存使用狀況的話,Command + R刷新頁面,會自動記錄整個加載過程。想看某些操做先後的內存變化的話,操做前點「黑點」按鈕開始記錄,操做完畢點「紅點」按鈕結束記錄

記錄完畢後勾選中部的JS Heap,藍色折線表示內存變化趨勢,若是整體趨勢不斷上漲,沒有大幅回落,就再經過手動GC來確認:再操做記錄一遍,操做結束前或者過程當中作幾回手動GC(點「黑色垃圾桶」按鈕),若是GC的時間點折線沒有大幅回落,總體趨勢仍是不斷上漲,就有可能存在內存泄漏

或者更粗暴的確認方式,開始記錄 -> 重複操做50次 -> 看有沒有自動GC引起的大幅降低,在使用的內存大小達到閾值時會自動GC,若是有泄漏的話,操做n次總會達到閾值,也能夠用來確認內存泄漏問題是否已修復

P.S.還能看到document數量(可能針對iframe),節點數量、事件監聽器數量、佔用GPU內存的變化趨勢,其中節點數量及事件監聽器數量變化也有指導意義

Memory
這個面板有3個工具,分別是堆快照、內存分配狀況和內存分配時間軸:

堆快照(Take Heap Snapshot),用來具體分析各種型對象存活狀況,包括實例數量、引用路徑等等

內存分配狀況(Record Allocation Profile),用來查看分配給各函數的內存大小

內存分配時間軸(Record Allocation Timeline),用來查看實時的內存分配及回收狀況

其中內存分配時間軸和堆快照比較有用,時間軸用來定位內存泄漏操做,對快照用來具體分析問題

關於具體用法的更多介紹請查看解決內存問題

Record Allocation Timeline
點開時間軸,對頁面進行各類交互操做,出現的藍色柱子表示新內存分配,灰色的表示釋放回收,若是時間軸上存在規律性的藍色柱子,那就有很大可能存在內存泄漏

而後再反覆操做觀察,看是什麼操做致使藍色柱子殘留,剝離出具體的某個操做

Take Heap Snapshot
堆快照用來進一步分析,找到泄漏的具體對象類型

到這裏應該已經鎖定可疑的操做了,經過不斷重複該操做,觀察堆快照各項的數量變化來定位泄漏對象類型

堆快照有4種查看模式:

Summary:摘要視圖,展開並選中子項查看Object’s retaining tree(引用路徑)

Comparison:對比視圖,與其它快照對比,看增、刪、Delta數量及內存大小

Containment:俯瞰視圖,自頂向下看堆的狀況,根節點包括window對象,GC root,原生對象等等

Dominators:支配樹視圖,新版Chrome好像去掉了,展現以前術語概念部分提到的支配樹

其中最經常使用的是對比視圖和摘要視圖,對比視圖能夠把2次操做和1次操做的快照作diff,看Delta增量,找出哪類對象一直在增加。摘要視圖用來分析這類可疑對象,看Distance,找出奇怪的長路徑上,哪一環忘記斷開了

看摘要視圖有個小常識是新增的東西是黃底黑字,刪除的是紅底黑字,原本就有的是白底黑字,這一點很關鍵

關於對快照用法的更多圖示,請查看如何記錄堆快照

四.排查步驟
1.確認問題,找出可疑操做
先確認是否真的存在內存泄漏:

切換到Performance面板,開始記錄(有必要從頭記的話)

開始記錄 -> 操做 -> 中止記錄 -> 分析 -> 重複確認

確認存在內存泄漏的話,縮小範圍,肯定是什麼交互操做引發的

也能夠進一步經過Memory面板的內存分配時間軸來確認問題,Performance面板的優點是能看到DOM節點數和事件監聽器的變化趨勢,甚至在沒有肯定是內存問題拉低性能時,還能夠經過Performance面板看網絡響應速度、CPU使用率等因素

2.分析堆快照,找出可疑對象
鎖定可疑的交互操做後,經過內存快照進一步深刻:

切換到Memory面板,截快照1

作一次可疑的交互操做,截快照2

對比快照2和1,看數量Delta是否正常

再作一次可疑的交互操做,截快照3

對比3和2,看數量Delta是否正常,猜想Delta異常的對象數量變化趨勢

作10次可疑的交互操做,截快照4

對比4和3,驗證猜想,肯定什麼東西沒有被按預期回收

3.定位問題,找到緣由
鎖定可疑對象後,再進一步定位問題:

該類型對象的Distance是否正常,大多數實例都是3級4級,個別到10級以上算異常

看路徑深度10級以上(或者明顯比其它同類型實例深)的實例,什麼東西引用着它

4.釋放引用,修復驗證
到這裏基本找到問題源頭了,接下來解決問題:

想辦法斷開這個引用

梳理邏輯流程,看其它地方是否存在不會再用的引用,都釋放掉

修改驗證,沒解決的話從新定位

固然,梳理邏輯流程在一開始就能夠作,邊用工具分析,邊確認邏輯流程漏洞,左右開弓,最後驗證能夠看Performance面板的趨勢折線或者Memory面板的時間軸

五.常見案例
這些場景可能存在內存泄漏隱患,固然,作好收尾工做就能夠解決

1.隱式全局變量

function foo(arg) {
    bar = "this is a hidden global variable";
}

bar就被掛到window上了,若是bar指向一個巨大的對象,或者一個DOM節點,就會代碼內存隱患

另外一種不太明顯的方式是構造函數被直接調用(沒有經過new來調用):

function foo() {
    this.variable = "potential accidental global";
}

// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();

或者匿名函數裏的this,在非嚴格模式也指向global。能夠經過lint檢查或者開啓嚴格模式來避免這些顯而易見的問題

2.被忘記的timer或callback

var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        // Do stuff with node and someResource.
        node.innerHTML = JSON.stringify(someResource));
    }
}, 1000);

若是後續id爲Node的節點被移除了,定時器裏的node變量仍然持有其引用,致使遊離的DOM子樹沒法釋放

回調函數的場景與timer相似:

var element = document.getElementById('button');

function onClick(event) {
    element.innerHtml = 'text';
}

element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers that don't
// handle cycles well.

移除節點以前應該先移除節點身上的事件監聽器,由於IE6沒處理DOM節點和JS之間的循環引用(由於BOM和DOM對象的GC策略都是引用計數),可能會出現內存泄漏,現代瀏覽器已經不須要這麼作了,若是節點沒法再被訪問的話,監聽器會被回收掉

3.遊離DOM的引用

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image'),
    text: document.getElementById('text')
};

function doStuff() {
    image.src = 'http://some.url/image';
    button.click();
    console.log(text.innerHTML);
    // Much more logic
}

function removeButton() {
    // The button is a direct child of body.
    document.body.removeChild(document.getElementById('button'));

    // At this point, we still have a reference to #button in the global
    // elements dictionary. In other words, the button element is still in
    // memory and cannot be collected by the GC.
}

常常會緩存DOM節點引用(性能考慮或代碼簡潔考慮),但移除節點的時候,應該同步釋放緩存的引用,不然遊離子樹沒法釋放

另外一個更隱蔽的場景是:

var select = document.querySelector;
var treeRef = select("#tree");
var leafRef = select("#leaf");
var body = select("body");

body.removeChild(treeRef);

//#tree can't be GC yet due to treeRef
treeRef = null;

//#tree can't be GC yet due to indirect
//reference from leafRef

leafRef = null;
//#NOW can be #tree GC

以下圖:

JS內存泄漏排查方法

treegc

遊離子樹上任意一個節點引用沒有釋放的話,整棵子樹都沒法釋放,由於經過一個節點就能找到(訪問)其它全部節點,都給標記上活躍,不會被清除

4.閉包

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing)
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage);
    }
  };
};
setInterval(replaceThing, 1000);

粘到console執行,再經過Performance面板趨勢折線或者Memory面板時間軸看內存變化,可以發現很是規律的內存泄漏(折線穩步上升,每秒一根藍色柱子筆直筆直的)

由於閉包的典型實現方式是每一個函數對象都有一個指向字典對象的關聯,這個字典對象表示它的詞法做用域。若是定義在replaceThing裏的函數都實際使用了originalThing,那就有必要保證讓它們都取到一樣的對象,即便originalThing被一遍遍地從新賦值,因此這些(定義在replaceThing裏的)函數都共享相同的詞法環境

但V8已經聰明到把不會被任何閉包用到的變量從詞法環境中去掉了,因此若是把unused刪掉(或者把unused裏的originalThing訪問去掉),就能解決內存泄漏

只要變量被任何一個閉包使用了,就會被添到詞法環境中,被該做用域下全部閉包共享。這是閉包引起內存泄漏的關鍵

P.S.關於這個有意思的內存泄漏問題的詳細信息,請查看An interesting kind of JavaScript memory leak

六.其它內存問題
除了內存泄漏,還有兩種常見的內存問題:

內存膨脹

頻繁GC

內存膨脹是說佔用內存太多了,但沒有明確的界限,不一樣設備性能不一樣,因此要以用戶爲中心。瞭解什麼設備在用戶羣中深受歡迎,而後在這些設備上測試頁面。若是體驗不好,那麼頁面可能存在內存膨脹的問題

頻繁GC很影響體驗(頁面暫停的感受,由於Stop-The-World),能夠經過Task Manager內存大小數值或者Performance趨勢折線來看:

Task Manager中若是內存或JS使用的內存數值頻繁上升降低,就表示頻繁GC

趨勢折線中,若是JS堆大小或者節點數量頻繁上升降低,表示存在頻繁GC

能夠經過優化存儲結構(避免造大量的細粒度小對象)、緩存複用(好比用享元工廠來實現複用)等方式來解決頻繁GC問題

參考資料
4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them

Fix Memory Problems

Taming The Unicorn: Easing JavaScript Memory Profiling In Chrome DevTools

Finding JavaScript memory leaks with Chrome

Profiling memory performance

相關文章
相關標籤/搜索