【譯】JavaScript是如何工做的:內存管理 + 如何處理4個常見的內存泄露

本文轉載自:衆成翻譯
譯者:Leslie Wang
審校: 爲之漫筆
連接:http://www.zcfy.cc/article/4211
原文:https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbecjavascript

幾周前,咱們開始寫一個系列,深刻探討JavaScript和它的工做原理。咱們認爲了解JavaScript的構成以及它們如何協做,有助於編寫出更好的代碼和應用程序。html

本系列第一篇重點介紹了引擎、運行時、調用棧。第二篇揭示了谷歌V8 JavaScript引擎的內部機制,而且提供了一些關於如何寫出更好的JavaScript代碼的建議。前端

本文做爲第三篇,將會討論另外一個開發者容易忽視的重要主題 :內存管理。咱們也會提供一些關於如何處理JavaScript內存泄露的技巧。在SessionStack,咱們須要確保不會形成內存泄露或者不會增長咱們集成的Web應用的內存消耗。java

概述

某些語言,好比C有低級的原生內存管理原語,像malloc()free()。開發人員使用這些原語能夠顯式分配和釋放操做系統的內存。node

相對地,JavaScript會在建立變量(對象、字符串)時自動分配內存,並在這些變量不被使用時自動釋放內存,這個過程被稱爲垃圾回收。這個「自動」釋放資源的特性帶來了不少困惑,讓JavaScript(和其餘高級級語言)開發者誤覺得能夠不關心內存管理。這是一個很大的錯誤web

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

內存生命週期

不管你用哪種編程語言,內存生命週期幾乎老是同樣的:編程

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

  • 分配內存— 內存是被操做系統分配,這容許程序使用它。在低級語言中(例如C),這是一個做爲開發者須要處理的顯式操做。在高級語言中,然而,這些操做都代替開發者進行了處理。
  • 使用內存。實際使用以前分配的內存,經過在代碼操做變量對內在進行讀和寫。
  • 釋放內存 。不用的時候,就能夠釋放內存,以便從新分配。與分配內存操做同樣,釋放內存在低級語言中也須要顯式操做。

想要快速的瞭解堆棧和內存的概念,能夠閱讀本系列第一篇文章。數組

什麼是內存

在直接探討Javascript中的內存以前,咱們先簡要的討論一下什麼是內存、內存大概是怎麼樣工做的。

在硬件中,電腦的內存包含了大量的觸發電路,每個觸發電路都包含一些<span style="font-size: 1rem;">可以儲存1位數據的</span>晶體管。觸發器經過惟一標識符來尋址,從而能夠讀取和覆蓋它們。所以,從概念上來說,能夠認爲電腦內存是一個巨大的可讀寫陣列。

人類不善於把咱們全部的思想和算術用位運算來表示,咱們把這些小東西組織成一個你們夥,這些你們夥能夠用來表現數字:8位是一個字節。字節之上是字(16位、32位)。

許多東西被存儲在內存中:

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

編譯器和操做系統共同工做幫助開發者完成大部分的內存管理,可是咱們推薦你瞭解一下底層到底發生了什麼。

編譯代碼的時候,編譯器會解析原始數據類型,提早計算出它們須要多大的內存空間。而後將所需的數量分配在棧空間中。之因此稱爲棧空間,是因在函數被調用的時候,他們的內存被添加在現有內存之上(就是會在棧的最上面添加一個棧幀來指向存儲函數內部變量的空間)。終止的時候,以LIFO(後進先出)的順序移除這些調用。例如:

int n; // 4字節
int x[4]; // 4個元素的數組,每一個元素4字節
double m; // 8字節

編譯器立刻知道須要內存
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的一些位。這確定會對其餘程序產生不但願產生的結果。

當函數調用其餘函數的時候,每個函數被調用的時候都會得到本身的棧塊。在本身的棧塊裏會保存函數內全部的變量,還有一個程序計數器會記錄變量執行時所在的位置。當函數執行完以後,會釋放它的內存以做他用。

動態分配

不幸的是,事情並非那麼簡單,由於在編譯的時候咱們並不知道一個變量將會須要多少內存。假設咱們作了下面這樣的事:

int n = readInput(); //讀取用戶的輸入

...

//建立一個有n個元素的數組

