JavaScript 中的記憶體洩漏以及如何處理

發表於2017-11-21

隨著現在的程式語言功能越來越成熟、複雜,記憶體管理也容易被大家忽略。本文將會討論JavaScript中的記憶體洩漏以及如何處理,方便大家在使用JavaScript編碼時,更好的應對記憶體洩漏帶來的問題。

概述

像C語言這樣的程式語言,具有簡單的記憶體管理功能函式,例如malloc( )和free( )。開發人員可以使用這些功能函式來顯式地分配和釋放系統的記憶體。

當建立物件和字串等時,JavaScript就會分配記憶體,並在不再使用時自動釋放記憶體,這種機制被稱為垃圾收集。這種釋放資源看似是“自動”的,但本質是混淆的,這也給JavaScript(以及其他高階語言)的開發人員產生了可以不關心記憶體管理的錯誤印象。其實這是一個大錯誤。

即使使用高階語言,開發人員也應該理解記憶體管理的知識。有時自動記憶體管理也會存在問題(例如垃圾收集器中的錯誤或實施限制等),開發人員必須瞭解這些問題才能正確地進行處理。

記憶體生命週期

無論你使用的是什麼程式語言,記憶體生命週期幾乎都是一樣的:139239-20171120114302040-1222119677

 

以下是對記憶體生命週期中每個步驟發生的情況的概述:

  • 分配記憶體 – 記憶體由作業系統分配,允許程式使用它。在簡單的程式語言中,這個過程是開發人員應該處理的一個顯式操作。然而,在高階程式語言中,系統會幫助你完成這個操作。
  • 記憶體使用 這是程式使用之前申請記憶體的時間段,你的程式碼會通過使用分配的變數

來對記憶體進行讀取和寫入操作。

  • 釋放記憶體  - 對於不再需要的記憶體進行釋放的操作,以便確保其變成空閒狀態並且可以被再次使用。與分配記憶體操作一樣,這個操作在簡單的程式語言中是需要顯示操作的。

什麼是記憶體?

在硬體層面上,計算機的記憶體由大量的觸發器組成的。每個觸發器包含一些電晶體,並能夠儲存一位資料。單獨的觸發器可以通過唯一的識別符號來定址,所以我們可以讀取和覆蓋它們。因此,從概念上講,我們可以把整個計算機記憶體看作是我們可以讀寫的一大塊空間。

很多東西都儲存在記憶體中:

  1. 程式使用的所有變數和其他資料。
  2. 程式的程式碼,包括作業系統的程式碼。

編譯器和作業系統一起工作,來處理大部分的記憶體管理,但是我們需要了解從本質上發生了什麼。

編譯程式碼時,編譯器會檢查原始資料型別,並提前計算它們需要多少記憶體,然後將所需的記憶體分配給呼叫堆疊空間中的程式。分配這些變數的空間被稱為堆疊空間,隨著函式的呼叫,記憶體會被新增到現有的記憶體之上。當終止時,空間以LIFO(後進先出)順序被移除。例如如下宣告:

編譯器插入與作業系統進行互動的程式碼,以便在堆疊中請求所需的位元組數來儲存變數。

在上面的例子中,編譯器知道每個變數的確切記憶體地址。實際上,每當我們寫入這個變數n,它就會在內部翻譯成“記憶體地址4127963”。

注意,如果我們試圖訪問x[4],我們將訪問與m關聯的資料。這是因為我們正在訪問陣列中不存在的元素 – 它比陣列中最後一個資料實際分配的元素多了4個位元組x[3],並且可能最終讀取(或覆蓋)了一些m位元。這對其餘部分會產生不利的後果。

139239-20171120114559524-910325934

當函式呼叫其它函式時,每個函式被呼叫時都會得到自己的堆疊塊。它會保留所有的區域性變數和一個程式計數器,還會記錄執行的地方。當功能完成時,其記憶體塊會被釋放,可以再次用於其它目的。

動態分配

如若我們不知道編譯時,變數需要的記憶體數量時,事情就會變得複雜。假設我們想要做如下事項:

在編譯時,編譯器不知道陣列需要多少記憶體,因為它是由使用者提供的輸入值決定的。

因此,它不能為堆疊上的變數分配空間。相反,我們的程式需要在執行時明確地向作業系統請求適當的空間。這個記憶體是從堆空間分配的。下表總結了靜態和動態記憶體分配之間的區別:

139239-20171120114707836-1204603160

在JavaScript中分配記憶體

現在來解釋如何在JavaScript中分配記憶體。

JavaScript使得開發人員免於處理記憶體分配的工作。

一些函式呼叫也會導致物件分配:

方法可以分配新的值或物件:

在JavaScript中使用記憶體

基本上在JavaScript中使用分配的記憶體,意味著在其中讀寫。

這可以通過讀取或寫入變數或物件屬性的值,或者甚至將引數傳遞給函式來完成。

