JS 從內存空間談到垃圾回收機制

 壹 ❀ 引html

從事計算機相關技術工做的同窗,對於內存空間相關概念多少有所耳聞,畢竟像我這種非計算機科班出身的人,對於棧堆,垃圾回收都能簡單說道幾句;當我明白JS 基本類型與引用類型數據存儲方式不一樣,纔對於爲什麼要使用深拷貝恍然大悟。只是知道和深刻了解是兩碼事,那麼這篇文章從內存空間提及。算法

 貳 ❀ 棧、堆與隊列數組

與c語言這種底層語言不一樣,JavaScript並無提供內存管理的接口,而是在建立變量時自動分配內存,當變量再也不須要使用時自動釋放,也就是咱們所常說的垃圾回收機制。瀏覽器

但不論是什麼程序語言,內存的聲明週期都知足如下三個階段:數據結構

a.分配你須要的內存空間閉包

b.使用分配到的內存(讀、寫)dom

c.不須要時將其釋放或歸還函數

大部分語言對於第二步是明確的,但對於JavaScript而言三步都是隱含的,也正是因如此才讓JavaScript開發者產生了不用關心內存管理的錯覺。oop

JavaScript內存空間分爲棧,堆,池,隊列。其中棧存放變量基本類型數據與指向複雜類型數據的引用指針堆存放複雜類型數據池又稱爲常量池,用於存放常量;而隊列在任務隊列也會使用。咱們一一細說。this

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時,都作兩次清除操做。

 伍 ❀ 參考

JavaScript深刻之帶你走進內存機制

MDN 內存管理

JS進階系列以內存空間

JavaScript 工做原理(一)——內存管理與四種常見內存泄漏的處理方法

相關文章
相關標籤/搜索