寫在前面
JS 的記憶體問題往往出現在單頁應用(SPA)中,一般認為場景特點是:
-
頁面生命週期長(用戶可能存留 10 分鐘、半小時甚至 2 小時)
-
交互功能多(頁面偏功能,而不是展示)
-
重 JS 應用(前端有複雜的數據狀態、視圖管理)
記憶體洩漏是一個累積的過程,只有頁面生命週期略長的時候才算是個問題(所謂「刷新一下滿血復活」)。頻繁交互能夠加快累積過程,偏展示的頁面很難把這樣的問題暴露出來。最後,JS 邏輯相對複雜才有可能出現記憶體問題(「bug 多是因為代碼量大,我自己都 hold 不住」),如果只是簡單的表單驗證提交,還沒什麼機會影響記憶體
那麼交互功能多和 JS 邏輯複雜的標準是什麼?到哪種程度才比較危險?
實際上,稍微有點交互功能(比如局部刷新)的簡單頁面,稍不仔細就會留下記憶體隱患,暴露出來就叫記憶體問題
一。工具環境
工具:
-
Chrome Task Manager 工具
-
Chrome DevTools Performance 面板
-
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
如下圖:
[caption id="attachment_1464" align="alignnone" width="368"]
treegc[/caption]
遊離子樹上任意一個節點引用沒有釋放的話,整棵子樹都無法釋放,因為通過一個節點就能找到(訪問)其它所有節點,都給標記上活躍,不會被清除
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 問題
暫無評論,快來發表你的看法吧