「中高級前端必須瞭解的」JS中的內存管理

前言

像C語言這樣的底層語言通常都有底層的內存管理接口,好比 malloc()和free()用於分配內存和釋放內存。
而對於JavaScript來講,會在建立變量(對象,字符串等)時分配內存,而且在再也不使用它們時「自動」釋放內存,這個自動釋放內存的過程稱爲垃圾回收。
由於自動垃圾回收機制的存在,讓大多Javascript開發者感受他們能夠不關心內存管理,因此會在一些狀況下致使內存泄漏。javascript

內存生命週期

JS 環境中分配的內存有以下聲明週期:html

  1. 內存分配:當咱們申明變量、函數、對象的時候,系統會自動爲他們分配內存
  2. 內存使用:即讀寫內存,也就是使用變量、函數等
  3. 內存回收:使用完畢,由垃圾回收機制自動回收再也不使用的內存

JS 的內存分配

爲了避免讓程序員費心分配內存,JavaScript 在定義變量時就完成了內存分配。前端

var n = 123; // 給數值變量分配內存
var s = "azerty"; // 給字符串分配內存

var o = {
  a: 1,
  b: null
}; // 給對象及其包含的值分配內存

// 給數組及其包含的值分配內存(就像對象同樣)
var a = [1, null, "abra"]; 

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

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

有些函數調用結果是分配對象內存:java

var d = new Date(); // 分配一個 Date 對象

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

有些方法分配新變量或者新對象:git

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一個新的字符串
// 由於字符串是不變量,
// JavaScript 可能決定不分配內存,
// 只是存儲了 [0-3] 的範圍。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新數組有四個元素,是 a 鏈接 a2 的結果

JS 的內存使用

使用值的過程其實是對分配內存進行讀取與寫入的操做。
讀取與寫入多是寫入一個變量或者一個對象的屬性值,甚至傳遞函數的參數。程序員

var a = 10; // 分配內存
console.log(a); // 對內存的使用

JS 的內存回收

JS 有自動垃圾回收機制,那麼這個自動垃圾回收機制的原理是什麼呢?
其實很簡單,就是找出那些再也不繼續使用的值,而後釋放其佔用的內存。github

大多數內存管理的問題都在這個階段。
在這裏最艱難的任務是找到再也不須要使用的變量。算法

再也不須要使用的變量也就是生命週期結束的變量,是局部變量,局部變量只在函數的執行過程當中存在,
當函數運行結束,沒有其餘引用(閉包),那麼該變量會被標記回收。數組

全局變量的生命週期直至瀏覽器卸載頁面纔會結束,也就是說全局變量不會被當成垃圾回收。瀏覽器

由於自動垃圾回收機制的存在,開發人員能夠不關心也不注意內存釋放的有關問題,但對無用內存的釋放這件事是客觀存在的。
不幸的是,即便不考慮垃圾回收對性能的影響,目前最新的垃圾回收算法,也沒法智能回收全部的極端狀況。

接下來咱們來探究一下 JS 垃圾回收的機制。

垃圾回收

引用

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

在內存管理的環境中,一個對象若是有訪問另外一個對象的權限(隱式或者顯式),叫作一個對象引用另外一個對象。

例如,一個Javascript對象具備對它原型的引用(隱式引用)和對它屬性的引用(顯式引用)。

在這裏,「對象」的概念不只特指 JavaScript 對象,還包括函數做用域(或者全局詞法做用域)。

引用計數垃圾收集

這是最初級的垃圾回收算法。

引用計數算法定義「內存再也不使用」的標準很簡單,就是看一個對象是否有指向它的引用。
若是沒有其餘對象指向它了,說明該對象已經再也不需了。

var o = { 
  a: {
    b:2
  }
}; 
// 兩個對象被建立,一個做爲另外一個的屬性被引用,另外一個被分配給變量o
// 很顯然,沒有一個能夠被垃圾收集


var o2 = o; // o2變量是第二個對「這個對象」的引用

