JavaScript 內存泄漏排查方法

一、概述
本文主要介紹了如何通過 Devtools 的 Memory 內存工具排查 JavaScript 內存泄漏問題。先介紹了一些相關概念,說明了 Memory 內存工具的使用方式,然后介紹了堆快照的分析方式,說明如何通過分析堆快照找到泄漏的 JavaScript 代碼,最后列舉了一些 JavaScript 內存泄漏的排查案例。

二、概念說明
1、內存泄漏
內存泄漏(Memory Leak)是指程序中已動態(tài)分配的堆內存由于某種原因程序未釋放或無法釋放,造成系統(tǒng)內存的浪費,導致程序運行速度減慢,甚至系統(tǒng)崩潰等嚴重后果。
簡單來說就是,按照業(yè)務邏輯,本該被回收的對象,可能因為某些代碼的實現(xiàn)不合理,導致對象沒有被及時回收,進而對象占用的內存無法釋放,導致內存的浪費。
2、Memory 常用功能

- 快照查看方式:主要有摘要和對比
- 類篩選器:可以過濾構造函數(shù),但是不能過濾對象名
例如,要查看包含video的構造函數(shù)名:

3、堆快照視圖
3.1 摘要視圖

- 構造函數(shù):JS構造函數(shù),以及由JS引擎、框架或庫創(chuàng)建的構造函數(shù)
- 距離 (distance):與 GC root 之間的距離,如果某節(jié)點沒有 distance,通常說明該節(jié)點即將被 gc 回收
- 卷影大小/淺層大小 (shadow size):對象本身的大小
淺層大小可以直觀地看出內存具體地分配給哪些對象了 - 保留的大小 (retained size):對象釋放后可以回收的內存大?。▍⒖糲hrome的定義)
- 保留的大小說明了哪些對象導致了內存占用高,但不一定是這個對象本身內存高,可能是因為它引用的對象占用內存高。
- 有時候某個對象的 retained size,不等于其所有屬性的 retained size 之和。因為該對象的多個屬性都被回收之后,才能讓這多個屬性引用的對象回收,所以這些被引用的對象的大小不會計入此對象中。例如這里的 DOMTimer@1199111936,保留的大小為 1468B ,但其引用的一個context@2160207 保留的大小為 124520B,已經(jīng)大于了 DOMTimer 的保留大小了。因為這個context不止被這個 DOMTimer 引用,還被其他的 DOMTimer 引用,需要這些 DOMTimer 全部被回收之后,才能把這個 context 回收,因此它的大小不計入 DOMTimer 中。換句話說,如果只有 DOMTimer 引用這個 context,那 context 的保留大小就會計入 DOMTimer 中。

3.2 對比視圖
- 新建:快照查看方式選擇比較后,若是新建列中有一個點,則表示是在兩個快照之間新建的對象

- 已刪除:同理,若在已刪除列有一個點,表示在兩個快照之間刪除的對象
- 增量:指對象增加的數(shù)量
- 分配大?。涸趦蓚€快照之間分配的大小
- 已釋放:在兩個快照之間釋放的內存大小
- 大小增量:在兩個快照之間增長的淺層大小
4、構造函數(shù)和對象
4.1 構造函數(shù)
- 在上半部分的構造函數(shù)這一列中,第一層都是構造函數(shù),后面的數(shù)字表示這個類的對象數(shù)。例如下圖中,當前的Object數(shù)量為89160
- 展開某一個對象后,子元素表示該對象的屬性,例如這里Object@207683的屬性包含aweme_list、map等
- 對象名的::之后的即對象所屬的類,@符號之后的表示對象id
4.2 對象
在下半部分的對象這一列中,節(jié)點之間的關系為:當前節(jié)點被子節(jié)點引用。例如前3行,aweme_list被一個Object類型的對象H引用,H被一個Context類型的對象context引用,context被一個函數(shù)類型的對象get $$引用。
藍色的鏈接可以跳轉到源代碼,源代碼中會以下劃線標注某段代碼:

