[譯] JavaScript是如何工作的:記憶體管理 + 如何處理4個常見的記憶體洩漏(譯)

王興欣發表於2017-09-26

前言:這篇文章的主要內容由翻譯而來,原文連結。但是大體內容與原文不盡相同,刪除了一些內容,同時新增部分內容。由於本文大部分內容是翻譯而來,若有理解不當之處還望諒解並指出,我會盡快進行修改。(內心:如果有什麼不對的地方還希望大家指出,反正我也不會改 。玩笑話玩笑話 別當真!)

概述

在一些語言中,開發人員需要手動的使用原生語句來顯示的分配和釋放記憶體。但是在許多高階語言中,這些過程都會被自動的執行。在JavaScript中,變數(物件,字串,等等)建立的時候為其分配記憶體,當不再被使用的時候會“自動地”釋放這些記憶體,這個過程被稱為垃圾回收。這個看似“自動的”釋放資源的本質是一個混亂的來源,給JavaScript(和其他高等級語言)開發者可以不去關心記憶體管理的錯誤印象。這是一個很大的錯誤

記憶體洩漏

記憶體洩漏(Memory Leak)是指程式中己動態分配的堆記憶體由於某種原因程式未釋放或無法釋放,造成系統記憶體的浪費,導致程式執行速度減慢甚至系統崩潰等嚴重後果。

記憶體洩漏缺陷具有隱蔽性、積累性的特徵,比其他記憶體非法訪問錯誤更難檢測。因為記憶體洩漏的產生原因是記憶體塊未被釋放,屬於遺漏型缺陷而不是過錯型缺陷。此外,記憶體洩漏通常不會直接產生可觀察的錯誤症狀,而是逐漸積累,降低系統整體效能,極端的情況下可能使系統崩潰。

記憶體生命週期

無論使用哪一種程式語言,記憶體的生命週期幾乎總是一模一樣的 分配記憶體、使用記憶體、釋放記憶體。 在這裡我們主要討論記憶體的回收。

引用計數垃圾回收

這是最簡單的垃圾回收演算法。一個物件在沒有被其他的引用指向的時候就被認為“可回收的”。

[譯] JavaScript是如何工作的:記憶體管理 + 如何處理4個常見的記憶體洩漏(譯)

對JS引用型別不熟悉的請先百度引用型別,理解了值型別(基本型別)和引用型別之後才能理解下面的程式碼

var obj1 = {
  obj2: {
    x: 1
  }
};
//2個物件被建立。 obj2被obj1引用,並且作為obj1的屬性存在。這裡並沒有可以被回收的。
//obj1和obj2都指向了{obj2: {x: 1}}這個物件,這個示例中用`原來的物件`來表示這個物件。

var obj3 = obj1;  //obj3也引用了obj1指向的物件。
obj1 = 1; // obj1不引用原來的物件了。此時原來的物件只有obj3在引用。

var obj4 = obj3.obj2; //obj4引用了obj3物件的obj2屬性,
//此時obj2物件有2個引用,一個是作為obj3的一個屬性,一個是作為obj4變數。

obj3 = 1;
// 咦,obj1原來物件只有obj3在引用,現在obj3也沒用在引用了。
// obj1原來的物件就淪為了一隻單身狗,於是乎抓狗大隊就來帶走了它。(好吧、其實記憶體就可以被回收了)。
// 然而  obj2物件依然有人愛(被obj4引用)。所以obj2的記憶體就不會被垃圾回收。

obj4 = null;
// obj2內心在吶喊:小姐姐不要離開我 QOQ。現在obj2也沒有被引用了,引用計數就是0
也就是可以被回收了。
複製程式碼

簡而言之~,如果記憶體有人愛,那就不會被回收。如果是單身狗的話,[手動滑稽]。

[譯] JavaScript是如何工作的:記憶體管理 + 如何處理4個常見的記憶體洩漏(譯)

迴圈引用會造成麻煩

引用計數在涉及迴圈引用的時候有一個缺陷。在下面的例子中,建立了2個物件,並且相互引用,這樣建立了一個迴圈。因此他們實際上是無用的,可以被釋放。然而引用計數演算法考慮到2個物件中的每一個至少被引用了一次,因此都不可以被回收。

[譯] JavaScript是如何工作的:記憶體管理 + 如何處理4個常見的記憶體洩漏(譯)

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2;
  o2.p = o1;
}

f();
複製程式碼

單身狗心裡千萬頭草泥馬在奔騰(我特麼也會自己牽自己手啊,我也會假裝情侶拍照啊)

[譯] JavaScript是如何工作的:記憶體管理 + 如何處理4個常見的記憶體洩漏(譯)

標記清除演算法

別以為你假裝不是單身狗就拿你沒辦法了,這個演算法確定了物件是否可以被達到。 這個演算法包含了以下步驟:

  1. 從‘根’上生成一個列表(通常是以全域性變數為根)。在JS中window物件可以作為一個'根'
  2. 所有的'根'都被標記為活躍的,所有的子變數也被遞迴檢查。能夠從'根'上到達的都不會被認為成垃圾。
  3. 沒有被標記為活躍的就被認為成垃圾。這些記憶體就會被釋放。