當記憶體不再需要時進行釋放

大部分記憶體洩漏問題都是在這個階段產生的,這個階段最難的問題就是確定何時不再需要已分配的記憶體。它通常需要開發人員確定程式中的哪個部分不再需要這些記憶體,並將其釋放。

高階語言嵌入了一個名為垃圾收集器的功能,其工作是跟蹤記憶體分配和使用情況,以便在不再需要分配記憶體的情況下自動釋放記憶體。

不幸的是,這個過程無法做到那麼準確,因為像某些記憶體不再需要的問題是不能由演算法來解決的。

大多數垃圾收集器通過收集不能被訪問的記憶體來工作,例如指向它的變數超出範圍的這種情況。然而,這種方式只能收集記憶體空間的近似值,因為在記憶體的某些位置可能仍然有指向它的變數,但它卻不會被再次訪問。

由於確定一些記憶體是否“不再需要”,是不可判定的,所以垃圾收集機制就有一定的侷限性。下面將解釋主要垃圾收集演算法及其侷限性的概念。

記憶體引用

垃圾收集演算法所依賴的主要概念之一就是記憶體引用。

在記憶體管理情況下,如果一個物件訪問變數(可以是隱含的或顯式的),則稱該物件引用另一個物件。例如,JavaScript物件具有對其原物件(隱式引用)及其屬性值(顯式引用)的引用。

在這種情況下,“物件”的概念擴充套件到比普通JavaScript物件更廣泛的範圍,並且還包含函式範圍。

引用計數垃圾收集

這是最簡單的垃圾收集演算法。如果有零個引用指向它,則該物件會被認為是“垃圾收集” 。

看看下面的程式碼:

週期引起問題

在週期方面有一個限制。例如下面的例子,建立兩個物件並相互引用,這樣會建立一個迴圈引用。在函式呼叫之後,它們將超出範圍,所以它們實際上是無用的,可以被釋放。然而,引用計數演算法認為,由於兩個物件中的每一個都被引用至少一次,所以兩者都不能被垃圾收集機制收回。

139239-20171120115004915-62677357

標記和掃描演算法

為了決定是否需要物件,標記和掃描演算法會確定物件是否是活動的。

標記和掃描演算法經過以下3個步驟:

  1. roots:通常,root是程式碼中引用的全域性變數。例如,在JavaScript中,可以充當root的全域性變數是“視窗”物件。Node.js中的相同物件稱為“全域性”。所有root的完整列表由垃圾收集器構建。
  2. 然後演算法會檢查所有root和他們的子物件並且標記它們是活動的(即它們不是垃圾)。任何root不能達到的,將被標記為垃圾。
  3. 最後,垃圾回收器釋放所有未標記為活動的記憶體塊,並將該記憶體返回給作業系統。

139239-20171120115052211-360802438

這個演算法比引用計數垃圾收集演算法更好。JavaScript垃圾收集(程式碼/增量/併發/並行垃圾收集)領域中所做的所有改進都是對這種標記和掃描演算法的實現改進,但不是對垃圾收集演算法本身的改進。

週期不再是問題了

在上面的相互引用例子中,在函式呼叫返回之後,兩個物件不再被全域性物件可訪問的物件引用。因此,它們將被垃圾收集器發現,從而進行收回。

139239-20171120115257649-2068824765

即使在物件之間有引用,它們也不能從root目錄中訪問,從而會被認為是垃圾而收集。

抵制垃圾收集器的直觀行為

儘管垃圾收集器使用起來很方便,但它們也有自己的一套標準,其中之一是非決定論。換句話說,垃圾收集是不可預測的。你不能真正知道什麼時候進行收集,這意味著在某些情況下,程式會使用更多的記憶體,雖然這是實際需要的。在其它情況下,在特別敏感的應用程式中,短暫暫停是很可能出現的。儘管非確定性意味著不能確定何時進行集合,但大多數垃圾收集實現了共享在分配期間進行收集的通用模式。如果沒有執行分配,大多數垃圾收集會保持空閒狀態。如以下情況:

  1. 大量的分配被執行。
  2. 大多數這些元素(或所有這些元素)被標記為無法訪問(假設我們將一個引用指向不再需要的快取)。
  3. 沒有進一步的分配執行。

在這種情況下,大多數垃圾收集不會做出任何的收集工作。換句話說,即使有不可用的引用需要收集,但是收集器不會進行收集。雖然這並不是嚴格的洩漏,但仍會導致記憶體使用率高於平時。

什麼是記憶體洩漏?

記憶體洩漏是應用程式使用過的記憶體片段,在不再需要時,不能返回到作業系統或可用記憶體池中的情況。

程式語言有各自不同的記憶體管理方式。但是是否使用某一段記憶體,實際上是一個不可判定的問題。換句話說,只有開發人員明確的知道是否需要將一塊記憶體返回給作業系統。

