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


 壹 ❀ 引

從事計算機相關技術工作的同學,對於內存空間相關概念多少有所耳聞,畢竟像我這種非計算機科班出身的人,對於棧堆,垃圾回收都能簡單說道幾句;當我明白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時,都做兩次清除操作。

 伍 ❀ 參考

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

MDN 內存管理

JS進階系列之內存空間

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM