JavaScript 是如何工做的:內存管理 + 如何處理四種常見的內存泄漏

JavaScript 是如何工做的:內存管理 + 如何處理四種常見的內存泄漏

原文 - How JavaScript works: memory management + how to handle 4 common memory leaksjavascript

原文做者 - Alexander Zlatkovhtml

原文地址 - blog.sessionstack.com/how-javascr…java

譯者 - yanleenode

譯文地址 - yanlee26.github.io/2018/02/01/…git

知乎專欄 - zhuanlan.zhihu.com/p/33483627 掘金專欄 - juejin.im/post/5a725c…github

幾個星期前,咱們開始了一系列旨在深刻研究JavaScript及其實際工做原理的系列文章:咱們認爲經過了解JavaScript的構建塊以及它們如何一塊兒玩,您將可以編寫更好的代碼和應用程​​序。算法

本系列的第一篇文章重點介紹了引擎,運行時和調用堆棧的概述第二偏文章仔細研究谷歌的V8 JavaScript引擎的內部零件,也提供了有關如何寫出更好的JavaScript代碼的一些提示。express

在這個第三篇文章中,咱們將討論另外一個愈來愈被開發人員忽視的關鍵主題,由於平常使用的編程語言(內存管理)愈來愈成熟和複雜。咱們也將提供關於如何處理JavaScript中的內存泄漏,咱們在一些技巧SessionStack中遵循咱們須要確保SessionStack不會形成內存泄漏,或不增長的Web應用程序,咱們正在整合的內存消耗。編程

概觀

像C這樣的語言具備低級的內存管理原語,好比malloc()和free()。開發人員使用這些原語來顯式分配和釋放操做系統的內存。數組

同時,當事物(對象,字符串等)被建立時,JavaScript分配內存,並在再也不使用時自動釋放內存,稱爲垃圾收集。這種釋放資源的看似「自動化」特性是混淆的一個緣由,給JavaScript(和其餘高級語言)的開發人員帶來了他們能夠選擇不關心內存管理的錯誤印象。這是一個大錯誤。

即便使用高級語言,開發人員也應該理解內存管理(至少是基本的)。有時,自動內存管理存在問題(例如垃圾收集器中的錯誤或實施限制等),開發人員必須瞭解這些問題才能正確處理這些問題(或者找到適當的解決方法,而且具備最小的權衡和代碼債務)。

內存生命週期

不管您使用什麼編程語言,內存生命週期幾乎都是同樣的:

https://user-gold-cdn.xitu.io/2018/2/1/1614eb96c6619394?w=1024&h=768&f=png&s=16698

如下是對循環中每一個步驟發生的狀況的概述:

  • 分配內存  - 內存由操做系統分配,容許程序使用它。在低級語言中(例如C),這是一個做爲開發人員應該處理的顯式操做。然而,在高級語言中,這是爲你照顧的。
  • 使用內存 - 這是您的程序實際上使用之前分配的內存的時間。讀取和寫入操做正在您的代碼中使用分配的變量。
  • 釋放內存  - 如今是釋放你不須要的整個內存的時間,以便它能夠變成空閒的而且能夠再次使用。與分配內存操做同樣,這個操做在低級語言中是明確的。

有關調用堆棧和內存堆的概念的快速概述,您能夠閱讀咱們關於主題的第一篇文章

什麼是內存?

在直接跳到JavaScript中的內存以前,咱們將簡要地討論一下內存的概況以及它是如何工做的。

在硬件層面上,計算機內存由大量的 觸發器組成。每一個觸發器包含一些晶體管,並可以存儲一位。單獨的觸發器能夠經過惟一的標識符來尋址,因此咱們能夠讀取和覆蓋它們。所以,從概念上講,咱們能夠將整個計算機內存看做是咱們能夠讀寫的一大塊位。

既然做爲人類,咱們並不善於把全部的思想和算術都作成一點點,咱們把它們組織成更大的羣體,它們能夠一塊兒用來表示數字。8位稱爲1個字節。除字節外,還有單詞(有時是16,有時是32位)。

不少東西都存儲在這個內存中:

  1. 全部程序使用的全部變量和其餘數據。
  2. 程序的代碼,包括操做系統的代碼。

編譯器和操做系統一塊兒工做,爲您處理大部分的內存管理,可是咱們建議您看看底下發生了什麼。

編譯代碼時,編譯器能夠檢查原始數據類型,並提早計算它們須要多少內存。而後將所需的金額分配給調用堆棧空間中的程序。分配這些變量的空間稱爲堆棧空間,由於隨着函數被調用,它們的內存被添加到現有的內存之上。當它們終止時,它們以LIFO(後進先出)順序被移除。例如,請考慮如下聲明:

int n; // 4個字節
int x [4]; // 4個元素的數組,每4個字節
雙m; // 8個字節
複製代碼

編譯器能夠當即看到代碼須要
4 + 4×4 + 8 = 28個字節。

這就是它與目前的整數和雙打的尺寸。大約20年前,整數一般是2個字節,雙4字節。您的代碼不該該依賴於此刻基本數據類型的大小。

編譯器將插入與操做系統交互的代碼,以便爲堆棧中的變量存儲所需的字節數。

在上面的例子中,編譯器知道每一個變量的確切內存地址。實際上,每當咱們寫入這個變量n,它就會在內部翻譯成「內存地址4127963」。

注意,若是咱們試圖訪問x[4]這裏,咱們將訪問與m關聯的數據。這是由於咱們正在訪問數組中不存在的元素 - 它比數組中最後一個實際分配的元素多了4個字節x[3],而且可能最終讀取(或覆蓋)了一些m比特。這對方案的其他部分幾乎確定會產生很是不但願的後果。

https://user-gold-cdn.xitu.io/2018/2/1/1614eb96c674afa3?w=1024&h=353&f=png&s=10304

當函數調用其餘函數時,每一個函數調用時都會獲得本身的堆棧塊。它保留了全部的局部變量,並且還有一個程序計數器,它記錄了執行的地方。當功能完成時,其內存塊再次可用於其餘目的。

動態分配

不幸的是,當咱們不知道編譯時變量須要多少內存時,事情並不那麼容易。假設咱們想要作以下的事情:

int n = readInput(); //從用戶讀取輸入
//用「n」個元素建立一個數組
複製代碼

這裏,在編譯時,編譯器不知道數組須要多少內存,由於它是由用戶提供的值決定的。

所以,它不能爲堆棧上的變量分配空間。相反,咱們的程序須要在運行時明確地向操做系統請求適當的空間。這個內存是從堆空間分配的。下表總結了靜態和動態內存分配之間的區別:

https://user-gold-cdn.xitu.io/2018/2/1/1614eb96c6e8ff49?w=1024&h=431&f=png&s=71479
靜態和動態分配的內存之間的差別

爲了充分理解動態內存分配是如何工做的,咱們須要在指針上花費更多的時間,這可能與本文主題偏離太多。若是您有興趣瞭解更多信息,請在評論中告訴咱們,咱們能夠在之後的文章中詳細介紹指針。

在JavaScript中(內存)分配

如今咱們將解釋第一步(分配內存)如何在JavaScript中工做。

JavaScript使開發人員免於處理內存分配的責任 - JavaScript自己就是這樣作的,同時還聲明瞭值。

var n = 374; // allocates memory for a number
var s = 'sessionstack'; // allocates memory for a string 
var o = {
  a: 1,
  b: null
}; // allocates memory for an object and its contained values
var a = [1, null, 'str'];  // (like object) allocates memory for the
                           // array and its contained values
function f(a) {
  return a + 3;
} // allocates a function (which is a callable object)
// function expressions also allocate an object
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);
複製代碼

一些函數調用也會致使對象分配:

var d = new Date(); // allocates a Date object
var e = document.createElement('div'); // allocates a DOM element
複製代碼

方法能夠分配新的值或對象:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string
// Since strings are immutable, 
// JavaScript may decide to not allocate memory, 
// but just store the [0, 3] range.
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
// new array with 4 elements being
// the concatenation of a1 and a2 elements
複製代碼

在JavaScript中使用內存

基本上在JavaScript中使用分配的內存,意味着在其中讀寫。

這能夠經過讀取或寫入變量或對象屬性的值,甚至將參數傳遞給函數來完成。

當內存再也不須要時釋放

大部份內存管理問題都是在這個階段。

這裏最困難的任務是肯定什麼時候再也不須要分配的內存。它一般須要開發人員肯定程序中的哪一個部分再也不須要這些內存,並將其釋放。

高級語言嵌入了一個名爲垃圾收集器的軟件,其工做是跟蹤內存分配和使用狀況,以便在再也不須要分配內存的狀況下自動釋放內存。

不幸的是,這個過程是一個近似值,由於知道是否須要某些內存的通常問題是不可斷定的(不能由算法來解決)。