o = 1;      // 如今,「這個對象」的原始引用o被o2替換了

var oa = o2.a; // 引用「這個對象」的a屬性
// 如今,「這個對象」有兩個引用了,一個是o2,一個是oa

o2 = "yo"; // 最初的對象如今已是零引用了
           // 他能夠被垃圾回收了
           // 然而它的屬性a的對象還在被oa引用,因此還不能回收

oa = null; // a屬性的那個對象如今也是零引用了
           // 它能夠被垃圾回收了

由上面能夠看出,引用計數算法是個簡單有效的算法。但它卻存在一個致命的問題:循環引用。

若是兩個對象相互引用,儘管他們已再也不使用,垃圾回收不會進行回收,致使內存泄露。

來看一個循環引用的例子:

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o  這裏

  return "azerty";
}

f();

上面咱們申明瞭一個函數 f ,其中包含兩個相互引用的對象。
在調用函數結束後,對象 o1 和 o2 實際上已離開函數範圍,所以再也不須要了。
但根據引用計數的原則,他們之間的相互引用依然存在,所以這部份內存不會被回收,內存泄露不可避免了。

再來看一個實際的例子:

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
};

上面這種JS寫法再普通不過了,建立一個DOM元素並綁定一個點擊事件。
此時變量 div 有事件處理函數的引用,同時事件處理函數也有div的引用!(div變量可在函數內被訪問)。
一個循序引用出現了,按上面所講的算法,該部份內存無可避免的泄露了。

爲了解決循環引用形成的問題,現代瀏覽器經過使用標記清除算法來實現垃圾回收。

標記清除算法

標記清除算法將「再也不使用的對象」定義爲「沒法達到的對象」。
簡單來講,就是從根部(在JS中就是全局對象)出發定時掃描內存中的對象。
凡是能從根部到達的對象,都是還須要使用的。
那些沒法由根部出發觸及到的對象被標記爲再也不使用,稍後進行回收。

從這個概念能夠看出,沒法觸及的對象包含了沒有引用的對象這個概念(沒有任何引用的對象也是沒法觸及的對象)。
但反之未必成立。

工做流程:

  1. 垃圾收集器會在運行的時候會給存儲在內存中的全部變量都加上標記。
  2. 從根部出發將能觸及到的對象的標記清除。
  3. 那些還存在標記的變量被視爲準備刪除的變量。
  4. 最後垃圾收集器會執行最後一步內存清除的工做,銷燬那些帶標記的值並回收它們所佔用的內存空間。

循環引用再也不是問題了

再看以前循環引用的例子:

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}

f();

函數調用返回以後,兩個循環引用的對象在垃圾收集時從全局對象出發沒法再獲取他們的引用。
所以,他們將會被垃圾回收器回收。

內存泄漏

什麼是內存泄漏

程序的運行須要內存。只要程序提出要求,操做系統或者運行時(runtime)就必須供給內存。

對於持續運行的服務進程(daemon),必須及時釋放再也不用到的內存。
不然,內存佔用愈來愈高,輕則影響系統性能,重則致使進程崩潰。

本質上講,內存泄漏就是因爲疏忽或錯誤形成程序未能釋放那些已經再也不使用的內存,形成內存的浪費。

內存泄漏的識別方法

經驗法則是,若是連續五次垃圾回收以後,內存佔用一次比一次大,就有內存泄漏。
這就要求實時查看內存的佔用狀況。

在 Chrome 瀏覽器中,咱們能夠這樣查看內存佔用狀況

  1. 打開開發者工具,選擇 Performance 面板
  2. 在頂部勾選 Memory
  3. 點擊左上角的 record 按鈕
  4. 在頁面上進行各類操做,模擬用戶的使用狀況
  5. 一段時間後,點擊對話框的 stop 按鈕,面板上就會顯示這段時間的內存佔用狀況

來看一張效果圖:

咱們有兩種方式來斷定當前是否有內存泄漏:

  1. 屢次快照後,比較每次快照中內存的佔用狀況,若是呈上升趨勢,那麼能夠認爲存在內存泄漏
  2. 某次快照後,看當前內存佔用的趨勢圖,若是走勢不平穩,呈上升趨勢,那麼能夠認爲存在內存泄漏

在服務器環境中使用 Node 提供的 process.memoryUsage 方法查看內存狀況

console.log(process.memoryUsage());
// { 
//     rss: 27709440,
//     heapTotal: 5685248,
//     heapUsed: 3449392,
//     external: 8772 
// }

process.memoryUsage返回一個對象,包含了 Node 進程的內存佔用信息。

該對象包含四個字段,單位是字節,含義以下:

  • rss(resident set size):全部內存佔用,包括指令區和堆棧。
  • heapTotal:"堆"佔用的內存,包括用到的和沒用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎內部的 C++ 對象佔用的內存。

判斷內存泄漏,以heapUsed字段爲準。

常見的內存泄露案例

意外的全局變量

function foo() {
    bar1 = 'some text'; // 沒有聲明變量 其實是全局變量 => window.bar1
    this.bar2 = 'some text' // 全局變量 => window.bar2
}
foo();

在這個例子中,意外的建立了兩個全局變量 bar1 和 bar2

被遺忘的定時器和回調函數

在不少庫中, 若是使用了觀察者模式, 都會提供回調方法, 來調用一些回調函數。
要記得回收這些回調函數。舉一個 setInterval的例子:

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

若是後續 renderer 元素被移除,整個定時器實際上沒有任何做用。
但若是你沒有回收定時器,整個定時器依然有效, 不但定時器沒法被內存回收,
定時器函數中的依賴也沒法回收。在這個案例中的 serverData 也沒法被回收。

閉包

在 JS 開發中,咱們會常常用到閉包,一個內部函數,有權訪問包含其的外部函數中的變量。
下面這種狀況下,閉包也會形成內存泄露:

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 的閉包。

這個範例的關鍵在於,閉包之間是共享做用域的,儘管 unused 可能一直沒有被調用,可是 someMethod 可能會被調用,就會致使沒法對其內存進行回收。
當這段代碼被反覆執行時,內存會持續增加。

DOM 引用

不少時候, 咱們對 Dom 的操做, 會把 Dom 的引用保存在一個數組或者 Map 中。

var elements = {
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    document.body.removeChild(document.getElementById('image'));
    // 這個時候咱們對於 #image 仍然有一個引用, Image 元素, 仍然沒法被內存回收.
}

上述案例中,即便咱們對於 image 元素進行了移除,可是仍然有對 image 元素的引用,依然沒法對齊進行內存回收。

另外須要注意的一個點是,對於一個 Dom 樹的葉子節點的引用。
舉個例子: 若是咱們引用了一個表格中的td元素,一旦在 Dom 中刪除了整個表格,咱們直觀的以爲內存回收應該回收除了被引用的 td 外的其餘元素。
可是事實上,這個 td 元素是整個表格的一個子元素,並保留對於其父元素的引用。
這就會致使對於整個表格,都沒法進行內存回收。因此咱們要當心處理對於 Dom 元素的引用。

如何避免內存泄漏

記住一個原則:不用的東西,及時歸還。

  1. 減小沒必要要的全局變量,使用嚴格模式避免意外建立全局變量。
  2. 在你使用完數據後,及時解除引用(閉包中的變量,dom引用,定時器清除)。
  3. 組織好你的邏輯,避免死循環等形成瀏覽器卡頓,崩潰的問題。

參考

寫在最後

  • 文中若有錯誤,歡迎在評論區指正,若是這篇文章幫到了你,歡迎點贊關注
  • 本文同步首發與github,可在github中找到更多精品文章,歡迎Watch & Star ★

歡迎關注微信公衆號【前端小黑屋】,每週1-3篇精品優質文章推送,助你走上進階之旅

相關文章
相關標籤/搜索