【重溫基礎】22.記憶體管理

pingan8787發表於2019-03-01

本文是 重溫基礎 系列文章的第二十二篇。
今日感受:優化學習方法。

系列目錄:

本章節複習的是JS中的記憶體管理,這對於我們開發非常有幫助。

前置知識
絕大多數的程式語言,他們的記憶體生命週期基本一致:

memory lifecycle

  1. 分配所需使用的記憶體 ——(分配記憶體
  2. 使用分配到的記憶體(讀、寫) ——(使用記憶體
  3. 不需要時將其釋放\歸還 ——(釋放記憶體

對於所有的程式語言,第二部分都是明確的。而第一和第三部分在底層語言中是明確的。
但在像JavaScript這些高階語言中,大部分都是隱含的,因為JavaScript具有自動垃圾回收機制(Garbage collected)。
因此在做JavaScript開發時,不需要關心記憶體的使用問題,所需記憶體分配和無用記憶體回收,都完全實現自動管理。

1.概述

像C語言這樣的高階語言一般都有底層的記憶體管理介面,比如 malloc()free()。另一方面,JavaScript建立變數(物件,字串等)時分配記憶體,並且在不再使用它們時“自動”釋放。 後一個過程稱為垃圾回收。這個“自動”是混亂的根源,並讓JavaScript(和其他高階語言)開發者感覺他們可以不關心記憶體管理。 這是錯誤的。 ——《MDN JavaScript 記憶體管理》

MDN中的介紹告訴我們,作為JavaScript開發者,還是需要去了解記憶體管理,雖然JavaScript已經給我們做好自動管理。

2.JavaScript記憶體生命週期

2.1 分配記憶體

在做JavaScript開發時,我們定義變數的時候,JavaScript便為我們完成了記憶體分配:

var num = 100;      // 為數值變數分配記憶體
var str = 'pingan'; // 為字串變數分配記憶體
var obj = {
    name : 'pingan'
};                  // 為物件變數及其包含的值分配記憶體
var arr = [1, null, 'hi']; // 為陣列變數及其包含的值分配記憶體

function fun(num){
  return num + 2;
};                  // 為函式(可呼叫的物件)分配記憶體

// 函式表示式也能分配一個物件
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);
複製程式碼

另外,通過呼叫函式,也會分配記憶體:

// 型別1. 分配物件記憶體
var date = new Date();   // 分配一個Date物件
var elem = document.createElement('div'); // 分配一個DOM元素

// 型別2. 分配新變數或者新物件
var str1 = "pingan";
var str2 = str1.substr(0, 3); // str2 是一個新的字串

var arr1 = ["hi", "pingan"];
var arr2 = ["hi", "leo"];
var arr3 = arr1.concat(arr2); // arr3 是一個新的陣列(arr1和arr2連線的結果)
複製程式碼

2.2 使用記憶體

使用記憶體的過程實際上是對分配的記憶體進行讀取與寫入的操作。
通常表現就是使用定義的值。
讀取與寫入可能是寫入一個變數或者一個物件的屬性值,甚至傳遞函式的引數。

var num = 1;
num ++; // 使用已經定義的變數,做遞增操作
複製程式碼

2.3 釋放記憶體

當我們前面定義好的變數或函式(分配的記憶體)已經不需要使用的時候,便需要釋放掉這些記憶體。這也是記憶體管理中最難的任務,因為我們不知道什麼時候這些記憶體不使用。
很好的是,在高階語言直譯器中,已經嵌入“垃圾回收器”,用來跟蹤記憶體的分配和使用,以便在記憶體不使用時自動釋放(這並不是百分百跟蹤到,只是個近似過程)。

3.垃圾回收機制

就像前面提到的,“垃圾回收器”只能解決一般情況,接下來我們需要了解主要的垃圾回收演算法和它們侷限性。

3.1 引用

垃圾回收演算法主要依賴於引用的概念。
即在記憶體管理環境中,一個物件如果有許可權訪問另一個物件,不論顯式還是隱式,稱為一個物件引用另一個物件。
例如:一個JS物件具有對它原型的引用(隱式引用)和對它屬性的引用(顯式引用)。 注意:
這裡的物件,不僅包含JS物件,也包含函式作用域(或全域性詞法作用域)。

3.2 引用計數垃圾收集

這個演算法,把“物件是否不再需要”定義為:當一個物件沒有被其他物件所引用的時候,回收該物件。這是最初級的垃圾收集演算法。

var obj = {
    leo : {
        age : 18
    };
};
複製程式碼

這裡建立2個物件,一個作為leo的屬性被引用,另一個被分配給變數obj

// 省略上面的程式碼
/*
我們將前面的
    {
        leo : {
            age : 18
        };
    };
稱為“這個物件”
*/
var obj2 = obj;  // obj2變數是第二個對“這個物件”的引用
obj = 'pingan';  // 將“這個物件”的原始是引用obj換成obj2

var leo2 = obj2.leo;  // 引用“這個物件”的leo屬性
複製程式碼

可以看出,現在的“這個物件”已經有2個引用,一個是obj2,另一個是leo2

obj2 = 'hi'; 
// 將obj2變成零引用,因此,obj2可以被垃圾回收
// 但是它的屬性leo還在被leo2物件引用,所以還不能回收

leo2 = null;
// 將leo變成零引用,這樣obj2和leo2都可以被垃圾回收
複製程式碼

這個演算法有個限制
無法處理迴圈引用。即兩個物件建立時相互引用形成一個迴圈。

function fun(){
    var obj1 = {}, obj2 = {};
    obj1.leo = obj2;  // obj1引用obj2
    obj2.leo = obj1;  // obj2引用obj1
    return 'hi pingan';
}
fun();
複製程式碼

Cyclic Reference

可以看出,它們被呼叫之後,會離開函式作用域,已經沒有用了可以被回收,然而引用計數演算法考慮到它們之間相互至少引用一次,所以它們不會被回收。

實際案例
在IE6,7中,使用引用計數方式對DOM物件進行垃圾回收,常常造成物件被迴圈引用導致記憶體洩露:

var obj;
window.onload = function(){
    obj = document.getElementById('myId');
    obj.leo = obj;
    obj.data = new Array(100000).join('');
};
複製程式碼

可以看出,DOM元素obj中的leo屬性引用了自己obj,造成迴圈引用,若該屬性(leo)沒有移除或設定為null,垃圾回收器總是且至少有一個引用,並一直佔用記憶體,即使從DOM樹刪除,如果這個DOM元素含大量資料(如data屬性)則會導致佔用記憶體永遠無法釋放,出現記憶體洩露。

3.3 標記清除演算法

這個演算法,將“物件是否不再需要”定義為:物件是否可以獲得。

Mark and Sweep

標記清除演算法,是假定設定一個根物件(root),在JS中是全域性物件。垃圾回收器定時找所有從根開始引用的物件,然後再找這些物件引用的物件...直到找到所有可以獲得的物件蒐集所有不能獲得的物件

它比引用計數垃圾收集更好,因為“有零引用的物件”總是不可獲得的,但是相反卻不一定,參考“迴圈引用”。

迴圈引用不再是問題:

function fun(){
    var obj1 = {}, obj2 = {};
    obj1.leo = obj2;  // obj1引用obj2
    obj2.leo = obj1;  // obj2引用obj1
    return 'hi pingan';
}
fun();
複製程式碼

還是這個程式碼,可以看出,使用標記清除演算法來看,函式呼叫之後,兩個物件無法從全域性物件獲取,因此將被回收。相同的,下面案例,一旦 obj 和其事件處理無法從根獲取到,他們將會被垃圾回收器回收。

var obj;
window.onload = function(){
    obj = document.getElementById('myId');
    obj.leo = obj;
    obj.data = new Array(100000).join('');
};
複製程式碼

注意: 那些無法從根物件查詢到的物件都將被清除。

3.4 個人小結

在日常開發中,應該注意及時切斷需要回收物件與根的聯絡,雖然標記清除演算法已經足夠強壯,就像下面程式碼:

var obj,ele=document.getElementById('myId');
obj.div = document.createElement('div');
ele.appendChild(obj.div);
// 刪除DOM元素
ele.removeChild(obj.div);
複製程式碼

如果我們只是做小型專案開發,JS用的比較少的話,記憶體管理可以不用太在意,但是如果是大專案(SPA,伺服器或桌面應用),那就需要考慮好記憶體管理問題了。

4.記憶體洩露(Memory Leak)

4.1 記憶體洩露概念

在電腦科學中,記憶體洩漏指由於疏忽或錯誤造成程式未能釋放已經不再使用的記憶體。記憶體洩漏並非指記憶體在物理上的消失,而是應用程式分配某段記憶體後,由於設計錯誤,導致在釋放該段記憶體之前就失去了對該段記憶體的控制,從而造成了記憶體的浪費。 ——維基百科

其實簡單理解:一些不再使用的記憶體無法被釋放
當記憶體佔用越來越多,不僅影響系統效能,嚴重的還會導致程式奔潰。

4.2 記憶體洩露案例

  1. 全域性變數

未定義的變數,會被定義到全域性,當頁面關閉才會銷燬,這樣就造成記憶體洩露。如下:

function fun(){
    name = 'pingan';
};
複製程式碼
  1. 未銷燬的定時器和回撥函式
    如果這裡舉一個定時器的案例,如果定時器沒有回收,則不僅整個定時器無法被記憶體回收,定時器函式的依賴也無法回收:
var data = {};
setInterval(function(){
    var render = document.getElementById('myId');
    if(render){
        render.innderHTML = JSON.stringify(data);
    }
}, 1000);
複製程式碼
  1. 閉包
var str = null;
var fun = function(){
    var str2 = str;
    var unused = function(){
        if(str2) console.log('is unused');
    };
    str = {
        my_str = new Array(100000).join('--');
        my_fun = function(){
            console.log('is my_fun');
        };
    };
};
setInterval(fun, 1000);
複製程式碼

定時器中每次呼叫funstr都會獲得一個包含巨大的陣列和一個對於新閉包my_fun的物件,並且unused是一個引用了str2的閉包。
整個案例中,閉包之間共享作用域,儘管unused可能一直沒有呼叫,但my_fun可能被呼叫,就會導致記憶體無法回收,記憶體增長導致洩露。

  1. DOM引用 當我們把DOM的引用儲存在一個陣列或Map中,即使移除了元素,但仍然有引用,導致無法回收記憶體。例如:
var ele = {
    img : document.getElementById('my_img')
};
function fun(){
    ele.img.src = "http://www.baidu.com/1.png";
};
function foo(){
    document.body.removeChild(document.getElementById('my_img'));
};
複製程式碼

即使foo方法將my_img元素移除,但fun仍有引用,無法回收。

4.3 記憶體洩露識別方法

  1. 瀏覽器

通過Chrome瀏覽器檢視記憶體佔用:

記憶體洩露識別方法1

步驟如下:

  • 開啟開發者工具,選擇 Timeline 皮膚
  • 在頂部的Capture欄位裡面勾選 Memory
  • 點選左上角的錄製按鈕
  • 在頁面上進行各種操作,模擬使用者的使用情況
  • 一段時間後,點選對話方塊的 **stop **按鈕,皮膚上就會顯示這段時間的記憶體佔用情況

如果記憶體佔用基本平穩,接近水平,就說明不存在記憶體洩漏。

記憶體洩露識別方法1

反之,就是記憶體洩漏了。

記憶體洩露識別方法1

  1. 命令列

命令列可以使用 Node 提供的process.memoryUsage方法。

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

process.memoryUsage返回一個物件,包含了 Node 程式的記憶體佔用資訊。該物件包含四個欄位,單位是位元組,含義如下。

記憶體洩露識別方法1

  • rss(resident set size):所有記憶體佔用,包括指令區和堆疊。
  • heapTotal:"堆"佔用的記憶體,包括用到的和沒用到的。
  • heapUsed:用到的堆的部分。
  • external: V8 引擎內部的 C++ 物件佔用的記憶體。

判斷記憶體洩漏,以heapUsed欄位為準。

參考文章

  1. MDN JavaScript指南 記憶體管理
  2. 精讀《JS 中的記憶體管理》
  3. 阮一峰老師JavaScript 記憶體洩漏教程

本部分內容到這結束

Author 王平安
E-mail pingan8787@qq.com
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推薦 github.com/pingan8787/…
JS小冊 js.pingan8787.com
微信公眾號 前端自習課

前端自習課

相關文章