大多數垃圾收集器經過收集不能被訪問的內存來工做,例如指向它的全部變量超出範圍。然而,這是能夠收集的一組內存空間的近似值,由於在任什麼時候候內存位置可能仍然有一個指向它的變量,但它將不會被再次訪問。

垃圾收集

因爲發現一些內存是否「再也不須要」的事實是不可斷定的,因此垃圾收集實現了對通常問題的解決方案的限制。本節將解釋理解主要垃圾收集算法及其侷限性的必要概念。

內存引用

垃圾收集算法所依賴的主要概念是引用(reference)之一。

在內存管理的狀況下,若是一個對象訪問後者(能夠是隱含的或顯式的),則稱該對象引用另外一個對象。例如,JavaScript對象具備對其原型(隱式引用)及其屬性值(顯式引用)的引用。

在這種狀況下,「對象」的概念被擴展到比普通JavaScript對象更普遍的範圍,而且還包含函數做用域(或全局詞法做用域)。

詞法範圍定義瞭如何在嵌套函數中解析變量名稱:即便父函數已經返回,內部函數也包含父函數的做用域。

引用計數垃圾收集

這是最簡單的垃圾收集算法。若是有零個引用指向它,則該對象被認爲是「垃圾收集」 。

看看下面的代碼:

var o1 = {
  o2: {
    x: 1
  }
};
// 2 objects are created. 
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected

var o3 = o1; // the 'o3' variable is the second thing that 
            // has a reference to the object pointed by 'o1'. 
                                                       
o1 = 1;      // now, the object that was originally in 'o1' has a         
            // single reference, embodied by the 'o3' variable

var o4 = o3.o2; // reference to 'o2' property of the object.
                // This object has now 2 references: one as
                // a property. 
                // The other as the 'o4' variable

o3 = '374'; // The object that was originally in 'o1' has now zero
            // references to it. 
            // It can be garbage-collected.
            // However, what was its 'o2' property is still
            // referenced by the 'o4' variable, so it cannot be
            // freed.

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it. 
           // It can be garbage collected.
複製代碼

循環形成問題

在循環方面有一個限制。在下面的例子中,建立兩個對象並相互引用,從而建立一個循環。在函數調用以後,它們會超出範圍,因此它們其實是無用的,能夠被釋放。然而,引用計數算法認爲,因爲兩個對象中的每個被引用至少一次,因此二者都不能被垃圾收集。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 references o2
  o2.p = o1; // o2 references o1. This creates a cycle.
}

f();
複製代碼

https://user-gold-cdn.xitu.io/2018/2/1/1614eb96bfe93807?w=386&h=209&f=png&s=10259

標記和掃描算法

爲了肯定是否須要對象,此算法肯定對象是否可達。

標記和掃描算法通過這3個步驟:

  • 根:一般,根是代碼中引用的全局變量。例如,在JavaScript中,能夠充當根的全局變量是「窗口」對象。Node.js中的相同對象稱爲「全局」。全部根的完整列表由垃圾收集器構建。
  • 算法而後檢查全部根和他們的孩子而且標記他們是活躍的(意思,他們不是垃圾)。任何根不能達到的將被標記爲垃圾。
  • 最後,垃圾回收器釋放全部未標記爲活動的內存塊,並將該內存返回給操做系統。

https://user-gold-cdn.xitu.io/2018/2/1/1614eb96c6b097aa?w=972&h=514&f=gif&s=268959

標記和掃描算法的可視化

這個算法比前一個算法更好,由於「一個對象有零引用」致使這個對象沒法訪問。正如咱們已經看到週期同樣,狀況正好相反。

截至2012年,全部現代瀏覽器都發布了標記式的垃圾回收器。JavaScript垃圾收集(代碼/增量/併發/並行垃圾收集)領域中所作的全部改進都是對這種算法(標記和掃描)的實現改進,但不是對垃圾收集算法自己的改進,也不是它的目標是決定一個對象是否可達。

本文中,您能夠詳細閱讀有關跟蹤垃圾回收的更詳細信息,這些垃圾回收也涵蓋了標記和掃描以及其優化。

循環再也不是問題了

在上面的第一個例子中,在函數調用返回以後,兩個對象再也不被全局對象可訪問的東西引用。所以,它們將被垃圾收集器發現沒法訪問。

https://user-gold-cdn.xitu.io/2018/2/1/1614eb96c7179f06?w=1024&h=768&f=png&s=24898

即便在對象之間有引用,它們也不能從根目錄訪問。

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

