「前端進階」JS中的記憶體管理

雲中橋發表於2019-06-17

你知道的越多,你不知道的越多
點贊再看,手留餘香,與有榮焉

前言

像C語言這樣的底層語言一般都有底層的記憶體管理介面,比如 malloc()和free()用於分配記憶體和釋放記憶體。 而對於JavaScript來說,會在建立變數(物件,字串等)時分配記憶體,並且在不再使用它們時“自動”釋放記憶體,這個自動釋放記憶體的過程稱為垃圾回收。 因為自動垃圾回收機制的存在,讓大多Javascript開發者感覺他們可以不關心記憶體管理,所以會在一些情況下導致記憶體洩漏。

記憶體生命週期

「前端進階」JS中的記憶體管理

JS 環境中分配的記憶體有如下宣告週期:

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

有些函式呼叫結果是分配物件記憶體:

var d = new Date(); // 分配一個 Date 物件

var e = document.createElement('div'); // 分配一個 DOM 元素
複製程式碼

有些方法分配新變數或者新物件:

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 有自動垃圾回收機制,那麼這個自動垃圾回收機制的原理是什麼呢? 其實很簡單,就是找出那些不再繼續使用的值,然後釋放其佔用的記憶體。

大多數記憶體管理的問題都在這個階段。 在這裡最艱難的任務是找到不再需要使用的變數。

不再需要使用的變數也就是生命週期結束的變數,是區域性變數,區域性變數只在函式的執行過程中存在, 當函式執行結束,沒有其他引用(閉包),那麼該變數會被標記回收。

全域性變數的生命週期直至瀏覽器解除安裝頁面才會結束,也就是說全域性變數不會被當成垃圾回收。

因為自動垃圾回收機制的存在,開發人員可以不關心也不注意記憶體釋放的有關問題,但對無用記憶體的釋放這件事是客觀存在的。 不幸的是,即使不考慮垃圾回收對效能的影響,目前最新的垃圾回收演算法,也無法智慧回收所有的極端情況。

接下來我們來探究一下 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. 最後垃圾收集器會執行最後一步記憶體清除的工作,銷燬那些帶標記的值並回收它們所佔用的記憶體空間。

「前端進階」JS中的記憶體管理

迴圈引用不再是問題了

再看之前迴圈引用的例子:

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 按鈕,皮膚上就會顯示這段時間的記憶體佔用情況

來看一張效果圖:

「前端進階」JS中的記憶體管理

我們有兩種方式來判定當前是否有記憶體洩漏:

  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篇精品優質文章推送,助你走上進階之旅

「前端進階」JS中的記憶體管理

相關文章