表示在這段代碼中,當前對象引用了下一個對象。例如下圖中的4784.0ec58630.js:1的某段代碼中,$$()函數(shù)的上下文context引用了H

4.3 查看對象信息
通過鼠標懸停在對象上,可以查看對象信息,不過并不是所有對象都能查看到信息,顯示"預覽不可用"的對象可能已經(jīng)被回收了。

4.4 在頁面訪問 dom
- 如果對象的右邊有個窗口圖標,則表示可以在窗口訪問這個元素,鼠標懸停在對象上,可以查看信息,同時會在頁面高亮該對象,例如這個video標簽是當前視頻所在video標簽

- 有時候即使有這個窗口圖標,頁面中也不會高亮這個元素(可能已經(jīng)是Detached狀態(tài)了),或者有些元素沒有這個窗口圖標。這個時候如果還想知道這個是什么元素,可以查看其信息,找到其對應的class,然后在“元素”中搜索
例如這里的Detached HTMLImageElement@2407721對象,它實際上就是頁面中的“搶”標簽:


- 如果查看元素對象的信息時,顯示"預覽不可用",則暫時沒有辦法找到該元素。此時可以看看它引用了哪些元素或者被哪些元素引用,看看是否能在頁面中查看這些元素,如果可以,再以此推測之前的元素。
5、堆快照常見對象類型
5.1 Detached DOM
- 如果刪除了某個dom節(jié)點,但仍有變量對此節(jié)點存在引用關系,則這個dom節(jié)點就會變成游離狀態(tài),也就是不存在于document上了。
- 簡單來說就是,dom節(jié)點已經(jīng)不存在于頁面中了,但仍然被JS對象引用著。
5.2 DOM Timer
定時器。setInterval()和setTimeout()函數(shù)會創(chuàng)建。是最容易出現(xiàn)泄漏的對象之一,寫代碼時,很容易出現(xiàn)創(chuàng)建了定時器但是沒有銷毀的情況,這樣就會導致定時器引用的對象泄漏。
5.3 Context
通常指函數(shù)的上下文
- 例如以下代碼中,會自動為inlineTestFunc函數(shù)創(chuàng)建一個context對象,該context對象會引用variable
function testFunc(){
const variable = 'I am refereced by inlineTestFunc()'
const inlineTestFunc = function () {}
return inlineTestFunc
}
window.testFunc = testFunc()- 如果在其他地方引用了inlineTestFunc()函數(shù),那么variable變量也會同時被引用。
5.4 Closure
閉包:函數(shù)以及其捆綁的周邊環(huán)境狀態(tài)
5.5 Compiled code
運行代碼占用的內存,通常不會出現(xiàn)內存泄漏
5.6 InternalNode
瀏覽器內置對象,通常不需要關注,一方面是因為導致內存泄漏的一般是JS對象,而不是內部對象。另一方面是因為它造成的內存泄漏在前端不好解決。如果確實需要獲得InternalNode的具體對象名,來排查內存泄漏,可以通過在編譯chrome時添加特定參數(shù)來實現(xiàn),可以參考:
- InternalNode是什么:
https://link.juejin.cn/?target=https%3A%2F%2Fstackoverflow.com%2Fquestions%2F66802111%2Fwhat-is-internalnode-in-chrome-heap-profile - 如何編譯Chrome:
https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fchromium%2Fchromium%2Fblob%2Fmaster%2Fdocs%2Fwindows_build_instructions.md
三、排查步驟
1、Devtools手動排查
使用 Devtools 的 Memory 內存工具來對 JS 內存泄漏進行排查分析