儘管垃圾收集者很方便,但他們也有本身的一套權衡。其中之一是非決定論。換句話說,GC是不可預測的。你不能真正知道何時收集。這意味着在某些狀況下,程序會使用更多的內存,這是實際須要的。在其餘狀況下,在特別敏感的應用程序中,短暫暫停多是顯而易見的。雖然非肯定性意味着不能肯定什麼時候執行集合,但大多數GC實現共享在分配期間進行收集通行證的通用模式。若是沒有執行分配,大多數GC保持空閒狀態。考慮如下狀況:

  • 大量的分配被執行。
  • 大多數這些元素(或全部這些元素)被標記爲沒法訪問(假設咱們將一個引用指向咱們再也不須要的緩存)。
  • 沒有進一步的分配執行。 在這種狀況下,大多數GC不會運行任何進一步的收集通行證。換句話說,即便有不可用的引用可用於收集,這些收集器不會聲明。這些並非嚴格的泄漏,但仍會致使內存使用率高於平時。

什麼是內存泄漏?

就像內存建議同樣,內存泄漏是應用程序過去使用的內存片斷,但再也不須要,但還沒有返回到操做系統或可用內存池。

https://user-gold-cdn.xitu.io/2018/2/1/1614eb979b71d731?w=450&h=437&f=jpeg&s=41174

編程語言有利於不一樣的內存管理方式。可是,是否使用某一段內存其實是一個不可斷定的問題。換句話說,只有開發人員能夠明確是否能夠將一塊內存返回到操做系統。

某些編程語言提供了幫助開發人員執行此操做 其餘人則但願開發人員可以徹底清楚一段內存什麼時候未被使用。維基百科有關手動和自動內存管理的好文章。

四種常見的JavaScript泄漏

1:全局變量

JavaScript以一種有趣的方式處理未聲明的變量:當引用未聲明的變量時,在全局對象中建立一個新變量。在瀏覽器中,全局對象將是window,這意味着

function foo(arg) {
    bar = "some text";
}
複製代碼

至關於:

function foo(arg) {
    window.bar = "some text";
}
複製代碼

讓咱們說的目的bar是在foo函數中只引用一個變量。若是您不使用var聲明,將會建立一個冗餘的全局變量。在上述狀況下,這不會形成太大的傷害。你固然能夠想象一個更具破壞性的場景。

你也能夠用下面的方法不當心建立一個全局變量this:

function foo() {
    this.var1 = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();
複製代碼

您能夠經過‘use strict’;在JavaScript文件的開始處添加以免全部這些,這將開啓更嚴格的解析JavaScript模式,從而防止意外建立全局變量。

意外的全局變量固然是一個問題,然而,更多的時候,你的代碼會受到垃圾收集器沒法收集的顯式全局變量的影響。須要特別注意用於臨時存儲和處理大量信息的全局變量。若是您必須使用全局變量來存儲數據,那麼確保將其分配爲空值,或者在完成後從新分配

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

讓咱們setInterval舉個例子,由於它常常用在JavaScript中。

提供接受回調的觀察者和其餘工具的庫一般確保全部對回調的引用在其實例沒法訪問時變得沒法訪問。不過,下面的代碼並非一個可貴的發現:

var serverData = loadData(); setInterval(function() { var renderer = document.getElementById('renderer'); if(renderer) { renderer.innerHTML = JSON.stringify(serverData); } }, 5000); //This will be executed every ~5 seconds. 上面的代碼片斷顯示了使用引用節點或再也不須要的數據的定時器的結果。

該renderer對象可能會在某些時候被替換或刪除,這會使間隔處理程序封裝的塊變得冗餘。若是發生這種狀況,那麼處理程序及其依賴項都不會被收集,由於間隔須要先中止(請記住,它仍然是活動的)。這一切都歸結爲serverData確實存儲和處理負載數據的事實也不會被收集。

當使用觀察者時,你須要確保你作了一個明確的調用來刪除它們(或者再也不須要觀察者,不然對象將變得不可用)。

幸運的是,大多數現代瀏覽器都會爲你作這件事:即便你忘記刪除監聽器,觀察對象變得沒法訪問,它們也會自動收集觀察者處理程序。過去一些瀏覽器沒法處理這些狀況(舊的IE6)。

可是,儘管如此,一旦對象變得過期,這是符合最佳實踐的。看下面的例子:

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers // that don't handle cycles well. 複製代碼

removeEventListener在現代瀏覽器支持能夠檢測這些週期並適當處理它們的垃圾收集器以前,再也不須要調用節點。

若是您利用jQueryAPI(其餘庫和框架也支持這一點),您也能夠在節點過期以前刪除偵聽器。即便應用程序在較舊的瀏覽器版本下運行,庫也會確保沒有內存泄漏。

3:關閉

JavaScript開發的一個關鍵方面是閉包:一個內部函數,能夠訪問外部(封閉)函數的變量。因爲JavaScript運行時的實現細節,可能如下列方式泄漏內存:

var theThing = null;
var replaceThing = function(){
  var originalThing = theThing; 
  var unused = function(){ 
    if(originalThing)//對'originalThing'的引用
      console.log(「hi」); 
  };
  theThing = { 
    longStr:new Array(1000000).join('*'),
    someMethod:function(){ 
      console.log(「message」); 
    } 
  }; 
};
setInterval(replaceThing,1000);
複製代碼

一旦replaceThing被調用,theThing獲取由一個大數組和一個新的閉包(someMethod)組成的新對象。然而,originalThing被unused變量所持有的閉包所引用(這個theThing變量是前一次調用的變量replaceThing)。須要記住的是,一旦在同一父做用域中爲閉包建立了閉包的做用域,做用域就被共享了。

在這種狀況下,爲閉包建立的範圍將someMethod與之共享unused。unused有一個參考originalThing。即便unused從未使用過,someMethod 也能夠theThing在整個範圍以外使用replaceThing(例如全球某個地方)。並且someMethod與封閉範圍同樣unused,引用unused必須originalThing強制它保持活躍(兩封閉之間的整個共享範圍)。這阻止了它的收集。

在上面的例子中,所述封閉建立的範圍someMethod與共享unused,而unused引用originalThing。someMethod能夠theThing在replaceThing範圍以外使用,儘管這unused是歷來沒有使用的事實。事實上,未使用的引用originalThing要求它保持活躍,由於someMethod與未使用的共享封閉範圍。

全部這些均可能致使至關大的內存泄漏。當上面的代碼片斷一遍又一遍地運行時,您能夠預期會看到內存使用率的上升。當垃圾收集器運行時,其大小不會縮小。建立一個閉包的鏈表(theThing在這種狀況下它的根是可變的),而且每一個閉包範圍都帶有對大數組的間接引用。

Meteor團隊發現了這個問題,他們有一篇很好的文章,詳細描述了這個問題。

4:超出DOM引用

有些狀況下開發人員在數據結構中存儲DOM節點。假設你想快速更新表格中幾行的內容。若是在字典或數組中存儲對每一個DOM行的引用,則會有兩個對同一個DOM元素的引用:一個在DOM樹中,另外一個在字典中。若是你決定擺脫這些行,你須要記住使兩個引用沒法訪問。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    // The image is a direct child of the body element.
    document.body.removeChild(document.getElementById('image'));
    // At this point, we still have a reference to #button in the
    //global elements object. In other words, the button element is
    //still in memory and cannot be collected by the GC.
}
複製代碼

在涉及DOM樹內的內部節點或葉節點時,還有一個額外的考慮因素須要考慮。若是您在代碼中保留對錶格單元格(標籤)的引用,並決定從DOM中刪除該表格,並保留對該特定單元格的引用,則能夠預期會出現嚴重的內存泄漏。你可能會認爲垃圾收集器會釋放除了那個單元以外的全部東西。但狀況並不是如此。因爲單元格是表格的子節點,而且子節點保持對父節點的引用,因此對錶格單元格的這種單引用能夠將整個表格保存在內存中。

咱們在SessionStack嘗試遵循這些最佳實踐,編寫正確處理內存分配的代碼,緣由以下: 一旦將SessionStack集成到生產Web應用程序中,它就會開始記錄全部事件:全部DOM變動,用戶交互,JavaScript異常,堆棧跟蹤,網絡請求失敗,調試消息等等。
使用SessionStack,您能夠在Web應用程序中重放問題,看到你的用戶發生的一切。全部這些都必須在您的網絡應用程序沒有性能影響的狀況下進行。 因爲用戶能夠從新加載頁面或導航你的應用程序,全部的觀察者,攔截器,變量分配等都必須正確處理,因此它們不會致使任何內存泄漏,或者不會增長Web應用程序的內存消耗咱們正在整合。

有一個免費的計劃,因此你能夠試試看

https://user-gold-cdn.xitu.io/2018/2/1/1614eb9795f6cf61?w=1031&h=573&f=png&s=86009

參考

相關文章
相關標籤/搜索