四種常見的JavaScript記憶體洩漏

1:全域性變數

JavaScript以一種有趣的方式來處理未宣告的變數:當引用未宣告的變數時,會在全域性物件中建立一個新變數。在瀏覽器中,全域性物件將是window,這意味著

相當於:

bar只是foo函式中引用一個變數。如果你不使用var宣告,將會建立一個多餘的全域性變數。在上述情況下,不會造成很大的問題。但是,如若是下面的這種情況。

你也可能不小心建立一個全域性變數this:

你可以通過在JavaScript檔案的開始處新增‘use strict’;來避免這中錯誤,這種方式將開啟嚴格的解析JavaScript模式,從而防止意外建立全域性變數。

意外的全域性變數當然是一個問題。更多的時候,你的程式碼會受到顯式的全域性變數的影響,而這些全域性變數在垃圾收集器中是無法收集的。需要特別注意用於臨時儲存和處理大量資訊的全域性變數。如果必須使用全域性變數來儲存資料,那麼確保將其分配為空值,或者在完成後重新分配。

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

下面列舉setInterval的例子,這也是經常在JavaScript中使用。

對於提供監視的庫和其它接受回撥的工具,通常在確保所有回撥的引用在其例項無法訪問時,會變成無法訪問的狀態。但是下面的程式碼卻是一個例外:

上面的程式碼片段顯示了使用引用節點或不再需要的資料的定時器的結果。

該renderer物件可能會在某些時候被替換或刪除,這會使interval處理程式封裝的塊變得冗餘。如果發生這種情況,那麼處理程式及其依賴項都不會被收集,因為interval需要先停止。這一切都歸結為儲存和處理負載資料的serverData不會被收集的原因。

當使用監視器時,你需要確保做了一個明確的呼叫來刪除它們。

幸運的是,大多數現代瀏覽器都會為你做這件事:即使你忘記刪除監聽器,當被監測物件變得無法訪問,它們就會自動收集監測處理器。這是過去的一些瀏覽器無法處理的情況(例如舊的IE6)。

看下面的例子:

由於現代瀏覽器支援垃圾回收機制,所以當某個節點變的不能訪問時,你不再需要呼叫removeEventListener,因為垃圾回收機制會恰當的處理這些節點。

如果你正在使用jQueryAPI(其他庫和框架也支援這一點),那麼也可以在節點不用之前刪除監聽器。即使應用程式在較舊的瀏覽器版本下執行,庫也會確保沒有記憶體洩漏。

3:閉包

JavaScript開發的一個關鍵方面是閉包。閉包是一個內部函式,可以訪問外部(封閉)函式的變數。由於JavaScript執行時的實現細節,可能存在以下形式洩漏記憶體:

一旦replaceThing被呼叫,theThing會獲取由一個大陣列和一個新的閉包(someMethod)組成的新物件。然而,originalThing會被unused變數所持有的閉包所引用(這是theThing從以前的呼叫變數replaceThing)。需要記住的是,一旦在同一父作用域中為閉包建立了閉包的作用域,作用域就被共享了。

在這種情況下,閉包建立的範圍會將someMethod共享給unused。然而,unused有一個originalThing引用。即使unused從未使用過,someMethod 也可以通過theThing在整個範圍之外使用replaceThing。而且someMethod通過unused共享了閉包範圍,unused必須引用originalThing以便使其它保持活躍(兩封閉之間的整個共享範圍)。這就阻止了它被收集。

所有這些都可能導致相當大的記憶體洩漏。當上面的程式碼片段一遍又一遍地執行時,你會看到記憶體使用率的不斷上升。當垃圾收集器執行時,其記憶體大小不會縮小。這種情況會建立一個閉包的連結串列,並且每個閉包範圍都帶有對大陣列的間接引用。

4:超出DOM引用

在某些情況下,開發人員會在資料結構中儲存DOM節點,例如你想快速更新表格中的幾行內容的情況。如果在字典或陣列中儲存對每個DOM行的引用,則會有兩個對同一個DOM元素的引用:一個在DOM樹中,另一個在字典中。如果你不再需要這些行,則需要使兩個引用都無法訪問。

在涉及DOM樹內的內部節點或葉節點時,還有一個額外的因素需要考慮。如果你在程式碼中保留對錶格單元格(標籤)的引用,並決定從DOM中刪除該表格,還需要保留對該特定單元格的引用,則可能會出現嚴重的記憶體洩漏。你可能會認為垃圾收集器會釋放除了那個單元之外的所有東西,但情況並非如此。由於單元格是表格的一個子節點,並且子節點保留著對父節點的引用,所以對錶格單元格的這種引用,會將整個表格儲存在記憶體中。

總結

以上內容是對JavaScript記憶體管理機制的講解,以及常見的四種記憶體洩漏的分析。希望對JavaScript的程式設計人員有所幫助。

相關文章