作者 | 木及
來源 | 阿里技術公眾號
很多開發者可能平時並不關心自己維護的頁面是否存在內存洩漏,原因可能是剛開始簡單的頁面內存洩漏的速度很緩慢,在造成嚴重卡頓之前可能就被用戶刷新了,問題也就被隱藏了,但是隨著頁面越來越複雜,尤其當你的頁面是 SAP 方式交互時,內存洩漏的隱患便越來越嚴重,直到突然有一天用戶反饋說:“操作一會兒頁面就卡住不動了,也不知道為什麼,以前不這樣的呀”。
這篇文章通過一些簡單的例子介紹內存洩漏的調查方法、總結內存洩漏出現的原因和常見情況,並針對每種情況總結如何避免內存洩漏。希望能對大家有所幫助。
一 一個簡單的例子
先看一個簡單的例子,下面是這個例子對應的代碼:
代碼 1
代碼 1 的邏輯很簡單:點擊“add date”按鈕時會向 dateAry 數組中 push 3000 個 new Date 對象,點擊“clear”按鈕時將 dateAry 清空。很明顯,“add date”操作會造成內存佔用不斷增長,如果將這個邏輯用在實際應用中便會造成內存洩漏(不考慮故意將代碼邏輯設計成這樣的情況),下面我們看一下如何調查這種內存增長出現的原因以及如何找出內存洩漏點。
1 heap snapshot
為了避免瀏覽器插件的干擾,我們在 chrome 中新建一個無痕窗口打開上述代碼。然後在 chrome 的 devtools 中的 Memory 工具中找到 “Heap Snapshot”工具,點擊左上角的錄製按鈕錄製一個 Snapshot,然後點擊“add date”按鈕,在手動觸發 GC(Garbage Collect)之後,再次錄製一個 Snapshot,反覆執行上述操作若干次,像圖 1 中操作的那樣,得到一系列的 Snapshot。
圖 1 錄製 Snapshot
圖 2 是我們剛剛得到的 Snapshot 組,其中的第一個是頁面初始加載的時候錄製的,不難發現,從第二個開始,每個 Snapshot 相比於上一個其大小都增加了約 200KB,我們點擊選擇 Snapshot 2,在 class filter 輸入框中處輸入 date,可以得到 Snapshot 2 中所有被 Date 構造器構造出來的 JS 對象,也就是 Date 對象,這裡看到的構造器跟瀏覽器內部的實現有關,不必跟 JS 的對象對應。
選中一個 Date 對象,在下面的面板中可以看到所選對象的持有鏈以及相關持有對象的內存的保留大小(Retained Size),從圖中可以看出選中的 Date 對象是 Array 的第 1 個元素(index 從 0 開始),而這個 Array 的持有者是 system/Context 上下文中的 dateAry,system/Context 上下文就是代碼中 script 標籤的上下文,我們可以看到在這個 dataAry 的保留大小是 197KB,我們再切到 Snapshot 3,用相同的方式查看內存持有和大小,可以發現 Snapshot 3 中的 dataAry 的保留大小變成了 386KB,相比於 Snapshot 2 增漲了約 200KB!逐一比較後面的 Snapshot 4、5 後也能得到相同的對比結果,即下一個 Snapshot 中的 dateAry 比上一個的保留大小大約 200KB。
圖 2 錄製的 Snapshot 組
參考【代碼 1】我們可以知道,“add date”按鈕在被點擊時,會向 dateAry 數組中 push 3000 個新的 Date 對象,而在圖 2 中的 Date 構造器的右側可以看到這 3000 個 Date 對象(Date x 3000),它對應的正式我們的循環創建的那 3000 個 Date 對象。綜合上面的操作我們可以知道,chorome devtools 中的 Memroy 的 Heap Snapshot 工具可以錄製某一個時刻的所有內存對象,也就是一個“快照”,快照中按“構造器”分組,展示了所有被記錄下來的 JS 對象。
如果這個頁面是一個實際服務於用戶的網站的某個頁面話(用戶可能非常頻繁的點擊“add date”按鈕,作者可能想記錄用戶點擊的次數?也許吧,雖然我也不知道他什麼要這麼做)隨著用戶使用時間的增長,“add date”按鈕的反應就會越來越慢,整體頁面也隨之越來越卡,原因除了系統的內存資源被佔用之外,還有 GC 的頻率和時長增長,如圖 3 所示,因為 GC 執行的過程中 JS 的執行是被暫停的,所以頁面就會呈現出越來越卡的樣子。
圖 3 Performance 錄製的 GC 佔比
圖 4 chrome 的任務管理器
最終:
圖 5 內存佔用過高導致瀏覽器崩潰
那麼,在這個“實際”的場景下,如何找出那“作祟”的 3000 個 Date 對象呢?我們首先想到的應該是就是:之前不是錄製了好多個 Snapshot 嗎?可不可以把它們做對比找到“差異”呢,從差異中找到增長的地方不就行了?思路非常正確,在此之前我們再分析一下這幾個 Snapshot:每次點擊“add date”按鈕、手動觸發 GC、得到的 Snapshot 的大小相比上一次都有所增加,如果這種內存的增長現象不符合“預期”的話(顯然在這個“實際”的例子中是不符合預期的),那麼這裡就有很大的嫌疑存在內存洩漏。
這個時候我們選中 Snapshot 2,在圖 2 所示的 " Summary" 處選擇“Comparison”,在右側的 "All objects" 處選擇 Snapshot 1,這樣一來,Constructor 裡展示便是 Snapshot 1 和 Snapshot 2 的對比,通過觀察不難發現,圖中的 +144KB 最值得懷疑,於是我們選中它的構造器 Date,展開選中任意子項看詳情,發現其是被 Array 構造器構造出來的 dateAry 持有的(即 dateAry 中的一員),並且 dateAry 被三個地方持有,其中系統內部的 array 我們不用理會,圖 6 中寫有 "context in ()" 地方給了我們持有 dateAry 的 context 所在的位置,點擊便可以跳到代碼所在的位置了,整個操作如圖 6 所示:
圖 6 定位代碼位置
這裡有一個值得注意的地方,圖 6 中的 “context in () @449305” 中的 "()",這裡之所以展示為了 "()" 是因為代碼中用了“匿名函數”(代碼 2 中第 2 行的箭頭函數):
// 【寫入 date】
pushDate.addEventListener("click", () => {
dateCount.innerHTML = `${++dateNum}`;
for (let j = 0; j < 3000; ++j) {
dateAry.push(new Date());
}
});
代碼 2 匿名函數
但是如果我們給函數起一個名字,如下面的代碼所示,也就是如果我們使用具名函數(代碼3 第 2 行函數 add)或者將函數賦值給一個變量並使用這個變量(第 10 和 18 行的行為)的時候,devtools 中都可以看到相應的函數的名字,這也就可以幫助我們更好的定位代碼,如圖 7 所示。
// 【寫入 date】
pushDate.addEventListener("click", function add() {
dateCount.innerHTML = `${++dateNum}`;
for (let j = 0; j < 3000; ++j) {
dateAry.push(new Date());
}
});
const clear = document.querySelector(".clear");
const doClear = function () {
dateAry = [];
dateCount.innerHTML = "0";
};
// 【回收內存】
clear.addEventListener("click", doClear);
代碼 3 具名函數
圖 7 具名函數方便定位
這樣我們便找到了代碼可疑的地方,只需要將代碼的作者抓過來對著他一頓“分析”這個內存洩漏的問題基本就水落石出了。
其實,Snapshot 除了“Comparison”之外還有一個更便捷的用於對比的入口,在這裡直接可以看到在錄製 Snapshot 1 和 Snapshot 2 兩個時間點之間被分配出來的內存,用這種方式也可以定位到那個可疑的 Date x 3000:
圖 8 Snapshot 比較器
上文件介紹的是用 Heap Snapshot 尋找內存洩漏點的方法,這個方法的優點:可以錄製多個 Snapshot,然後方便的兩兩比較,並且能看到 Snapshot 中的全量內存,這一點是下文要講的“Allocation instrumentation on timeline”方法不具備的,並且這種方法可以更加方便地查找後面會講的因 Detached Dom 導致的內存洩漏。
2 Allocation instrumentation on timeline
但是,不知道你有沒有覺得,這種高頻率地錄製 Snapshot、對比、再對比的方式有點兒麻煩?我需要不斷的去點擊“add date”,然後鼠標又要跑過去點擊手動 GC、錄製 Snapshot、等待錄製完畢,再去操作,再去錄製。有沒有簡單一些的方式來查找內存洩漏?這個時候我們回到 Memory 最初始的界面,你突然發現 “Heap snapshot”下面還有一個 radio:“Allocation instrumentation on timeline”,並且這個 radio 下面的介紹文案的最後寫著:“Use this profile type to isolate memory leaks”,原來這是一個專門用於調查內存洩漏的工具!於是,我們選中這個 radio,點擊開始錄製按鈕,然後將注意力放在頁面上,然後你發現當點擊“add date”按鈕時,右面錄製的 timeline 便會多出一個心跳:
圖 9 Allocation instrumentation on timeline
如圖 9 所示,每當我們點擊“add date”按鈕時,右面都有一個對應的心跳,當我們點擊“clear”按鈕時,剛才出現的所有心跳便全都“縮回”去了,於是我們得出結論:每一個“心跳”都是一次內存分配,其高度代表內存分配的量,在之後的時間推移過程中,如果剛才心跳對應的被分配的內存被 GC 回收了,“心跳”便會跟著變化為回收之後的高度。於是,我們便擺脫了在 Snapshot 中來回操作、錄製的窘境,只需要將注意力集中在頁面的操作上,並觀察哪個操作在右邊的時間線變化中是可疑的。
經過一系列操作,我們發現“add date”這個按鈕的點擊行為很可疑,因為它分配的內存不會自動被回收,也就是隻要點擊一次,內存就會增長一點,我們停止錄製,得到了一個 timeline 的 Snapshot,這個時候如果我們點擊某個心跳的話:
圖 10 點擊某個心跳
熟悉的 Date x 3000 又出現了(圖 11),點擊一個 Date 對象看持有鏈,接下來便跟上文 Snapshot 的持有鏈分析一樣了:
圖 11 通過 timeline 找到洩漏點
這個方法的優點上文已經說明,可以非常直觀、方便的觀察內存隨可疑操作的分配與回收過程,可以方便的觀察每次分配的內存。它的缺點:錄製時間較長時 devtools 收集錄制結果的時間會很長,甚至有時候會卡死瀏覽器;下文會講到 detached DOM,這個工具不能比較出 detached DOM,而 heap snapshot 可以。
3 performance
devtools 中的 Performance 面版中也有一個 Memory 功能,下面看一下它如何使用。我們把 Memory 勾選上,並錄製一個 performance 結果:
圖 12 Performance 的錄製過程
在圖 12 中可以看到,在錄製的過程中我們連續點擊“add date”按鈕 10 次,然後點擊一次“clear”按鈕,然後再次點擊“add date” 10 次,得到的最終結果如圖 13 所示:
圖 13 Performance 的錄製結果
在圖 13 中我們可以得到下面的信息:
- 整個操作過程中內存的走勢:參見圖 13 下方的位置,第一輪點擊 10 次的過程中內存不斷增長,點 clear 之後內存斷崖式下跌,第二輪點擊 10 次內存又不斷增長。這也是這個工具的主要作用:得到可疑操作的內存走勢圖,如果內存持續走高則有理由懷疑此操作由內存洩漏的可能。
- 內存的增長量:參見 JS Heap 位置,鼠標放上去可以看見每個階梯上下位置的內存增長/下跌的量
- 通過在 timeline 中定位某個“階梯”,我們也能找到可疑的代碼,如圖 14 所示:
圖 14 通過 Performance 定位問題代碼
這種方法的優點:可以直觀得看到內存的總體走勢,並且同時得到所有操作過程中的函數調用棧和時間等信息。缺點:沒有具體的內存分配的細節,錄製的過程不能實時看到內存分配的過程。
二 內存洩漏出現的場景
1 全局
JS 採用標記清掃法去回收無法訪問的內存對象,被掛載在全局對象(在瀏覽器中即指的是 window 對象,在垃圾回收的角度上稱其為根節點,也叫 GC root)上的屬性所佔用內存是不會被回收的,因為其是始終可以訪問的,這也符合“全局”的命名含義。
解決方案就是避免用全局對象存儲大量的數據。
2 閉包(closure)
我們把【代碼 1】稍加改動便可以得到一個閉包導致內存洩漏的版本:
代碼 3 閉包導致內存洩漏
將上述代碼加載到 chrome 中,並用 timeline 的方式錄製一個 Snapshot,得到的結果如圖 15 所示:
圖 15 閉包的錄製結果
我們選中 index = 2 的心跳,可以看到 Constructor 裡面出現了一個 "(closure)",我們展開這個 closure,可以看到裡面的 "inner()",inner() 後面的 "()" 表示 inner 是一個函數,這時候你可能會問:“圖中的 Constructor 的 Retained Size 大小都差不多,為什麼你要選 (closure)?”,正是因為沒有明顯佔比較高的 Retained Size 我們才隨便選一個調查,後面你會發現不管你選了哪一個最後的調查鏈路都是殊途同歸的。
我們在下面的 Retainers 中看下 inner() 的持有細節:從下面的 Retainers 中可以看出 inner() 這個 closure 是某個 Array 的第 2 項(index 從 0 開始),而這個數組的持有者是 system/Context(即全局) 中的 ary,通過觀察可以看到 ary 的持有大小(Retained Size)是 961KB 大約等於 192KB 的 5 倍,5 即是我們點擊“add date”按鈕的次數,而下面的 5 個 "previous in system/Context" 每個大小都是 192KB,而它們最終都是被某個 inner() 閉包持有,至此我們便可以得出結論:全局中有一個 ary 數組,它的主要內存是被 inner() 填充的,通過藍色的 index.html:xx 處的代碼入口定位到代碼所在地看一下一切就都瞭然了,原來是 inner() 閉包內部持有了一個大對象,並且所有的 inner() 閉包及其持有的大對象都被 ary 對象持有,而 ary 對象是全局的不會被回收,導致了內存洩漏(如果這種行為不符合預期的話)。返回去,如果這個時候你選擇上面提到的 system/Context 構造器,你會看到(見圖 16,熟悉吧):
圖 16 system/Context
也就是你選擇的 system/Context 其實是 inner() 閉包的上下文對象(context),而此上下文持有了 192KB 內存,通過藍色的 index.html:xx 又可以定位到問題代碼了。如果你像圖 17 一樣選擇了 Date 構造器進行查看的話也可以最終定位到問題,此處將分析過程留給讀者自己進行:
圖 17 選中 Date 構造器
3 Detached DOM
我們先看一下下面的代碼,並用 chrome 載入它:
代碼 4 Detached Dom
然後我們採用 Heap Snapshot 的方式將點擊“del”按鈕前後的兩個 snapshot 錄製下來,得到的結果如圖 6 所示。我們選用和 snapshot 1 對比的方式並在 snapshot 2 的過濾器中輸入 "detached"。我們觀察得到的篩選結果的 "Delta" 列,其中不為 0 的列如下:
要解釋上述表格需要先介紹一個知識點:DOM 對象被回收需要同時滿足兩個條件,1、DOM 在 DOM 樹中被刪掉;2、DOM 沒有被 JS 對象引用。其中第二點還是比較容易被忽視的。正如上面的例子所示,Detached HTMLButtonElement +1 代表有一個 button DOM 被從組件樹中刪掉了,但是仍有 JS 引用之(我們不考慮有意為之的情況)。
相似的,Detached EventListener 也是因為 DOM 被刪掉了,但是事件沒有解綁,於是 Detached 了,解決方案也很簡單:及時解綁事件即可。
於是解決的方法就很簡單了:參見代碼 5,回掉函數 del 在執行完畢時臨時變量會被回收,於是兩個條件就都同時滿足了,DOM 對象就會被回收掉,事件解綁了,Detached EventListener 也就沒有了。值得注意的是 table 元素,如果一個 td 元素髮生了 detached,則由於其自身引用了自己所在的 table,於是整個 table 就也不會被回收了。
代碼 5 Detached DOM 的解決方法
圖 18 Detached DOM 的 Snapshot
Performance monitor 工具
DOM/event listener 洩漏在編寫輪播圖、彈窗、toast 提示這種工具的時候還是很容易出現的,chrome 的 devtools 中有一個 Performance monitor 工具可以用來幫助我們調查內存中是否有 DOM/event listener 洩漏。首先看一下代碼 6:
代碼 6 不斷增加 DOM NODE
按照我們圖 19 的方式打開 Performance monitor 面版:
圖 19 打開 Performance monitor 工具
DOM Nodes 右側的數量是當前內存中的所有 DOM 節點的數量,包括當前 document 中存在的和 detached 的以及計算過程中臨時創建的,每當我們點擊一次“add date”按鈕,並手動觸發 GC 之後 DOM Nodes 的數量就 + 2,這是因為我們向 document 中增加了一個 button 節點和一個 button 的文字節點,就像圖 20 中所示。如果你寫的 toast 組件在臨時插入到 document 並過一會兒執行了 remove 之後處於了 detached 狀態的話,Performance monitor 面版中的 DOM Nodes 數量就會不斷增加,結合 snapshot 工具你便可以定位到問題所在了。值得一提的是,有的第三方的庫的 toast 便存在這個問題,不知道你被坑過沒有。
圖 20 不斷增加的 DOM Nodes
4 console
這一點可能有人不會留意到,控制檯打印的內容是需要始終保持引用的存在的,這一點也是值得注意的,因為打印過多過大對象的話也是會造成內存洩漏的,如圖 21 所示(配合代碼 7)。解決方法便是不要肆意打印對象到控制檯中,只打印必要的信息出來。
代碼 7 console 導致內存洩漏
圖 21 console 導致的內存洩漏
三 總結
本文用了幾個簡單的小例子介紹了內存洩漏出現的時機、尋找洩漏點的方法並將各種方法的優缺點進行了對比,總結了避免出現內存洩漏的注意點。希望能對讀者有所幫助。文中如果有本人理解錯誤或書寫錯誤的地方歡迎留言指正。
參考
https://commandlinefanatic.com/cgi-bin/showarticle.cgi?article=art038
https://developer.chrome.com/docs/devtools/memory-problems/
https://www.bitdegree.org/learn/chrome-memory-tab
前端開發技術圖譜
6 大知識點,14 個課程,680 個課時,將前端開發知識和實戰經驗融入圖譜,包含 HTML 、CSS、JavaScript 、jQuery 、Vue 、React 、Angular 、NodeJS 等前端開發必備技能,幫你迅速提升。
點擊這裡,開始學習吧~