[譯] JavaScript如何工做:垃圾回收機制 + 常見的4種內存泄漏

原文地址: How JavaScript works: memory management...
javascript

本文永久連接: https://github.com/AttemptWeb...
java

有部分的刪減和修改,不過大部分是參照原文來的,翻譯的目的主要是弄清JavaScript的垃圾回收機制,以爲有問題的歡迎指正。
git

JavaScript 中的內存分配

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

JavaScript 減輕了開發人員處理內存分配的責任 - JavaScript本身執行了內存分配,同時聲明瞭值。web

var n = 374; // 爲number分配內存
var s = 'sessionstack'; // 爲string分配內存  
var o = {
  a: 1,
  b: null
}; //爲對象及屬性分配內存 

function f(a) {
  return a + 3;
} // 爲函數分配內存
// 函數表達式分配內存
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);複製代碼

在 JavaScript 中使用內存

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

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

垃圾回收機制

因爲發現一些內存是否「再也不須要」事實上是不可斷定的,因此垃圾收集在實施通常問題解決方案時具備侷限性。下面將解釋主要垃圾收集算法及其侷限性的基本概念。
瀏覽器

內存引用

若是一個對象能夠訪問另外一個對象(能夠是隱式的或顯式的),則稱該對象引用另外一個對象。例如, 一個 JavaScript 引用了它的 prototype (隱式引用)和它的屬性值(顯式引用)。

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

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

引用計數垃圾收集

這是最簡單的垃圾收集算法。 若是有零個指向它的引用,則該對象被認爲是可垃圾回收的。 請看下面的代碼:bash

var o1 = {
  o2: {
    x: 1
  }
};
// 兩個對象被建立。
// ‘o1’對象引用‘o2’對象做爲其屬性。
// 不能夠被垃圾收集

var o3 = o1; // ‘o3’變量是第二個引用‘o1‘指向的對象的變量. 
                                                       
o1 = 1;      // 如今,在‘o1’中的對象只有一個引用,由‘o3’變量表示

var o4 = o3.o2; // 對象的‘o2’屬性的引用.
                // 此對象如今有兩個引用:一個做爲屬性、另外一個做爲’o4‘變量

o3 = '374'; // 原來在「o1」中的對象如今爲零,對它的引用能夠垃圾收集。
            // 可是,它的‘o2’屬性存在,由‘o4’變量引用,所以不能被釋放。

o4 = null; // ‘o1’中最初對象的‘o2’屬性對它的引用爲零。它能夠被垃圾收集。複製代碼

週期產生問題

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

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // ‘o1’ 應用 ‘02’ o1 references o2
  o2.p = o1; // ‘o2’ 引用 ‘o2’ . 一個循環被建立
}
f();複製代碼

                             

標記和掃描算法

爲了肯定是否須要某個對象,本算法判斷該對象是否可訪問。

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

  1. 根節點:通常來講,根是代碼中引用的全局變量。例如,在 JavaScript 中,能夠充當根節點的全局變量是「window」對象。Node.js 中的全局對象被稱爲「global」。完整的根節點列表由垃圾收集器構建。
  2. 而後算法檢查全部根節點和他們的子節點而且把他們標記爲活躍的(意思是他們不是垃圾)。任何根節點不能訪問的變量將被標記爲垃圾
  3. 最後,垃圾收集器釋放全部未被標記爲活躍的內存塊,並將這些內存返回給操做系統


標記和掃描算法行爲的可視化。(Mark and sweep) 標記與清除

由於「一個對象有零引用」致使該對象不可訪問,因此這個算法比前一個算法更好。咱們在週期中看到的情形恰巧相反,是不正確的。 截至 2012 年,全部現代瀏覽器都內置了標記掃描式的垃圾回收器。去年在 JavaScript 垃圾收集(通用/增量/併發/並行垃圾收集)領域中所作的全部改進都是基於這種算法(標記和掃描)的實現改進,但這不是對垃圾收集算法自己的改進,也不是對判斷一個對象是否可訪問這個目標的改進。

週期再也不是問題

在上面的例子中,函數調用返回後,兩個對象再也不被全局對象中的變量引用。所以,垃圾收集器會認爲它們不可訪問。


即便兩個對象之間有引用,根節點不在訪問它們。

統計垃圾收集器行爲

儘管垃圾收集器很方便,但他們也有本身的一套策略。其中之一是不肯定性。換句話說,GC(垃圾收集器)是不可預測的。你不能肯定一個垃圾收集器什麼時候會執行收集。這意味着在某些狀況下,程序其實須要更多的內存。其餘狀況下,在特別敏感的應用程序中,短暫和卡頓多是明顯的。儘管不肯定性意味着不能肯定一個垃圾收集器什麼時候執行收集,大多數 GC 共享分配中的垃圾收集通用模式。若是沒有執行分配,大多數 GC 保持空閒狀態。考慮以下場景:

  1. 大量的分配被執行。
  2. 大多數這些元素(或所有)被標記爲不可訪問(假設咱們廢除一個指向咱們再也不須要的緩存的引用)。
  3. 沒有執行更深的內存分配。

