壹 ❀ 引
從事計算機相關技術工作的同學,對於內存空間相關概念多少有所耳聞,畢竟像我這種非計算機科班出身的人,對於棧堆,垃圾回收都能簡單說道幾句;當我明白JS 基本類型與引用類型數據存儲方式不同,才對於為何要使用深拷貝恍然大悟。只是知道和深入了解是兩碼事,那么這篇文章從內存空間說起。
貳 ❀ 棧、堆與隊列
與c語言這種底層語言不同,JavaScript並沒有提供內存管理的接口,而是在創建變量時自動分配內存,當變量不再需要使用時自動釋放,也就是我們所常說的垃圾回收機制。
但不管是什么程序語言,內存的聲明周期都滿足以下三個階段:
a.分配你需要的內存空間
b.使用分配到的內存(讀、寫)
c.不需要時將其釋放或歸還
大部分語言對於第二步是明確的,但對於JavaScript而言三步都是隱含的,也正是因如此才讓JavaScript開發者產生了不用關心內存管理的錯覺。
JavaScript內存空間分為棧,堆,池,隊列。其中棧存放變量,基本類型數據與指向復雜類型數據的引用指針;堆存放復雜類型數據;池又稱為常量池,用於存放常量;而隊列在任務隊列也會使用。我們一一細說。
1.棧數據結構
棧數據結構具備FILO(first in last out)先進后出的特性,較為經典的就是乒乓球盒結構,先放進去的乒乓球只能最后取出來。我在 一篇文章看懂JS執行上下文 這篇文章中有提到執行上下文棧,它用於存放js代碼在執行過程中創建的所有上下文,同樣也具備FILO的特性。
在js中數據類型一般分類基本數據類型(Number Boolean Null Undefined String Symbol)與引用數據類型(Object Array Function ...),其中棧一般用於存放基本類型數據,例如以下代碼在棧內存中分布:
var a = 1; var b = a; a = 2;
可以看到基本類型數據的變量名與值都存放在棧內存中,當我們將變量a復制給b時,棧會新開內存用於存放變量b,且當我們修改變量a時對變量b不會造成任何影響,因為a與b是互不相關的兩份數據。
2.堆數據結構
堆數據結構是一種無序的樹狀結構,同時它還滿足key-value鍵值對的存儲方式;我們只用知道key名,就能通過key查找到對應的value。比較經典的就是書架存書的例子,我們知道書名,就可以找到對應的書籍。
在js中堆內存一般用於存儲引用類型的數據,需要注意的是由於引用類型的數據一般可以拓展,數據大小可變,所以存放在堆內存中;但對引用類型數據的引用地址是固定的,所以地址指向還是會存放在棧內存中。
我們通過內存圖來模擬以下代碼:
var a = [1,2,3]; var b = a; a.push(4);
當我們創建數組a時,棧內存中只保存了變量a與指向堆內存中數組的地址指針,而當我們將a復制給變量b時,其實只是復制了一份地址指針,兩者還是指向同一數組,無論誰修改,都會影響彼此。
這便是我們熟知的淺拷貝,若想對淺拷貝與深拷貝有更深了解,歡迎閱讀博主 深拷貝與淺拷貝的區別,實現深拷貝的幾種方法這篇文章。
3.隊列
隊列具有FIFO(First In First Out)先進先出的特性,與棧內存不同的是,棧內存只存在一個出口用於數據進棧出棧;而隊列有一個入口與一個出口,理解隊列一個較為實際的例子就像我們排隊取餐,先排隊的永遠能先取到餐。
在js中使用隊列較為突出的就是js執行機制中的event loop事件循環,如果大家對於js事件執行機制有興趣,可以閱讀博主 JS執行機制詳解,定時器時間間隔的真正含義 這篇文章,一定會讓你有所收獲。
叄 ❀ 垃圾回收機制
我們在前面已經說到JS內存分配回收由計算機自動完成,同時也提到了垃圾回收機制這個概念,這里來細說。
1.js中的內存回收
在js中,垃圾回收器每隔一段時間就會找出那些不再使用的數據,並釋放其所占用的內存空間。
以全局變量和局部變量來說,函數中的局部變量在函數執行結束后這些變量已經不再被需要,所以垃圾回收器會識別並釋放它們。而對於全局變量,垃圾回收器很難判斷這些變量什么時候才不被需要,所以盡量少使用全局變量。
2.垃圾回收的兩種模式
那么垃圾回收器是如何檢測變量是否需要的呢,大體上分為兩種檢測手段,引用計數與標記清除。
引用計數
引用計數的判斷原理很簡單,就是看一份數據是否還有指向它的引用,如果沒有任何對象再指向它,那么垃圾回收器就會回收,舉個例子:
// 創建一個對象,由變量o指向這個對象的兩個屬性 var o = { name: '聽風是風', handsome: true }; // name雖然設置為了null,但o依舊有name屬性的引用 o.name = null; var s = o; // 我們修改並釋放了o對於對象的引用,但變量s依舊存在引用 o = null; // 變量s也不再引用,對象很快會被垃圾回收器釋放 s = null;
引用計數存在一個很大的問題,就是對象間的循環引用,比如如下代碼中,對象o1與o2相互引用,即便函數執行完畢,垃圾回收器通過引用計數也無法釋放它們。
function f() { var o1 = {}; var o2 = {}; o1.a = o2; // o1 引用 o2 o2.a = o1; // o2 引用 o1 return; }; f();
標記清除
標記清除的概念也好理解,從根部出發看是否能達到某個對象,如果能達到則認定這個對象還被需要,如果無法達到,則釋放它,這個過程大致分為三步:
a.垃圾回收器創建roots列表,roots通常是代碼中保留引用的全局變量,在js中,我們一般認定全局對象window作為root,也就是所謂的根部。
b.從根部出發檢查所有 的roots,所有的children也會被遞歸檢查,能從root到達的都會被標記為active。
c.未被標記為active的數據被認定為不再需要,垃圾回收器開始釋放它們。
當一個對象零引用時,我們從根部一定無法到達;但反過來,從根部無法到達的不一定是嚴格意義上的零引用,比如循環引用,所以標記清除要更優於引用計數。
從2012年起,所有現代瀏覽器都使用了標記清除垃圾回收算法,但老版本的IE6除外。
肆 ❀ 如何避免內存泄漏
我們已經知道了垃圾回收的原理,那么我們如何避免創建無法回收的對象,以至造成內存泄漏的尷尬呢?下面說說常見的四種js內存泄漏。
1.全局變量
盡可能少的去創建全局變量是js開發者的常識,但如下兩種方式還是會意外的創建全局變量,第一是在函數中聲明變量未使用var:
function fn() { a = 1; }; fn(); window.a //1
上述代碼中我們在函數體內聲明了一個變量a,由於未使用var聲明,即便在函數體內,但它依舊是一個全局變量。我們知道全局變量等同於在window上添加屬性,所以在函數執行完畢,我們依舊可以訪問到它。
第二種是在函數體內通過this來創建變量:
function fn() { this.a = 1; }; fn(); window.a //1
我們知道,當直接調用函數fn時,等同於window.fn(),所以函數體內的this會指向window,所以本質上還是創建了一個全局變量。
當然上述問題也不是無法解決,我們可以使用嚴格模式來避免這個問題,試着在代碼頭部添加‘use strict’,你會發現a就無法訪問了,因為嚴格模式下,全局對象指向undefined。
有時候我們無法避免使用全局變量,那么記得在使用完畢后手動釋放它們,例如讓變量指向null。
2.被遺忘的定時器或回調函數
var serverData = loadData(); setInterval(function () { var renderer = document.getElementById('renderer'); if (renderer) { renderer.innerHTML = JSON.stringify(serverData); } }, 3000);
在上述代碼中,當dom元素renderer被移除時,由於是周期定時器的緣故,定時器回調函數始終無法被回收,這也導致了定時器會一直對數據serverData保持引用,好的做法是在不需要時停止定時器。
在例如我們在使用事件監聽時,如果不再需要監聽記得移除監聽事件。
var element = document.getElementById('button'); function onclick(event) { element.innerHTML = 'text'; }; element.addEventListener('click', onclick); // 移除監聽 element.removeEventListener('click', onclick);
3.閉包
閉包在js開發中是極其常見的,我們來看個例子:
var theThing = null; var replaceThing = function () { var originalThing = theThing; var unused = function () { //unused未執行,但一直保持對theThing的引用 if (originalThing) console.log("hi"); }; //創建一個新對象 theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log("message"); } }; }; setInterval(replaceThing, 1000);
定時器每次調用replaceThing,theThing都會獲得一個包含數組longStr與閉包someMethod的新對象。
閉包unused保持着對象originalThing的引用,因為theThing賦值的緣故,也保持了對theThing的引用。雖然unused沒執行,但引用關系會導致originalThing一直無法被回收,那么theThing也一樣。正確做法是在replaceThing 最后添加originalThing = null;
所以我們常說,對於閉包中的變量,在不需要時一定記得手動釋放。
4.DOM的引用
操作dom總是被認為是不好的,但一定得操作,我們的習慣是通過一個變量來存儲它,這樣就可以反復使用了,但這也會造成一個問題,dom會被引用2次。
var elements = document.getElementById('button') function doStuff() { elements.innerHTML = '聽風是風'; }; // 清除引用 elements = null; document.body.removeChild(document.getElementById('button'));
在上述代碼中,一次引用是基於dom樹的引用,第二是變量elements的引用,當我們不需要這個dom時,都做兩次清除操作。
伍 ❀ 參考