[譯] JavaScript是如何工作的:記憶體管理 + 如何處理4個常見的記憶體洩漏(譯)
上圖就是標記清除的動作。

在之前的例子中,雖然兩個變數相互引用,但在函式執行完之後,這個兩個變數都沒有被window物件上的任何物件所引用。因此,他們會被認為不可到達的。

[譯] JavaScript是如何工作的:記憶體管理 + 如何處理4個常見的記憶體洩漏(譯)

4種常見的JS記憶體洩漏

1:全域性變數 JavaScript用一個有趣的方式管理未被宣告的變數:對未宣告的變數的引用在全域性物件裡建立一個新的變數。在瀏覽器的情況下,這個全域性物件是window。換句話說:

function foo(arg) {
  bar = 'some text';
}
//等同於
function foo(arg) {
  window.bar = 'some text';
}

複製程式碼

如果bar被假定只在foo函式的作用域裡引用,但是你忘記了使用var去宣告它,一個意外的全域性變數就被宣告瞭。 在這個例子裡,洩漏一個簡單的字元並不會造成很大的傷害,但是它確實有可能變得更糟。 有時有會通過this來建立意外的全域性變數。

為了防止這些問題發生,可以在你的JaveScript檔案開頭使用'use strict';。這個可以使用一種嚴格的模式解析JavaScript來阻止意外的全域性變數。

如果有時全域性變數被用於暫時儲存大量的資料或者涉及到的資訊,那麼在使用完之後應該指定為null或者重新分配

2:被遺忘的定時器或者回撥 還是來個栗子吧,定時器可能會產生對不再需要的DOM節點或者資料的引用。

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

renderer物件在將來有可能被移除,讓interval沒有存在的意義。然而當處理器interval仍然起作用時,renderer並不能被回收(interval在物件被移除時需要被停止),如果interval不能被回收,它的依賴也不可能被回收。這就意味著serverData,大概儲存了大量的資料,也不可能被回收。 如今,大部分的瀏覽器都能而且會在物件變得不可到達的時候回收觀察處理器,甚至監聽器沒有被明確的移除掉。在物件被處理之前,最好也要顯式地刪除這些觀察者。

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。 框架和外掛例如jQuqery在處理節點(當使用具體的api的時候)之前會移除監聽器。這個是外掛內部的處理可以確保不會產生記憶體洩漏,甚至執行在有問題的瀏覽器上(哈哈哈 說的就是IE6)。

3: 閉包 閉包是javascript開發的一個關鍵方面,一個內部函式使用了外部(封閉)函式的變數。由於JavaScript執行的細節,它可能以下面的方式造成記憶體洩漏:

var theThing = null;

var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) console.log('hi')  //引用了originalThing
  };
  
  theThing = {
    longStr: new Array(1000000).jojin('*'),
    someMethod: function (){
      console.log('message');  
    }
  };
};

setInterval(replaceThing,1000);
複製程式碼

這些程式碼做了一件事情,每次relaceThing被呼叫,theThing獲得一個包含大量資料和新的閉包(someMethod)的物件。同時,變數unused引用了originalThingtheThing是上一次函式被呼叫時產生的)。已經有點困惑了吧?最重要的事情是一旦為同一父域中的作用域產生閉包,則該作用域是共享的。

在這個案例中,someMethodunused共享閉包作用域,unused引用了originalThing,這阻止了originalThing的回收,儘管unused不會被使用,但是someMethod依然可以通過theThing來訪問replaceThing作用域外的變數(例如某些全域性的)。

4:來自DOM的引用 在你要重複的操作DOM節點的時候,儲存DOM節點是十分有用的。但是在你需要移除DOM節點的時候,需要確保移除DOM tree和程式碼中儲存的引用。

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

//Do some stuff

document.body.removeChild(document.getElementById('image'));
//這個時候  雖然從dom tree中移除了id為image的節點,但是還保留了一個對該節點的引用。於是image仍然不能被回收。
複製程式碼

當涉及到DOM樹內部或子節點時,需要考慮額外的考慮因素。例如,你在JavaScript中保持對某個表格的特定單元格的引用。有一天你決定從DOM中移除表格但是保留了對單元格的引用。你也許會認為除了單元格其他的都會被回收。實際並不是這樣的:單元格是表格的一個子節點,子節點保持了對父節點的引用。確切的說,JS程式碼中對單元格的引用造成了整個表格被留在記憶體中了,所以在移除有被引用的節點時候要移除其子節點。

總結

  1. 小心使用全域性變數,儘量不要使用全域性變數來儲存大量資料,如果是暫時使用,要在使用完成之後手動指定為null或者重新分配
  2. 如果使用了定時器,在無用的時候要記得清除。如果為DOM節點繫結了事件監聽器,在移除節點時要先登出事件監聽器。
  3. 小心閉包的使用。如果掌握不好,至少在使用大量資料的時候仔細考量。在使用遞迴的時候也要非常小心(例如用canvas做小遊戲)。
  4. 在移除DOM節點的時候要確保在程式碼中沒有對節點的引用,這樣才能完全的移除節點。在移除父節點之前要先移除子節點。

相關文章