1.1 復現(xiàn)和堆快照抓取
- 確定疑似有內存泄漏的操作,例如抖音PC客戶端中切換視頻、發(fā)送評論、發(fā)送彈幕等。復現(xiàn)操作要盡可能的小,并且最好盡可能地排除其他變量的干擾,這對后續(xù)的問題定位有很大的影響。
- 訪問不壓縮代碼的頁面(可選)
- 手動或用js腳本寫 puppeteer 在瀏覽器復現(xiàn)
- GC 后拍攝快照;執(zhí)行疑似導致內存泄漏的操作;GC 后拍攝快照
a. 執(zhí)行疑似泄漏的操作時,建議重復執(zhí)行多次,讓上漲的內存大于 30MB 以上,根據(jù)過往經(jīng)驗,最好在100MB~200MB左右(太大的話抓快照太慢了),這樣會比較容易觀察,否則上漲的內存太小,不容易發(fā)現(xiàn)是哪些對象增長。通常情況下,這個快照大小幾乎可以直接看出哪些對象異常。例如這里切換了 50 個視頻,可以明顯地看出 Video 元素的異常:

1.2 篩選泄漏的引用鏈
比較執(zhí)行泄漏操作前和執(zhí)行泄漏操作后的快照,篩選疑似泄漏的引用鏈
1.2.1 篩選方法
手動篩選時,往往很難 100% 確定哪些對象是泄漏的,一般來說只能是懷疑某些對象泄漏,有了懷疑的對象后,按照下一步的方法來分析引用鏈是否合理,如果沒有找到可疑的引用鏈,那么就需要反復進行1.2和1.3步驟,才能更容易找到泄漏的對象。如果說看了好多對象,或者看了幾分鐘都沒看出來有哪些異常,那么可能是測試得到堆快照對比的增量大小太小了,不容易直接看出來,或者是觀察的數(shù)據(jù)類型不好找到泄漏,比如Object、Array就很難篩選出泄漏的對象,這時可以考慮看其他數(shù)據(jù)類型。
1.2.2 優(yōu)先看內存增量大的對象
優(yōu)先看內存增量比較高的數(shù)據(jù),例如某些對象的大小增量為 100M,而其他的就只有 10M 不到,那么優(yōu)先看大小增量為 100M 的。如果都內存增長都差不多,可以繼續(xù)按下面步驟進行排查。
如果某些對象大小增量比較高,說明它們最有可能是泄漏的對象。它們?yōu)槭裁礇]有被回收?可以通過點開對象查看引用的信息,分析該對象整個引用跟蹤鏈路,來找到其中某個不合理的引用。
1.2.3 注意內存占用大的 Detached 元素
如果沒有內存占用相對較高的對象,或者有好幾種數(shù)據(jù)大小增長都差不多,可以從Detached元素入手,Detached元素出現(xiàn)內存泄漏的概率比較大,可以觀察內存占用相對比較高的Detached元素,原因有兩個:
這里需要注意的是,Detached元素的淺層大小通常是很小的,而前面提到過,通過堆快照對比得出的“大小增量”指的是淺層大小增量,所以在堆快照的對比視圖里,Detached元素的大小增量一般都會比較小,它實際造成的泄漏是大于“大小增量”這個值的,那么怎么知道它造成了多少泄漏?可以點開某個元素,可以看到它的保留的大小,這個就反應出了它造成的內存泄漏大小。
- 一方面是因為object、array之類的數(shù)據(jù),通常對象數(shù)量非常大(幾萬個是比較常見的),并且有很多是正常對象,而泄漏的對象混雜在其中很難分辨哪些是泄漏的、哪些是正常的,除非泄漏的對象非常多,占據(jù)它們數(shù)量的大部分,或者有個別對象占用的內存特別大時,才比較容易直接觀察到。
- 另一方面是Detached元素,通常對象數(shù)較少,并且比較容易出現(xiàn)相似的引用鏈,如果這里面有新的泄漏對象出現(xiàn),很容易發(fā)現(xiàn)這些新增的泄漏對象

