js 內存泄漏場景、如何監控以及分析

本文收錄於 gitbook.dasu.fun前端

Q:什麼是內存泄漏?git

字面上的意思,申請的內存沒有及時回收掉,被泄漏了數組

Q:爲何會發生內存泄漏?瀏覽器

雖然前端有垃圾回收機制,但當某塊無用的內存,卻沒法被垃圾回收機制認爲是垃圾時,也就發生內存泄漏了網絡

而垃圾回收機制一般是使用標誌清除策略,簡單說,也就是引用從根節點開始是否可達來斷定是不是垃圾閉包

上面是發生內存泄漏的根本緣由,直接緣由則是,當不一樣生命週期的兩個東西相互通訊時,一方生命到期該回收了,卻被另外一方還持有時,也就發生內存泄漏了函數

因此,下面就來說講,哪些場景會形成內存泄漏工具

哪些狀況會引發內存泄漏

1. 意外的全局變量

全局變量的生命週期最長,直到頁面關閉前,它都存活着,因此全局變量上的內存一直都不會被回收post

當全局變量使用不當,沒有及時回收(手動賦值 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 是否可用,那麼爲何不直接再使用個全局變量來存儲,爲何選擇了局部變量?

因此,當寫代碼時,當涉及到閉包的場景時,應該要特別注意,若是使用不當,極可能會形成一些嚴重的內存泄漏場景

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

相關文章
相關標籤/搜索