瀏覽器的記憶體泄漏場景、監控以及分析

  • 2020 年 2 月 13 日
  • 筆記

記憶體泄漏

Q:什麼是記憶體泄漏?

字面上的意思,申請的記憶體沒有及時回收掉,被泄漏了

Q:為什麼會發生記憶體泄漏?

雖然前端有垃圾回收機制,但當某塊無用的記憶體,卻無法被垃圾回收機制認為是垃圾時,也就發生記憶體泄漏了

而垃圾回收機制通常是使用標誌清除策略,簡單說,也就是引用從根節點開始是否可達來判定是否是垃圾

上面是發生記憶體泄漏的根本原因,直接原因則是,當不同生命周期的兩個東西相互通訊時,一方生命到期該回收了,卻被另一方還持有時,也就發生記憶體泄漏了

所以,下面就來講講,哪些場景會造成記憶體泄漏

哪些情況會引起記憶體泄漏

1. 意外的全局變數

全局變數的生命周期最長,直到頁面關閉前,它都存活著,所以全局變數上的記憶體一直都不會被回收

當全局變數使用不當,沒有及時回收(手動賦值 null),或者拼寫錯誤等將某個變數掛載到全局變數時,也就發生記憶體泄漏了

2. 遺忘的定時器

setTimeout 和 setInterval 是由瀏覽器專門執行緒來維護它的生命周期,所以當在某個頁面使用了定時器,當該頁面銷毀時,沒有手動去釋放清理這些定時器的話,那麼這些定時器還是存活著的

也就是說,定時器的生命周期並不掛靠在頁面上,所以當在當前頁面的 js 里通過定時器註冊了某個回調函數,而該回調函數內又持有當前頁面某個變數或某些 DOM 元素時,就會導致即使頁面銷毀了,由於定時器持有該頁面部分引用而造成頁面無法正常被回收,從而導致記憶體泄漏了

如果此時再次打開同個頁面,記憶體中其實是有雙份頁面數據的,如果多次關閉、打開,那麼記憶體泄漏會越來越嚴重

而且這種場景很容易出現,因為使用定時器的人很容易遺忘清除

3. 使用不當的閉包

函數本身會持有它定義時所在的詞法環境的引用,但通常情況下,使用完函數後,該函數所申請的記憶體都會被回收了

但當函數內再返回一個函數時,由於返回的函數持有外部函數的詞法環境,而返回的函數又被其他生命周期東西所持有,導致外部函數雖然執行完了,但記憶體卻無法被回收

所以,返回的函數,它的生命周期應盡量不宜過長,方便該閉包能夠及時被回收

正常來說,閉包並不是記憶體泄漏,因為這種持有外部函數詞法環境本就是閉包的特性,就是為了讓這塊記憶體不被回收,因為可能在未來還需要用到,但這無疑會造成記憶體的消耗,所以,不宜爛用就是了

4. 遺漏的 DOM 元素

DOM 元素的生命周期正常是取決於是否掛載在 DOM 樹上,當從 DOM 樹上移除時,也就可以被銷毀回收了

但如果某個 DOM 元素,在 js 中也持有它的引用時,那麼它的生命周期就由 js 和是否在 DOM 樹上兩者決定了,記得移除時,兩個地方都需要去清理才能正常回收它

5. 網路回調

某些場景中,在某個頁面發起網路請求,並註冊一個回調,且回調函數內持有該頁面某些內容,那麼,當該頁面銷毀時,應該註銷網路的回調,否則,因為網路持有頁面部分內容,也會導致頁面部分內容無法被回收

如何監控記憶體泄漏

記憶體泄漏是可以分成兩類的,一種是比較嚴重的,泄漏的就一直回收不回來了,另一種嚴重程度稍微輕點,就是沒有及時清理導致的記憶體泄漏,一段時間後還是可以被清理掉

不管哪一種,利用開發者工具抓到的記憶體圖,應該都會看到一段時間內,記憶體佔用不斷的直線式下降,這是因為不斷發生 GC,也就是垃圾回收導致的

針對第一種比較嚴重的,會發現,記憶體圖裡即使不斷發生 GC 後,所使用的記憶體總量仍舊在不斷增長

另外,記憶體不足會造成不斷 GC,而 GC 時是會阻塞主執行緒的,所以會影響到頁面性能,造成卡頓,所以記憶體泄漏問題還是需要關注的

我們假設這麼一種場景,然後來用開發者工具查看下記憶體泄漏:

場景一:在某個函數內申請一塊記憶體,然後該函數在短時間內不斷被調用

