如何處理 JavaScript 記憶體洩露

發表於2017-10-05

幾周前,我們開始寫一個系列,深入探討JavaScript和它的工作原理。我們認為了解JavaScript的構成以及它們如何協作,有助於編寫出更好的程式碼和應用程式。

本系列第一篇重點介紹了引擎、執行時、呼叫棧。第二篇揭示了谷歌V8 JavaScript引擎的內部機制,並且提供了一些關於如何寫出更好的JavaScript程式碼的建議。

本文作為第三篇,將會討論另一個開發者容易忽視的重要主題 :記憶體管理。我們也會提供一些關於如何處理JavaScript記憶體洩露的技巧。在SessionStack,我們需要確保不會造成記憶體洩露或者不會增加我們整合的Web應用的記憶體消耗。

概述

某些語言,比如C有低階的原生記憶體管理原語,像malloc()free()。開發人員使用這些原語可以顯式分配和釋放作業系統的記憶體。

相對地,JavaScript會在建立變數(物件、字串)時自動分配記憶體,並在這些變數不被使用時自動釋放記憶體,這個過程被稱為垃圾回收。這個“自動”釋放資源的特性帶來了很多困惑,讓JavaScript(和其他高階級語言)開發者誤以為可以不關心記憶體管理。這是一個很大的錯誤

即使使用高階級語言,開發者也應該對於記憶體管理有一定的理解(至少有基本的理解)。有時自動記憶體管理存在一些問題(例如垃圾回收實現可能存在缺陷或者不足),開發者必須弄明白這些問題,以便找一個合適解決方法。

記憶體生命週期

無論你用哪一種程式語言,記憶體生命週期幾乎總是一樣的:

Here is an overview of what happens at each step of the cycle: 這是對生命週期中的每一步大概的說明:

  • 分配記憶體— 記憶體是被作業系統分配,這允許程式使用它。在低階語言中(例如C),這是一個作為開發者需要處理的顯式操作。在高階語言中,然而,這些操作都代替開發者進行了處理。
  • 使用記憶體。實際使用之前分配的記憶體,通過在程式碼操作變數對內在進行讀和寫。
  • 釋放記憶體 。不用的時候,就可以釋放記憶體,以便重新分配。與分配記憶體操作一樣,釋放記憶體在低階語言中也需要顯式操作。

想要快速的瞭解堆疊和記憶體的概念,可以閱讀本系列第一篇文章。

什麼是記憶體

在直接探討Javascript中的記憶體之前,我們先簡要的討論一下什麼是記憶體、記憶體大概是怎麼樣工作的。

在硬體中,電腦的記憶體包含了大量的觸發電路,每一個觸發電路都包含一些能夠儲存1位資料的電晶體。觸發器通過唯一識別符號來定址,從而可以讀取和覆蓋它們。因此,從概念上來講,可以認為電腦記憶體是一個巨大的可讀寫陣列。

人類不善於把我們所有的思想和算術用位運算來表示,我們把這些小東西組織成一個大傢伙,這些大傢伙可以用來表現數字:8位是一個位元組。位元組之上是字(16位、32位)。

許多東西被儲存在記憶體中:

  1. 所有的變數和程式中用到的資料;
  2. 程式的程式碼,包括作業系統的程式碼。

編譯器和作業系統共同工作幫助開發者完成大部分的記憶體管理,但是我們推薦你瞭解一下底層到底發生了什麼。

編譯程式碼的時候,編譯器會解析原始資料型別,提前計算出它們需要多大的記憶體空間。然後將所需的數量分配在棧空間中。之所以稱為棧空間,是因在函式被呼叫的時候,他們的記憶體被新增在現有記憶體之上(就是會在棧的最上面新增一個棧幀來指向儲存函式內部變數的空間)。終止的時候,以LIFO(後進先出)的順序移除這些呼叫。例如:

編譯器馬上知道需要記憶體 4 + 4 × 4 + 8 = 28位元組。

這是當前整型和雙精度的大小。大約20年以前,整型通常只需要2個位元組,雙精度需要4個位元組,你的程式碼不受基礎資料型別大小的限制。

編譯器會插入與作業系統互動的程式碼,來請求棧中必要大小的位元組來儲存變數。

在上面的例子中,編輯器知道每個變數準確的地址。事實上,無論什麼時候我們寫變數n,將會在內部被翻譯成類似“memory address 4127963”的語句。

