本文是 重溫基礎 系列文章的第二十二篇。

今日感受:優化學習方法。

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

前置知識

絕大多數的程序語言,他們的內存生命周期基本一致:

  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();

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

實際案例

在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 標記清除演算法

這個演算法,將「對象是否不再需要」定義為:對象是否可以獲得。

標記清除演算法,是假定設置一個根對象(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 內存泄露案例

  • 全局變數

未定義的變數,會被定義到全局,當頁面關閉才會銷毀,這樣就造成內存泄露。如下:

function fun(){
name = pingan;
};

  • 未銷毀的定時器和回調函數 如果這裡舉一個定時器的案例,如果定時器沒有回收,則不僅整個定時器無法被內存回收,定時器函數的依賴也無法回收:

var data = {};
setInterval(function(){
var render = document.getElementById(myId);
if(render){
render.innderHTML = JSON.stringify(data);
}
}, 1000);

  • 閉包

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可能被調用,就會導致內存無法回收,內存增長導致泄露。
  • 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 內存泄露識別方法

  • 瀏覽器

通過Chrome瀏覽器查看內存佔用:

步驟如下:

  1. 打開開發者工具,選擇 Timeline 面板
  2. 在頂部的Capture欄位裡面勾選 Memory
  3. 點擊左上角的錄製按鈕
  4. 在頁面上進行各種操作,模擬用戶的使用情況
  5. 一段時間後,點擊對話框的 stop 按鈕,面板上就會顯示這段時間的內存佔用情況

如果內存佔用基本平穩,接近水平,就說明不存在內存泄漏。

反之,就是內存泄漏了。

  1. 命令行

命令行可以使用 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欄位為準。

參考文章

  1. MDN JavaScript指南 內存管理
  2. 精讀《JS 中的內存管理》
  3. 阮一峰老師JavaScript 內存泄漏教程

本部分內容到這結束

推薦閱讀:

相关文章