[譯] JavaScript如何工作:垃圾回收機制 + 常見的4種記憶體洩漏

HerryLo發表於2019-04-01

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

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

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

JavaScript 中的記憶體分配

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

JavaScript 減輕了開發人員處理記憶體分配的責任 - JavaScript自己執行了記憶體分配,同時宣告瞭值。

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物件更廣泛的範圍,幷包含函式作用域(或全域性詞法範圍)。

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

引用計數垃圾收集

這是最簡單的垃圾收集演算法。 如果有零個指向它的引用,則該物件被認為是可垃圾回收的。 請看下面的程式碼:

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();複製程式碼

                             [譯] JavaScript如何工作:垃圾回收機制 + 常見的4種記憶體洩漏

標記和掃描演算法

為了確定是否需要某個物件,本演算法判斷該物件是否可訪問。

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

  1. 根節點:一般來說,根是程式碼中引用的全域性變數。例如,在 JavaScript 中,可以充當根節點的全域性變數是“window”物件。Node.js 中的全域性物件被稱為“global”。完整的根節點列表由垃圾收集器構建。
  2. 然後演算法檢查所有根節點和他們的子節點並且把他們標記為活躍的(意思是他們不是垃圾)。任何根節點不能訪問的變數將被標記為垃圾
  3. 最後,垃圾收集器釋放所有未被標記為活躍的記憶體塊,並將這些記憶體返回給作業系統

[譯] JavaScript如何工作:垃圾回收機制 + 常見的4種記憶體洩漏

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

因為“一個物件有零引用”導致該物件不可訪問,所以這個演算法比前一個演算法更好。我們在週期中看到的情形恰巧相反,是不正確的。 截至 2012 年,所有現代瀏覽器都內建了標記掃描式的垃圾回收器。去年在 JavaScript 垃圾收集(通用/增量/併發/並行垃圾收集)領域中所做的所有改進都是基於這種演算法(標記和掃描)的實現改進,但這不是對垃圾收集演算法本身的改進,也不是對判斷一個物件是否可訪問這個目標的改進。

週期不再是問題

在上面的例子中,函式呼叫返回後,兩個物件不再被全域性物件中的變數引用。因此,垃圾收集器會認為它們不可訪問。

[譯] JavaScript如何工作:垃圾回收機制 + 常見的4種記憶體洩漏

即使兩個物件之間有引用,根節點不在訪問它們。

統計垃圾收集器行為

儘管垃圾收集器很方便,但他們也有自己的一套策略。其中之一是不確定性。換句話說,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...


相關文章