// 點擊按鈕,就執行一次函數,申請一塊記憶體  startBtn.addEventListener("click", function() {      var a = new Array(100000).fill(1);      var b = new Array(20000).fill(1);  });

一個頁面能夠使用的記憶體是有限的,當記憶體不足時,就會觸發垃圾回收機制去回收沒用的記憶體

而在函數內部使用的變數都是局部變數,函數執行完畢,這塊記憶體就沒用可以被回收了

所以當我們短時間內不斷調用該函數時,可以發現,函數執行時,發現記憶體不足,垃圾回收機制工作,回收上一個函數申請的記憶體,因為上個函數已經執行結束了,記憶體無用可被回收了

所以圖中呈現記憶體使用量的圖表就是一條橫線過去,中間出現多處豎線,其實就是表示記憶體清空,再申請,清空再申請,每個豎線的位置就是垃圾回收機制工作以及函數執行又申請的時機

場景二:在某個函數內申請一塊記憶體,然後該函數在短時間內不斷被調用,但每次申請的記憶體,有一部分被外部持有

// 點擊按鈕,就執行一次函數,申請一塊記憶體  var arr = [];  startBtn.addEventListener("click", function() {      var a = new Array(100000).fill(1);      var b = new Array(20000).fill(1);      arr.push(b);  });

看一下跟第一張圖片有什麼區別?

不再是一條橫線了吧,而且橫線中的每個豎線的底部也不是同一水平了吧

其實這就是記憶體泄漏了

我們在函數內申請了兩個數組記憶體,但其中有個數組卻被外部持有,那麼,即使每次函數執行完,這部分被外部持有的數組記憶體也依舊回收不了,所以每次只能回收一部分記憶體

這樣一來,當函數調用次數增多時,沒法回收的記憶體就越多,記憶體泄漏的也就越多,導致記憶體使用量一直在增長

另外,也可以使用 performance monitor 工具,在開發者工具里找到更多的按鈕,在裡面打開此功能面板,這是一個可以實時監控 cpu,記憶體等使用情況的工具,會比上面只能抓取一段時間內工具更直觀一點:

梯狀上升的就是發生記憶體泄漏了,每次函數調用,總有一部分數據被外部持有導致無法回收,而後面平滑狀的則是每次使用完都可以正常被回收

這張圖需要注意下,第一個紅框末尾有個直線式下滑,這是因為,我修改了程式碼,把外部持有函數內申請的數組那行程式碼去掉,然後刷新頁面,手動點擊 GC 才觸發的效果,否則,無論你怎麼點 GC,有部分記憶體一直無法回收,是達不到這樣的效果圖的

以上,是監控是否發生記憶體泄漏的一些工具,但下一步才是關鍵,既然發現記憶體泄漏,那該如何定位呢?如何知道,是哪部分數據沒被回收導致的泄漏呢?

如何分析記憶體泄漏,找出有問題的程式碼

分析記憶體泄漏的原因,還是需要藉助開發者工具的 Memory 功能,這個功能可以抓取記憶體快照,也可以抓取一段時間內,記憶體分配的情況,還可以抓取一段時間內觸發記憶體分配的各函數情況

利用這些工具,我們可以分析出,某個時刻是由於哪個函數操作導致了記憶體分配,分析出大量重複且沒有被回收的對象是什麼

這樣一來,有嫌疑的函數也知道了,有嫌疑的對象也知道了,再去程式碼中分析下,這個函數里的這個對象到底是不是就是記憶體泄漏的元兇,搞定

先舉個簡單例子,再舉個實際記憶體泄漏的例子:

場景一:在某個函數內申請一塊記憶體,然後該函數在短時間內不斷被調用,但每次申請的記憶體,有一部分被外部持有

// 每次點擊按鈕,就有一部分記憶體無法回收,因為被外部 arr 持有了  var arr = [];  startBtn.addEventListener("click", function() {      var a = new Array(100000).fill(1);      var b = new Array(20000).fill(1);      arr.push(b);  });
  • 記憶體快照

可以抓取兩份快照,兩份快照中間進行記憶體泄漏操作,最後再比對兩份快照的區別,查看增加的對象是什麼,回收的對象又是哪些,如上圖。

也可以單獨查看某個時刻快照,從記憶體佔用比例來查看佔據大量記憶體的是什麼對象,如下圖:

還可以從垃圾回收機制角度出發,查看從 GC root 根節點出發,可達的對象里,哪些對象佔用大量記憶體:

從上面這些方式入手,都可以查看到當前佔用大量記憶體的對象是什麼,一般來說,這個就是嫌疑犯了