1.2.4 注意常見泄漏類型
事件監(jiān)聽(EventListener)、定時器(DOMTimer)、數(shù)組(Array),這是最容易導致內存泄漏的幾種數(shù)據(jù)類型,比如監(jiān)聽事件之后沒有及時取消監(jiān)聽,定時器開啟之后沒有銷毀,數(shù)組元素無限增加,可以專門針對這幾種類型進行排查,并對用到這類對象的代碼格外注意。
- 可通過多次復現(xiàn)泄漏操作的方式來確定某些對象或者數(shù)組是否存在泄漏。先抓取快照,鼠標懸停到疑似泄漏的數(shù)組上,查看信息,記錄下數(shù)組長度,然后執(zhí)行一遍疑似導致內存泄漏的操作,再查看數(shù)組長度(這里查看到的對象信息是實時的,所以無需抓快照也能看到當前的數(shù)組詳情),如果數(shù)組增長了,那么就有可能是泄漏的,接下來可以多重復幾次導致泄漏的操作,看看是否按預期增長,如果按預期增長,那么基本可以確定是泄漏對象。(為了確保準確性,可以在記錄長度前先進行 GC)
- 下圖中通過查看數(shù)組信息,可以看到數(shù)組長度為 986,執(zhí)行一次疑似泄漏操作后,長度變?yōu)榱?989。因此推測這里每執(zhí)行一次疑似泄漏操作會 +3 個元素,所以推測執(zhí)行 10 次疑似泄漏操作后,會增加 30 個,這里經(jīng)過驗證確實是增加 30 個,并且經(jīng)過一段時間后,依舊沒有減少,所以基本可以確定這個數(shù)組為泄漏對象

1.2.5 注意頻繁出現(xiàn)的引用鏈
內存泄漏通常會引起很多類似的對象無法被銷毀,因此很容易會出現(xiàn)很多對象的引用鏈是一樣的,所以可以在點開某種數(shù)據(jù)類型后(如DOM元素、object、string),多觀察幾個對象的引用鏈,如果某一條類似的引用鏈頻繁出現(xiàn),那么很有可能該引用鏈中出現(xiàn)了泄漏。
- 例如下面的圖中,10 個Object對象出現(xiàn)了 8 個相同的引用鏈,因此這條引用鏈中很可能存在泄漏,通過不斷測試可以確定引用鏈中的InternalNode是無限增長的,到此可以認為這里是存在內存泄漏的,再對HTMLVideoElement進行分析,最終確定是MediaElement和VideoElement存在內存泄漏。


1.3 確定泄漏的對象和代碼
在上一步驟中,篩選出一些疑似泄漏的引用鏈后,開始分析引用鏈的泄漏對象,確定導致泄漏的某個引用。
通常可以先不看InternalNode對象
1.3.1 從業(yè)務邏輯分析哪些是泄漏對象
根據(jù)業(yè)務邏輯來分析哪些對象是不應該存在的,查找創(chuàng)建或者引用了這個對象的代碼片段。可以先搞清楚整個引用鏈存在的原因,通過引用鏈上的對象的信息,或者dom元素信息等,來分析這個引用鏈因為哪一個業(yè)務邏輯而存在,根據(jù)這個業(yè)務邏輯聯(lián)想可能的泄漏情況,比如常見的事件、定時器是否已清除。
- 例如下圖這個引用鏈,從observerList和domResizeListener這兩個對象名可以推測這里使用了監(jiān)聽器模式,domResizeListener是監(jiān)聽器,當改變頁面大小的時候,通過調用observerList里的觀察者的回調函數(shù),修改某些 dom 的大小。根據(jù)這個代碼邏輯聯(lián)想,懷疑某些地方把一些 dom 元素加入到observerList里了,但是 dom 銷毀的時候沒有取消監(jiān)聽,dom 元素依然存在observerList里,導致無法被回收。

