【知識點】JS中的記憶體管理

2020-08-12 16:58:24

【知識點】JS中的記憶體管理

前言

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

 

目錄

  • 記憶體生命週期
  • 垃圾回收
  • 記憶體漏失

 

記憶體生命週期

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

回圈參照不再是問題了

再看之前回圈參照的例子:

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 按鈕,面板上就會顯示這段時間的記憶體佔用情況
  6. 來看一張效果圖:

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

  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. 組織好你的邏輯,避免死回圈等造成瀏覽器卡頓,崩潰的問題。

小編是個多年開發經驗的程式設計師。如果你想要學好WEB前端,在學習過程中,身邊沒有一個能夠指導你學習的人,可以到這個WEB前端裙,裏面最新學習路線和教學,不管是計算機專業想要往WEB前端方向發展,還是零基礎想轉行,都可以跟着教學學,有什麼不懂的可以在裏面問,這就是WEB前端裙。前面三個輸入112,中間三個輸入666,後面三個輸入2127