注意,如果我們嘗試訪問x[4]的記憶體(開始宣告的x[4]是長度為4的陣列,x[4]表示第五個元素),我們會訪問m的資料。那是因為我們正在訪問一個陣列裡不存在的元素,m比陣列中實際分配記憶體的最後一個元素x[3]要遠4個位元組,可能最後的結果是讀取(或者覆蓋)了m的一些位。這肯定會對其他程式產生不希望產生的結果。

當函式呼叫其他函式的時候,每一個函式被呼叫的時候都會獲得自己的棧塊。在自己的棧塊裡會儲存函式內所有的變數,還有一個程式計數器會記錄變數執行時所在的位置。當函式執行完之後,會釋放它的記憶體以作他用。

動態分配

不幸的是,事情並不是那麼簡單,因為在編譯的時候我們並不知道一個變數將會需要多少記憶體。假設我們做了下面這樣的事:

編譯器不知道這個陣列需要多少記憶體,因為陣列大小取決於使用者提供的值。

因此,此時不能在棧上分配空間。程式必須在執行時向作業系統請求夠用的空間。此時記憶體從堆空間中被分配。靜態與動態分配記憶體之間的不同在下面的表格中被總結出來:

靜態分配記憶體與動態分配記憶體的區別。

為了完全理解動態記憶體是如何分配的,我們需要花更多的時間在指標上,這個可能很大程度上偏離了這篇文章的主題。如果你有興趣學習更多的知識,那就在評論中讓我知道,我就可以在之後的文章中寫更多關於指標的細節。

JavaScript中的記憶體分配

現在我們來解釋JavaScript中的第一步(分配記憶體)是如何工作的。

JavaScript在開發者宣告值的時候自動分配記憶體。

在JavaScript中使用記憶體

在JavaScript中使用被分配的記憶體,本質上就是對內在的讀和寫。

比如,讀、寫變數的值或者物件的屬性,抑或向一個函式傳遞引數。

記憶體不在被需要時釋放記憶體

大部分的記憶體管理問題都在這個階段出現。

這裡最難的任務是找出這些被分配的記憶體什麼時候不再被需要。這常常要求開發者去決定程式中的一段記憶體不在被需要而且釋放它。

高階語言嵌入了一個叫垃圾回收的軟體,它的工作是跟蹤記憶體的分配和使用,以便於發現一些記憶體在一些情況下不再被需要,它將會自動地釋放這些記憶體。

不幸的是,這個過程是一個近似的過程,因為一般關於知道記憶體是否是被需要的問題是不可判斷的(不能用一個演算法解決)。

大部分的垃圾回收器會收集不再被訪問的記憶體,例如指向它的所有變數都在作用域之外。然而,這是一組可以收集的記憶體空間的近似值。因為在任何時候,一個記憶體地址可能還有一個在作用域裡的變數指向它,但是它將不會被再次訪問。

垃圾收集

由於找到一些記憶體是否是“不再被需要的”這個事實是不可判定的,垃圾回收的實現存在侷限性。本節解釋必要的概念去理解主要的垃圾回收演算法和它們的侷限性。

記憶體引用

垃圾回收演算法依賴的主要概念是引用。

在記憶體管理的語境下,一個物件只要顯式或隱式訪問另一個物件,就可以說它引用了另一個物件。例如,JavaScript物件引用其Prototype(隱式引用),或者引用prototype物件的屬性值(顯式引用)。