當然,也並不一定,當有嫌疑對象時,可以利用多次記憶體快照間比對,中間手動強制 GC 下,看下該回收的對象有沒有被回收,這是一種思路

  • 抓取一段時間內,記憶體分配情況

這個方式,可以有選擇性的查看各個記憶體分配時刻是由哪個函數發起,且記憶體存儲的是什麼對象

當然,記憶體分配是正常行為,這裡查看到的還需要藉助其他數據來判斷某個對象是否是嫌疑對象,比如記憶體佔用比例,或結合記憶體快照等等

  • 抓取一段時間內函數的記憶體使用情況

這個能看到的內容很少,比較簡單,目的也很明確,就是一段時間內,都有哪些操作在申請記憶體,且用了多少

總之,這些工具並沒有辦法直接給你答覆,告訴你 xxx 就是記憶體泄漏的元兇,如果瀏覽器層面就能確定了,那它幹嘛不回收它,幹嘛還會造成記憶體泄漏

所以,這些工具,只能給你各種記憶體使用資訊,你需要自己藉助這些資訊,根據自己程式碼的邏輯,去分析,哪些嫌疑對象才是記憶體泄漏的元兇

實例分析

來個網上很多文章都出現過的記憶體泄漏例子:

var t = null;  var replaceThing = function() {    var o = t    var unused = function() {      if (o) {        console.log("hi")      }    }      t = {          longStr: new Array(100000).fill('*'),          someMethod: function() {                         console.log(1)                      }        }  }  setInterval(replaceThing, 1000)

也許你還沒看出這段程式碼是不是會發生記憶體泄漏,原因在哪,不急

先說說這程式碼用途,聲明了一個全局變數 t 和 replaceThing 函數,函數目的在於會為全局變數賦值一個新對象,然後內部有個變數存儲全局變數 t 被替換前的值,最後定時器周期性執行 replaceThing 函數

  • 發現問題

我們先利用工具看看,是不是會發生記憶體泄漏:

三種記憶體監控圖表都顯示,這發生記憶體泄漏了:反覆執行同個函數,記憶體卻梯狀式增長,手動點擊 GC 記憶體也沒有下降,說明函數每次執行都有部分記憶體泄漏了

這種手動強制垃圾回收都無法將記憶體將下去的情況是很嚴重的,長期執行下去,會耗盡可用記憶體,導致頁面卡頓甚至崩掉

  • 分析問題

既然已經確定有記憶體泄漏了,那麼接下去就該找出記憶體泄漏的原因了

首先通過 sampling profile,我們把嫌疑定位到 replaceThing 這個函數上

接著,我們抓取兩份記憶體快照,比對一下,看看能否得到什麼資訊:

比對兩份快照可以發現,這過程中,數組對象一直在增加,而且這個數組對象來自 replaceThing 函數內部創建的對象的 longStr 屬性

其實這張圖資訊很多了,尤其是下方那個嵌套圖,嵌套關係是反著來,你倒著看的話,就可以發現,從全局對象 Window 是如何一步步訪問到該數組對象的,垃圾回收機制正是因為有這樣一條可達的訪問路徑,才無法回收

其實這裡就可以分析了,為了多使用些工具,我們換個圖來分析吧

我們直接從第二份記憶體快照入手,看看:

從第一份快照到第二份快照期間,replaceThing 執行了 7 次,剛好創建了 7 份對象,看來這些對象都沒有被回收

那麼為什麼不會被回收呢?

replaceThing 函數只是在內部保存了上份對象,但函數執行結束,局部變數不應該是被回收了么

繼續看圖,可以看到底下還有個閉包佔用很大記憶體,看看:

為什麼每一次 replaceThing 函數調用後,內部創建的對象都無法被回收呢?

因為 replaceThing 的第一次創建,這個對象被全局變數 t 持有,所以回收不了

後面的每一次調用,這個對象都被上一個 replaceThing 函數內部的 o 局部變數持有而回收不了

而這個函數內的局部變數 o 在 replaceThing 首次調用時被創建的對象的 someMethod 方法持有,該方法掛載的對象被全局變數 t 持有,所以也回收不了

這樣層層持有,每一次函數的調用,都會持有函數上次調用時內部創建的局部變數,導致函數即使執行結束,這些局部變數也無法回收

口頭說有點懵,盜張圖(侵權刪),結合垃圾回收機制的標記清除法(俗稱可達法)來看,就很明了了:

盜自 https://juejin.im/post/5979b5755188253df1067397

  • 整理結論