編譯器不知道這個數組須要多少內存,由於數組大小取決於用戶提供的值。

所以,此時不能在棧上分配空間。程序必須在運行時向操做系統請求夠用的空間。此時內存從堆空間中被分配。靜態與動態分配內存之間的不一樣在下面的表格中被總結出來:

靜態分配內存與動態分配內存的區別。

爲了徹底理解動態內存是如何分配的,咱們須要花更多的時間在指針上,這個可能很大程度上偏離了這篇文章的主題。若是你有興趣學習更多的知識,那就在評論中讓我知道,我就能夠在以後的文章中寫更多關於指針的細節。

JavaScript中的內存分配

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

JavaScript在開發者聲明值的時候自動分配內存。

var n = 374; // 爲數值分配內存
var s = 'sessionstack'; //爲字符串分配內存

var o = {
  a: 1,
  b: null
};  //爲對象和它包含的值分配內存

var a = [1, null, 'str']; //爲數組和它包含的值分配內存

function f(a) {
  return a + 3;
} //爲函數(可調用的對象)分配內存

//函數表達式也會分配一個對象
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);

 //一些函數調用也會致使對象分配
`var d = new Date(); // allocates a Date object`   //分配一個Date對象的內存

`var e = document.createElement('div');  //分配一個DOM元素的內存

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

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3);  //s2是一個新的字符串
// 由於字符串是不可變的
// JavaScript可能決定不分配內存
// 而僅僅存儲 0-3的範圍

var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
//新的數組有4個元素是a1和a2鏈接起來的。

在JavaScript中使用內存

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

好比,讀、寫變量的值或者對象的屬性,抑或向一個函數傳遞參數。

內存不在被須要時釋放內存

大部分的內存管理問題都在這個階段出現。

這裏最難的任務是找出這些被分配的內存何時再也不被須要。這經常要求開發者去決定程序中的一段內存不在被須要並且釋放它。

高級語言嵌入了一個叫垃圾回收的軟件,它的工做是跟蹤內存的分配和使用,以便於發現一些內存在一些狀況下再也不被須要,它將會自動地釋放這些內存。

不幸的是,這個過程是一個近似的過程,由於通常關於知道內存是不是被須要的問題是不可判斷的(不能用一個算法解決)。

大部分的垃圾回收器會收集再也不被訪問的內存,例如指向它的全部變量都在做用域以外。然而,這是一組能夠收集的內存空間的近似值。由於在任什麼時候候,一個內存地址可能還有一個在做用域裏的變量指向它,可是它將不會被再次訪問。

垃圾收集

因爲找到一些內存是不是「再也不被須要的」這個事實是不可斷定的,垃圾回收的實現存在侷限性。本節解釋必要的概念去理解主要的垃圾回收算法和它們的侷限性。

內存引用

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

在內存管理的語境下,一個對象只要顯式或隱式訪問另外一個對象,就能夠說它引用了另外一個對象。例如,JavaScript對象引用其Prototype(隱式引用),或者引用prototype對象的屬性值(顯式引用)。

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

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

引用計數垃圾回收

這是最簡單的垃圾回收算法。 一個對象在沒有其餘的引用指向它的時候就被認爲「可被回收的」。

看一下下面的代碼:

var o1 = {
  o2: {
    x: 1
  }
};

//2個對象被建立
/'o2'被'o1'做爲屬性引用
//誰也不能被回收

var o3 = o1; //'o3'是第二個引用'o1'指向對象的變量

o1 = 1;      //如今,'o1'只有一個引用了,就是'o3'
var o4 = o3.o2; // 引用'o3'對象的'o2'屬性
                //'o2'對象這時有2個引用: 一個是做爲對象的屬性
                //另外一個是'o4'

o3 = '374'; //'o1'原來的對象如今有0個對它的引用
             //'o1'能夠被垃圾回收了。
            //然而它的'o2'屬性依然被'o4'變量引用,因此'o2'不能被釋放。

o4 = null;  //最初'o1'中的'o2'屬性沒有被其餘的引用了
           //'o2'能夠被垃圾回收了

循環引用創造麻煩

在涉及循環引用的時候有一個限制。在下面的例子中,兩個對象被建立了,並且相互引用,這樣建立了一個循環引用。它們會在函數調用後超出做用域,應該能夠釋放。然而引用計數算法考慮到2個對象中的每個至少被引用了一次,所以都不能夠被回收。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 引用 o2
  o2.p = o1; // o2 引用 o1\. 造成循環引用
}

f();

標記清除算法

爲了決定一個對象是否被須要,這個算法用於肯定是否能夠找到某個對象。

這個算法包含如下步驟。

  1. 垃圾回收器生成一個根列表。根一般是將引用保存在代碼中的全局變量。在JavaScript中,window對象是一個能夠做爲根的全局變量。
  2. 全部的根都被檢查和標記成活躍的(不是垃圾),全部的子變量也被遞歸檢查。全部可能從根元素到達的都不被認爲是垃圾。
  3. 全部沒有被標記成活躍的內存都被認爲是垃圾。垃圾回收器就能夠釋放內存而且把內存還給操做系統。

上圖就是標記清除示意。

這個算法就比以前的(引用計算)要好些,由於「一個對象沒有被引用」致使這個對象不能被訪問。相反,正如咱們在循環引用的示例中看到的,對象不能被訪問到,不必定不存在引用。

2012年起,全部瀏覽器都內置了標記清除垃圾回收器。在過去幾年中,JavaScript垃圾回收領域中的全部改進(代/增量/並行/並行垃圾收集)都是由這個算法(標記清除法)改進實現的,但並非對垃圾收集算法自己的改進,也沒有改變它肯定對象是否可達這個目標。

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

循環引用再也不是問題

在上面的例子中(循環引用的那個),在函數執行完以後,這個2個對象沒有被任何能夠到達的全局對象所引用。所以,他們將會被垃圾回收器發現爲不可到達的。

儘管在這兩個對象之間有相互引用,可是他們不能從全局對象上到達。

垃圾回收器的反常行爲

儘管垃圾回收器很方便,可是他們有一套本身的方案。其中之一就是不肯定性。換句話說,GC是不可預測的。你不可能知道一個回收器何時會被執行。這意味着程序在某些狀況下會使用比實際需求還要多的內存。在其餘狀況下,在特別敏感的應用程序中,可能會出現短停頓。儘管不肯定意味着不能肯定回收工做什麼時候執行,但大多數GC實現都會在分配內存的期間啓動收集例程。若是沒有內存分配,大部分垃圾回收就保持空閒。參考下面的狀況。

  1. 執行至關大的一組分配。
  2. 這些元素中的大部分(或者全部的)都被標記爲不可到達的(假設咱們清空了一個指向咱們再也不須要的緩存的引用。)
  3. 沒有更多的分配被執行。

在這種狀況下,大多數垃圾回收實現都不會作進一步的回收。換句話說,儘管這裏有不可達的引用變量可供回收,回收器也不會管。嚴格講,這不是泄露,但結果卻會佔用比一般狀況下更多的內存。

什麼是內存泄漏

內存泄漏基本上就是再也不被應用須要的內存,因爲某種緣由,沒有被歸還給操做系統或者進入可用內存池。

編程語言喜歡不一樣的管理內存方式。然而,一段肯定的內存是否被使用是一個不可判斷的問題。換句話說,只有開發者才能弄清楚,是否一段內存能夠被還給操做系統。

某些編程語言爲開發者提供了釋放內存功能。另外一些則期待開發者清楚的知道一段內存何時是沒用的。Wikipedia有一篇很是好的關於內存管理的文章。

4種常見的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做爲函數調用,this指向全局變量(window)
// 而不是undefined
foo();

爲了防止這些問題發生,能夠在你的JaveScript文件開頭使用'use strict';。這個可使用一種嚴格的模式解析JavaScript來阻止意外的全局變量。

除了意外建立的全局變量,明確建立的全局變量一樣也不少。這些固然屬於不能被回收的(除非被指定爲null或者從新分配)。特別那些用於暫時存儲數據的全局變量,是很是重要的。若是你必需要使用全局變量來存儲大量數據,確保在是使用完成以後爲其賦值null或者從新賦其餘值。

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

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

大多數庫,特別是提供觀察器或其餘接收回調的實用函數的,都會在本身的實例沒法訪問前把這些回調也設置爲沒法訪問。但涉及setInterval時,下面這樣的代碼十分常見:

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

定時器可能會致使對不須要的節點或者數據的引用。

renderer對象在未來有可能被移除,讓interval處理器內部的整個塊都變得沒有用。但因爲interval仍然起做用,處理程序並不能被回收(除非interval中止)。若是interval不能被回收,它的依賴也不可能被回收。這就意味着serverData,大概保存了大量的數據,也不可能被回收。

在觀察者的狀況下,在他們再也不被須要(或相關對象須要設置成不能到達)的時候明確的調用移除是很是重要的。

在過去,這一點尤爲重要,由於某些瀏覽器(舊的IE6)不能很好的管理循環引用(更多信息見下文)。現在,大部分的瀏覽器都能並且會在對象變得不可到達的時候回收觀察處理器,即便監聽器沒有被明確的移除掉。然而,在對象被處理以前,要顯式地刪除這些觀察者仍然是值得提倡的作法。例如:

var element = document.getElementById('launch-button');
var counter = 0;

function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}

element.addEventListener('click', onClick);

// 作點事

element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);

// 當元素被銷燬
//元素和事件都會即便在老的瀏覽器裏也會被回收

現在的瀏覽器(包括IE和Edge)使用現代的垃圾回收算法,能夠當即發現並處理這些循環引用。換句話說,先調用removeEventListener再刪節點並不是嚴格必要。

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

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)的對象。同時,變量unused保持了一個引用originalThing(theThing是上次調用replaceThing生成的值)的閉包。已經有點困惑了吧?最重要的事情是一旦爲同一父域中的做用域產生閉包,則該做用域是共享的。

這裏,做用域產生了閉包,someMethodunused共享這個閉包中的內存。unused引用了originalThing。儘管unused不會被使用,someMethod能夠經過theThing來使用replaceThing做用域外的變量(例如某些全局的)。並且someMethodunused有共同的閉包做用域,unusedoriginalThing的引用強制oriiginalThing保持激活狀態(兩個閉包共享整個做用域)。這阻止了它的回收。

當這段代碼重複執行,能夠觀察到被使用的內存在持續增長。垃圾回收運行的時候也不會變小。從本質上來講,閉包的鏈接列表已經建立了(以theThing變量爲根),這些閉包每一個做用域都間接引用了大數組,致使大量的內存泄漏。

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

4: DOM外引用

有的時候在數據結構裏存儲DOM節點是很是有用的,好比你想要快速更新一個表格幾行的內容。此時存儲每一行的DOM節點的引用在一個字典或者數組裏是有意義的。此時一個DOM節點有兩個引用:一個在dom樹中,另一個在字典中。若是在將來的某個時候你想要去移除這些排,你須要確保兩個引用都不可到達。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};

function doStuff() {
    image.src = 'http://example.com/image_name.png';
}

function removeImage() {
    //image是body元素的子節點
    document.body.removeChild(document.getElementById('image'));

    //這個時候咱們在全局的elements對象裏仍然有一個對#button的引用。
    //換句話說,buttom元素仍然在內存中並且不能被回收。
}

當涉及DOM樹內部或子節點時,須要考慮額外的考慮因素。例如,你在JavaScript中保持對某個表的特定單元格的引用。有一天你決定從DOM中移除表格可是保留了對單元格的引用。人們也許會認爲除了單元格其餘的都會被回收。實際並非這樣的:單元格是表格的一個子節點,子節點保持了對父節點的引用。確切的說,JS代碼中對單元格的引用形成了整個表格被留在內存中了,因此在移除有被引用的節點時候要小心。

咱們在SessionStack努力遵循這些最佳實踐,由於:

一旦你整合essionStack到你的生產應用中,它就開始記錄全部的事情:DOM變化、用戶交互、JS異常、堆棧跟蹤、失敗的網絡請求、調試信息,等等。

經過SessionStack,你能夠回放應用中的問題,看到問題對用戶的影響。全部這些都不會對你的應用產生性能的影響。由於用戶能夠從新加載頁面或者在應用中跳轉,全部的觀察者、攔截器、變量分配都必須合理處置。以避免形成內存泄漏,也預防增長整個應用的內存佔用。

這是一個免費的計劃,你如今能夠嘗試一下。


歡迎關注個人公衆號,關注前端文章:

justjavac微信公衆號

參考資料

相關文章
相關標籤/搜索