在這種狀況下,大多數 GC 不會運行任何更深層次的收集。換句話說,即便存在變量可用於收集,收集器也不會收集這些引用。這些並非嚴格的泄漏,但仍會致使高於平常的內存使用率。

什麼是內存泄漏?

內存泄漏 是 應用程序過去使用,但再也不須要的還沒有返回到操做系統或可用內存池的內存片斷。因爲沒有被釋放而致使的,它將可能引發程序的卡頓和崩潰。

JavaScript 常見的四種內存泄漏

1:全局變量

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

假設 bar 的目的只是引用 foo 函數中的一個變量。然而不使用 var 來聲明它,就會建立一個冗餘的全局變量

你能夠經過在 JavaScript 文件的開頭添加 'use strict'; 來避免這些後果,這將開啓一種更嚴格的 JavaScript 解析模式,從而防止意外建立全局變量。

意外的全局變量固然是個問題,然而更常出現的狀況是,你的代碼會受到顯式的全局變量的影響,而這些全局變量沒法經過垃圾收集器收集。須要特別注意用於臨時存儲和處理大量信息的全局變量。若是你必須使用全局變量來存儲數據,當你這樣作的時候,要保證一旦完成使用,就把他們賦值爲 null 或從新賦值

2:被忘記的定時器或者回調函數

咱們以常常在 JavaScript 中使用的 setInterval 爲例。

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //每5秒執行一次.複製代碼

上面的代碼片斷顯示了使用定時器引用節點或無用數據的後果。它既不會被收集,也不會被釋放。沒法被垃圾收集器收集,頻繁的被調用,佔用內存。而正確的使用方法是,確保一旦依賴於它們的事件已經處理完成,就經過明確的調用來刪除它們。

3:閉包

閉包是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 變量(這是從前一次調用 replaceThing 變量的 Thing 變量)所持有的閉包所引用。須要記住的是一旦爲同一個父做用域內的閉包建立做用域,做用域將被共享

在這個例子中,someMethod 建立的做用域與 unused 共享。unused 包含一個關於 originalThing 的引用。即便 unused 從未被引用過,someMethod 也能夠經過 replaceThing 做用域以外的 theThing 來使用它(例如全局的某個地方)。因爲 someMethodunused 共享閉包範圍,unused 指向 originalThing 的引用強制它保持活動狀態(兩個閉包之間的整個共享範圍)。這阻止了它們的垃圾收集。

在上面的例子中,爲閉包 someMethod 建立的做用域與 unused 共享,而 unused 又引用 originalThingsomeMethod 能夠經過 replaceThing 範圍以外的 theThing 來引用,儘管 unused 歷來沒有被引用過。事實上,unusedoriginalThing 的引用要求它保持活躍,由於 someMethodunused 的共享封閉範圍。

全部這些均可能致使大量的內存泄漏。當上面的代碼片斷一遍又一遍地運行時,您能夠預期到內存使用率的上升。當垃圾收集器運行時,其大小不會縮小。一個閉包鏈被建立(在例子中它的根就是 theThing 變量),而且每一個閉包做用域都包含對大數組的間接引用。

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() {
    // image 元素是body的直接子元素。
    document.body.removeChild(document.getElementById('image'));
    // 咱們仍然能夠在全局元素對象中引用button。換句話說,button元素仍在內存中,沒法由GC收集
}複製代碼

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

咱們在 SessionStack 嘗試遵循這些最佳實踐,編寫正確處理內存分配的代碼,緣由以下:

一旦將 SessionStack 集成到你的生產環境的 Web 應用程序中,它就會開始記錄全部的事情:全部的 DOM 更改,用戶交互,JavaScript 異常,堆棧跟蹤,失敗網絡請求,調試消息等。

經過 SessionStack web 應用程序中的問題,並查看全部的用戶行爲。全部這些都必須在您的網絡應用程序沒有性能影響的狀況下進行。

因爲用戶能夠從新加載頁面或導航你的應用程序,全部的觀察者,攔截器,變量分配等都必須正確處理,這樣它們纔不會致使任何內存泄漏,也不會增長咱們正在整合的Web應用程序的內存消耗。

這裏有一個免費的計劃因此你能夠試試看.

Resources

How JavaScript works: memory management...

ps: 順便推一下本身的我的公衆號:Yopai,有興趣的能夠關注,每週不按期更新,分享能夠增長世界的快樂

相關文章
相關標籤/搜索