1.3.2 導致泄漏的引用通?!熬嚯x”較大
- 導致泄漏的引用比較容易發(fā)生在“距離”較大的地方,也就是距離根節(jié)點比較遠的對象(DOMTimer除外)。距離根節(jié)點較遠的對象,和業(yè)務代碼的相關性比較大,距離根節(jié)點較近的對象,大多都是一些常駐對象,或者是難以回收的對象;而“距離”較大的對象,往往只會被一個對象引用,因為“距離”大的對象往往是業(yè)務代碼創(chuàng)建的,而“距離”較小的對象,通常會被很多對象引用,或者是一些底層的框架之類的,往往不容易出現(xiàn)泄漏。
a. 例如下圖中,上方的array為泄漏對象,為了避免這個泄漏,從根節(jié)點config到data這條引用鏈路中,回收任意一個對象都能使array被回收,但是如果要回收config,就需要把引用它的player、bound_this、context等都回收才行,一般來說這樣距離根節(jié)點較近的對象是很難回收的,通常也不是造成泄漏的原因,而這里最終泄漏的原因是,array是緩存的數(shù)據(jù),把緩存放進去之后沒有及時清理。(引用鏈中只能看出這個數(shù)組被誰引用,看不出是哪里添加的數(shù)據(jù),需要根據(jù)業(yè)務邏輯和代碼進行分析)

1.3.3 定位引用所在代碼
在對象視圖中的代碼跳轉鏈接,指出了對象被引用的地方,但并不是說就是這一行代碼導致的內存泄漏。例如下圖中泄漏的對象是taskCallback,通過代碼跳轉,指出的代碼是a.taskCallback(),說明taskCallback因為a對象的引用而無法被回收。這里經(jīng)過代碼分析得出的結論是:this.intervalTimer沒有及時銷毀,繼而存在引用intervalTimer->a->taskCallback,而導致taskCallback函數(shù)及其引用的對象泄漏。


四、排查案例
1、監(jiān)聽器泄漏
- 在抖音PC客戶端中,通過自動化測試發(fā)現(xiàn),刷視頻會出現(xiàn)持續(xù)的內存上漲,所以針對刷視頻這個場景進行內存泄漏排查。在刷視頻之前和刷視頻之后,分別抓取內存快照,選擇“比較”對這兩個快照進行比較,搜索detached元素,選中一個div查看其引用鏈

- 引用鏈表示從引用該div的對象出發(fā),一直到根節(jié)點的整個鏈路。
- 通過觀察多個div發(fā)現(xiàn),大部分都存在相同的引用鏈,即和上圖類似。detached元素本身就很可能是泄漏的對象,加上很多detached元素都有相同的引用鏈,所以這個引用鏈很可能存在內存泄漏。通過分析這些引用的名字,可以推測使用了監(jiān)聽器,JS代碼中比較容易出現(xiàn)泄漏的情況有監(jiān)聽器、定時器,這里懷疑是showDisturbLoginPanel這個對象,它被_events引用,推測這是一個事件,通過鼠標懸停到_events上查看內存,showDisturbLoginPanel是一個數(shù)組,里面存放的是函數(shù):


- 展開函數(shù),可以看到函數(shù)的代碼位置。查看了好幾個函數(shù),發(fā)現(xiàn)這些函數(shù)的位置都是相同的,點開函數(shù):

- 可以看到是監(jiān)聽事件,由此初步推測:每次刷視頻,都會監(jiān)聽事件,但是沒有及時銷毀。
- 有了初步推測之后,接下來再復現(xiàn)一次內存泄漏,然后驗證結果是否符合推測:

- 可以看到,再刷一次視頻后,數(shù)組長度 +3,到這里基本可以確定:showDisturbLoginPanel中引用的函數(shù)沒有釋放而引起泄漏,即事件沒有及時銷毀。
五、參考文檔
1、工具使用
- 前端性能監(jiān)控實踐(二)chrome devtools:
https://juejin.cn/post/6844904094033788941#heading-2 - Chrome DevTools 之 Profiles,深度性能優(yōu)化必備:
https://www.jianshu.com/p/504bde348956
2、排查方法
- JS內存泄漏排查方法-Chrome Profiles
http://caibaojian.com/chrome-profiles.html - 前端內存泄漏處理 - 掘金
https://juejin.cn/post/7005110828593020965 - 使用chrome的devtools查找內存溢出問題 - 掘金
https://juejin.cn/post/7054545859065118756 - JS內存泄漏排查方法
http://www.ayqy.net/blog/js%E5%86%85%E5%AD%98%E6%B3%84%E6%BC%8F%E6%8E%92%E6%9F%A5%E6%96%B9%E6%B3%95/






























