完全掌握JavaScript記憶體漏失(圖文詳解)

2022-01-28 19:00:14
本篇文章給大家帶來了關於JavaScript中記憶體洩露的相關知識,其中包括記憶體洩露是什麼,那些情況會引起記憶體洩露等相關問題,希望對大家有幫助。

js 記憶體漏失

什麼是記憶體漏失?

程式的執行需要記憶體。只要程式提出要求,作業系統或者執行時(runtime)就必須供給記憶體。

對於持續執行的服務程序(daemon),必須及時釋放不再用到的記憶體。否則,記憶體佔用越來越高,輕則影響系統效能,重則導致程序崩潰。
在這裡插入圖片描述
不再用到的記憶體,沒有及時釋放,就叫做記憶體漏失(memory leak)。

有些語言(比如 C 語言)必須手動釋放記憶體,程式設計師負責記憶體管理。

char * buffer;buffer = (char*) malloc(42);// Do something with bufferfree(buffer);

上面是 C 語言程式碼,malloc方法用來申請記憶體,使用完畢之後,必須自己用free方法釋放記憶體。

這很麻煩,所以大多數語言提供自動記憶體管理,減輕程式設計師的負擔,這被稱為"垃圾回收機制"(garbage collector)。

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

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

1. 意外的全域性變數

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

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

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

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

如果此時再次開啟同個頁面,記憶體中其實是有雙份頁面資料的,如果多次關閉、開啟,那麼記憶體漏失會越來越嚴重。而且這種場景很容易出現,因為使用定時器的人很容易遺忘清除。

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

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

4. 遺漏的 DOM 元素
DOM 元素的生命週期正常是取決於是否掛載在 DOM 樹上,當從 DOM 樹上移除時,也就可以被銷燬回收了
但如果某個 DOM 元素,在 js 中也持有它的參照時,那麼它的生命週期就由 js 和是否在 DOM 樹上兩者決定了,記得移除時,兩個地方都需要去清理才能正常回收它。

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


如何監控記憶體漏失

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

不管哪一種,利用開發者工具抓到的記憶體圖,應該都會看到一段時間內,記憶體佔用不斷的直線式下降,這是因為不斷髮生 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 就是記憶體漏失的元凶,如果瀏覽器層面就能確定了,那它幹嘛不回收它,幹嘛還會造成記憶體漏失

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

範例分析

例子1:

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 函數呼叫後,內部建立的物件都無法被回收呢?

因為 replaceThing 的第一次建立,這個物件被全域性變數 t 持有,所以回收不了。

後面的每一次呼叫,這個物件都被上一個 replaceThing 函數內部的 o 區域性變數持有而回收不了。

而這個函數內的區域性變數 o 在 replaceThing 首次呼叫時被建立的物件的 someMethod 方法持有,該方法掛載的物件被全域性變數 t 持有,所以也回收不了。

這樣層層持有,每一次函數的呼叫,都會持有函數上次呼叫時內部建立的區域性變數,導致函數即使執行結束,這些區域性變數也無法回收。

相關推薦:

以上就是完全掌握JavaScript記憶體漏失(圖文詳解)的詳細內容,更多請關注TW511.COM其它相關文章!