根據利用記憶體分析工具,可以得到如下資訊:

  1. 同一個函數調用,記憶體佔用卻呈現梯狀式上升,且手動 GC 記憶體都無法下降,說明記憶體泄漏了
  2. 抓取一段時間的記憶體申請情況,可以確定嫌疑函數是 replaceThing
  3. 比對記憶體快照發現,沒有回收的是 replaceThing 內部創建的對象(包括存儲數組的 longStr 屬性和方法 someMethod)
  4. 進一步分析記憶體快照發現,之所以不回收,是因為每次函數調用創建的這個對象會被存儲在函數上一次調用時內部創建的局部變數 o 上
  5. 而局部變數 o 在函數執行結束沒被回收,是因為,它被創建的對象的 someMethod 方法所持有

以上,就是結論,但我們還得分析為什麼會出現這種情況,是吧

其實,這就涉及到閉包的知識點了:

MDN 對閉包的解釋是,函數塊以及函數定義時所在的詞法環境兩者的結合就稱為閉包

而函數定義時,本身就會有一個作用域的內部屬性存儲著當前的詞法環境,所以,一旦某個函數被比它所在的詞法環境還長的生命周期的東西所持有,此時就會造成函數持有的詞法環境無法被回收

簡單說,外部持有某個函數內定義的函數時,此時,如果內部函數有使用到外部函數的某些變數,那麼這些變數即使外部函數執行結束了,也無法被回收,因為轉而被存儲在內部函數的屬性上了

還有一個知識點,外部函數里定義的所有函數共享一個閉包,也就是 b 函數使用外部函數 a 變數,即使 c 函數沒使用,但 c 函數仍舊會存儲 a 變數,這就叫共享閉包

回到這道題

因為 replaceThing 函數里,手動將內部創建的字面量對象賦值給全局變數,而且這個對象還有個 someMethod 方法,所以 someMethod 方法就因為閉包特性存儲著 replaceThing 的變數

雖然 someMethod 內部並沒有使用到什麼局部變數,但 replaceThing 內部還有一個 unused 函數啊,這個函數就使用了局部變數 o,因為共享閉包,導致 someMethod 也存儲著 o

而 o 又存著全局變數 t 替換前的值,所以就導致了,每一次函數調用,內部變數 o 都會有人持有它,所以無法回收

想要解決這個記憶體泄漏,就是要砍斷 o 的持有者,讓局部變數 o 能夠正常被回收

所以有兩個思路:要麼讓 someMethod 不用存儲 o;要麼使用完 o 就釋放;

如果 unused 函數沒有用,那可以直接去掉這個函數,然後看看效果:

這裡之所以還會梯狀式上升是因為,當前記憶體還足夠,還沒有觸發垃圾回收機制工作,你可以手動觸發 GC,或者運行一段時間等到 GC 工作後查看一下,記憶體是否下降到初始狀態,這表明,這些記憶體都可以被回收的

或者拉份記憶體快照看看,拉快照時,會自動先強制進行 GC 再拉取快照:

是吧,即使周期性調用 replaceThing 函數,函數內的局部變數 o 即使存儲著上個全局變數 t 的值,但畢竟是局部變數,函數執行完畢,如果沒有外部持有它的引用,也就可以被回收掉了,所以最終記憶體就只剩下全局變數 t 存儲的對象了

當然,如果 unused 函數不能去掉,那麼就只能是使用完 o 變數後需要記得手動釋放掉:

var unused = function() {      if (o) {        console.log("hi")        o = null;      }  }

但這種做法,不治本,因為在 unused 函數執行前,這堆記憶體還是一直存在著的,還是一直泄漏無法被回收的,與最開始的區別就在於,至少在 unused 函數執行後,就可以釋放掉而已

其實,這裡應該考慮的程式碼有沒有問題,為什麼需要局部變數存儲,為什麼需要 unused 函數的存在,這個函數的目的又是什麼,如果只是為了在將來某個時刻用來判斷上個全局變數 t 是否可用,那麼為什麼不直接再使用個全局變數來存儲,為什麼選擇了局部變數?

所以,當寫程式碼時,當涉及到閉包的場景時,應該要特別注意,如果使用不當,很可能會造成一些嚴重的記憶體泄漏場景

應該銘記,閉包會讓函數持有外部的詞法環境,導致外部詞法環境的某些變數無法被回收,還有共享一個閉包這種特性,只有清楚這兩點,才能在涉及到閉包使用場景時,正確考慮該如何實現,避免造成嚴重的記憶體泄漏

Tags: