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