在這種情況下,“物件”的概念擴充套件到比普通JavaScript物件更廣的範圍,並且還包含函式作用域。(或者global詞法作用域

詞法作用域定義變數的名字在巢狀的函式中如何被解析:內部的函式包含了父級函式的作用域,即使父級函式已經返回。

引用計數垃圾回收

這是最簡單的垃圾回收演算法。 一個物件在沒有其他的引用指向它的時候就被認為“可被回收的”。

看一下下面的程式碼:

迴圈引用創造麻煩

在涉及迴圈引用的時候有一個限制。在下面的例子中,兩個物件被建立了,而且相互引用,這樣建立了一個迴圈引用。它們會在函式呼叫後超出作用域,應該可以釋放。然而引用計數演算法考慮到2個物件中的每一個至少被引用了一次,因此都不可以被回收。

標記清除演算法

為了決定一個物件是否被需要,這個演算法用於確定是否可以找到某個物件。

這個演算法包含以下步驟。

  1. 垃圾回收器生成一個根列表。根通常是將引用儲存在程式碼中的全域性變數。在JavaScript中,window物件是一個可以作為根的全域性變數。
  2. 所有的根都被檢查和標記成活躍的(不是垃圾),所有的子變數也被遞迴檢查。所有可能從根元素到達的都不被認為是垃圾。
  3. 所有沒有被標記成活躍的記憶體都被認為是垃圾。垃圾回收器就可以釋放記憶體並且把記憶體還給作業系統。

上圖就是標記清除示意。

這個演算法就比之前的(引用計算)要好些,因為“一個物件沒有被引用”導致這個物件不能被訪問。相反,正如我們在迴圈引用的示例中看到的,物件不能被訪問到,不一定不存在引用。

2012年起,所有瀏覽器都內建了標記清除垃圾回收器。在過去幾年中,JavaScript垃圾回收領域中的所有改進(代/增量/並行/並行垃圾收集)都是由這個演算法(標記清除法)改進實現的,但並不是對垃圾收集演算法本身的改進,也沒有改變它確定物件是否可達這個目標。

推薦一篇文章,其中有關於跟蹤垃圾回收的細節,包括了標記清除法和它的優化演算法。

迴圈引用不再是問題

在上面的例子中(迴圈引用的那個),在函式執行完之後,這個2個物件沒有被任何可以到達的全域性物件所引用。因此,他們將會被垃圾回收器發現為不可到達的。

儘管在這兩個物件之間有相互引用,但是他們不能從全域性物件上到達。

垃圾回收器的反常行為

儘管垃圾回收器很方便,但是他們有一套自己的方案。其中之一就是不確定性。換句話說,GC是不可預測的。你不可能知道一個回收器什麼時候會被執行。這意味著程式在某些情況下會使用比實際需求還要多的記憶體。在其他情況下,在特別敏感的應用程式中,可能會出現短停頓。儘管不確定意味著不能確定回收工作何時執行,但大多數GC實現都會在分配記憶體的期間啟動收集例程。如果沒有記憶體分配,大部分垃圾回收就保持空閒。參考下面的情況。

  1. 執行相當大的一組分配。
  2. 這些元素中的大部分(或者所有的)都被標記為不可到達的(假設我們清空了一個指向我們不再需要的快取的引用。)
  3. 沒有更多的分配被執行。

在這種情況下,大多數垃圾回收實現都不會做進一步的回收。換句話說,儘管這裡有不可達的引用變數可供回收,回收器也不會管。嚴格講,這不是洩露,但結果卻會佔用比通常情況下更多的記憶體。

什麼是記憶體洩漏

記憶體洩漏基本上就是不再被應用需要的記憶體,由於某種原因,沒有被歸還給作業系統或者進入可用記憶體池。

程式語言喜歡不同的管理記憶體方式。然而,一段確定的記憶體是否被使用是一個不可判斷的問題。換句話說,只有開發者才能弄清楚,是否一段記憶體可以被還給作業系統。

某些程式語言為開發者提供了釋放記憶體功能。另一些則期待開發者清楚的知道一段記憶體什麼時候是沒用的。Wikipedia有一篇非常好的關於記憶體管理的文章。

4種常見的JavaScript記憶體洩漏

1:全域性變數

JavaScript用一個有趣的方式管理未被宣告的變數:對未宣告的變數的引用在全域性物件裡建立一個新的變數。在瀏覽器的情況下,這個全域性物件是window。換句話說:

等同於

如果bar被假定只在foo函式的作用域裡引用變數,但是你忘記了使用var去宣告它,一個意外的全域性變數就被宣告瞭。

在這個例子裡,洩漏一個簡單的字串不會造成很大的傷害,但是它確實有可能變得更糟。

另外一個意外建立全域性變數的方法是通過this:

為了防止這些問題發生,可以在你的JaveScript檔案開頭使用'use strict';。這個可以使用一種嚴格的模式解析JavaScript來阻止意外的全域性變數。

除了意外建立的全域性變數,明確建立的全域性變數同樣也很多。這些當然屬於不能被回收的(除非被指定為null或者重新分配)。特別那些用於暫時儲存資料的全域性變數,是非常重要的。如果你必須要使用全域性變數來儲存大量資料,確保在是使用完成之後為其賦值null或者重新賦其他值。

2: 被遺忘的定時器或者回撥

在JavaScript中使用setInterval是十分常見的。

大多數庫,特別是提供觀察器或其他接收回撥的實用函式的,都會在自己的例項無法訪問前把這些回撥也設定為無法訪問。但涉及setInterval時,下面這樣的程式碼十分常見:

定時器可能會導致對不需要的節點或者資料的引用。

renderer物件在將來有可能被移除,讓interval處理器內部的整個塊都變得沒有用。但由於interval仍然起作用,處理程式並不能被回收(除非interval停止)。如果interval不能被回收,它的依賴也不可能被回收。這就意味著serverData,大概儲存了大量的資料,也不可能被回收。

在觀察者的情況下,在他們不再被需要(或相關物件需要設定成不能到達)的時候明確的呼叫移除是非常重要的。

在過去,這一點尤其重要,因為某些瀏覽器(舊的IE6)不能很好的管理迴圈引用(更多資訊見下文)。如今,大部分的瀏覽器都能而且會在物件變得不可到達的時候回收觀察處理器,即使監聽器沒有被明確的移除掉。然而,在物件被處理之前,要顯式地刪除這些觀察者仍然是值得提倡的做法。例如:

如今的瀏覽器(包括IE和Edge)使用現代的垃圾回收演算法,可以立即發現並處理這些迴圈引用。換句話說,先呼叫removeEventListener再刪節點並非嚴格必要。

jQuery等框架和外掛會在丟棄節點前刪除監聽器。這都是它們內部處理,以保證不會產生記憶體洩漏,甚至是在有問題的瀏覽器(沒錯,IE6)上也不會。

3: 閉包

閉包是JavaScript開發的一個關鍵方面:一個內部函式使用了外部(封閉)函式的變數。由於JavaScript執行時實現的不同,它可能以下面的方式造成記憶體洩漏:

這段程式碼做了一件事:每次ReplaceThing被呼叫,theThing獲得一個包含大陣列和新的閉包(someMethod)的物件。同時,變數unused保持了一個引用originalThing(theThing是上次呼叫replaceThing生成的值)的閉包。已經有點困惑了吧?最重要的事情是一旦為同一父域中的作用域產生閉包,則該作用域是共享的。

這裡,作用域產生了閉包,someMethodunused共享這個閉包中的記憶體。unused引用了originalThing。儘管unused不會被使用,someMethod可以通過theThing來使用replaceThing作用域外的變數(例如某些全域性的)。而且someMethodunused有共同的閉包作用域,unusedoriginalThing的引用強制oriiginalThing保持啟用狀態(兩個閉包共享整個作用域)。這阻止了它的回收。

當這段程式碼重複執行,可以觀察到被使用的記憶體在持續增加。垃圾回收執行的時候也不會變小。從本質上來說,閉包的連線列表已經建立了(以theThing變數為根),這些閉包每個作用域都間接引用了大陣列,導致大量的記憶體洩漏。

這個問題被Meteor團隊發現,他們有一篇非常好的文章描述了閉包大量的細節。

4: DOM外引用

有的時候在資料結構裡儲存DOM節點是非常有用的,比如你想要快速更新一個表格幾行的內容。此時儲存每一行的DOM節點的引用在一個字典或者陣列裡是有意義的。此時一個DOM節點有兩個引用:一個在dom樹中,另外一個在字典中。如果在未來的某個時候你想要去移除這些排,你需要確保兩個引用都不可到達。

當涉及DOM樹內部或子節點時,需要考慮額外的考慮因素。例如,你在JavaScript中保持對某個表的特定單元格的引用。有一天你決定從DOM中移除表格但是保留了對單元格的引用。人們也許會認為除了單元格其他的都會被回收。實際並不是這樣的:單元格是表格的一個子節點,子節點保持了對父節點的引用。確切的說,JS程式碼中對單元格的引用造成了整個表格被留在記憶體中了,所以在移除有被引用的節點時候要當心。

我們在SessionStack努力遵循這些最佳實踐,因為:

一旦你整合essionStack到你的生產應用中,它就開始記錄所有的事情:DOM變化、使用者互動、JS異常、堆疊跟蹤、失敗的網路請求、除錯資訊,等等。

通過SessionStack,你可以回放應用中的問題,看到問題對使用者的影響。所有這些都不會對你的應用產生效能的影響。因為使用者可以重新載入頁面或者在應用中跳轉,所有的觀察者、攔截器、變數分配都必須合理處置。以免造成記憶體洩漏,也預防增加整個應用的記憶體佔用。

這是一個免費的計劃,你現在可以嘗試一下。

參考資料

相關文章