首頁 > 軟體

JavaScript垃圾回收機制(參照計數,標記清除,效能優化)

2022-07-28 22:01:23

一、前言

垃圾回收是JavaScript的隱藏機制,我們通常無需為垃圾回收勞心費力,只需要專注功能的開發就好了。但是這並不意味著我們在編寫JavaScript的時候就可以高枕無憂了,伴隨著我們實現的功能越來越複雜,程式碼量越積越大,效能問題就變的越來越突出。如何寫出執行速度更快,而且佔用記憶體更小的程式碼是程式設計師永無止歇的追求。一個優秀的程式設計師總是能在極其有限的資源下,實現驚人的效果,這也正式芸芸眾生和高高在上的神祗之間的區別。

二、何為垃圾

程式碼執行在計算機的記憶體中,我們在程式碼中定義的所有變數、物件、函數都會在記憶體中佔用一定的記憶體空間。在計算機中,記憶體空間是非常緊張的資源,我們必須時時刻刻注意記憶體的佔用量,畢竟記憶體條非常貴!如果一個變數、函數或者物件在建立之後不再被後繼的程式碼執行所需要,那麼它就可以被稱作垃圾。

雖然從直觀上理解垃圾的定義非常容易,但是對於一個計算機程式來說,我們很難在某一時刻斷定當前存在的變數、函數或者物件在未來不再使用。為了降低計算機記憶體的開銷,同時又保證計算機程式正常執行,我們通常規定滿足以下任一條件的物件或者變數為垃圾:

  • 沒有被參照的物件或者變數;
  • 無法存取到的物件(多個物件之間迴圈參照);

沒有被參照的變數或者物件相當於一座沒有門的房子,我們永遠都無法進入其中,因此不可能在用到它們。無法存取到的物件之間雖然具備連通性,但是仍然無法從外部進入其中,因此也無法再次被利用。滿足以上條件的物件或者變數,在程式未來執行過程中絕對不會再次被採用,因此可以放心的當作垃圾回收。

當我們通過以上定義明確了需要丟棄的物件,是否就意味著剩餘的變數、物件中就沒有垃圾了呢?

不是的!我們當前分辨出的垃圾只是所有垃圾的一部分,仍然會有其他垃圾不滿足以上條件,但是也不會再次被使用了。

這是否可以說滿足以上定義的垃圾是“絕對垃圾”,其他隱藏在程式中的為“相對垃圾”呢?

三、垃圾回收

垃圾回收機制(GC,Garbage Collection)負責在程式執行過程中回收無用的變數和記憶體佔用的空間。一個物件雖然沒有再次使用的可能,但是仍然存在於記憶體中的現象被稱為記憶體漏失。記憶體漏失是非常危險的現象,尤其在長時間執行的程式中。如果一個程式出現了記憶體漏失,它佔用的記憶體空間就會越來越多,直至耗盡記憶體。

字串、物件和陣列沒有固定的大小,所以只有當它們大小已知時才能對它們進行動態的儲存分配。JavaScript程式每次建立字串、陣列或物件時,直譯器都要分配記憶體才儲存這個實體。只要像這樣動態地分配了記憶體,最終都要釋放這些記憶體以便它們能夠被再次利用;否則,JavaScript的直譯器將會消耗完系統中所有可用的記憶體,造成系統崩潰。

JavaScript的垃圾回收機制會間歇性的檢查沒有用途的變數、物件(垃圾),並釋放條它們佔用的空間。

四、可達性(Reachability)

不同的程式語言採用不同的垃圾回收策略,例如C++就沒有垃圾回收機制,所有的記憶體管理靠程式設計師本身的技能,這也就造成了C++比較難以掌握的現狀。JavaScript採用可達性管理記憶體,從字面意思上看,可達的意思是可以到達,也就是指程式可以通過某種方式存取、使用的變數和物件,這些變數所佔用的記憶體是不可以被釋放的。

JavaScript規定了一個固有的可達值集合,集合中的值天生就是可達的:

當前正在執行的函數上下文(包括函數內的區域性變數、函數的引數等);當前巢狀呼叫鏈上的其他函數、它們的區域性變數和引數;全域性變數;其他內部的變數;

以上變數稱為,是可達性樹的頂層節點。

如果一個變數或則物件,直接或者間接的被根變數應用,則認為這個變數是可達的。

換一個說法,如果一個值能夠通過根存取到(例如,A.b.c.d.e),那麼這個值就是可達的。

五、可達性舉例

層次關聯

let people = {
    boys:{
        boys1:{name:'xiaoming'},
        boys2:{name:'xiaojun'},
    },
    girls:{
        girls1:{name:'xiaohong'},
        girls2:{name:'huahua'},
    }
};

以上程式碼建立了一個物件,並賦值給了變數people,變數people中包含了兩個物件boysgirlsboysgirls中又分別包含了兩個子物件。這也就建立了一個包含了3層參照關係的資料結構(不考慮基礎型別資料的情況下),

如下圖:

其中,people節點由於是全域性變數,所以天然可達。boysgirls節點由於被全域性變數直接參照,構成間接可達。boys1boys2girls1girls2由於被全域性變數間接應用,可以通過people.boys.boys存取,因此也屬於可達變數。

如果我們在以上程式碼的後面加上以下程式碼:

people.girls.girls2 = null;
people.girls.girls1 = people.boys.boys2;

那麼,以上參照層次圖將會變成如下形式:

其中,girls1girls2由於和grils節點斷開連線,從而變成了不可達節點,意味著將被垃圾回收機制回收。

而如果此時,我們再執行以下程式碼:

people.boys.boys2 = null;

那麼參照層次圖將變成如下形式:

此時,雖然boys節點和boys2節點斷開了連線,但是由於boys2節點和girls節點之間存在參照關係,所以boys2仍然屬於可達的,不會被垃圾回收機制回收。

以上關聯關係圖證明了為何稱全域性變數等值為,因為在關聯關係圖中,這一類值通常作為關係樹的根節點出現。

相互關聯

let people = {
    boys:{
        boys1:{name:'xiaoming'},
        boys2:{name:'xiaojun'},
    },
    girls:{
        girls1:{name:'xiaohong'},
        girls2:{name:'huahua'},
    }
};
people.boys.boys2.girlfriend = people.girls.girls1;	//boys2參照girls1
people.girls.girls1.boyfriend = people.boys.boys2;	//girls1參照boys2

以上程式碼在boys2girls1之間建立了一個相互關聯的關係,關係結構圖如下:

此時,如果我們切斷boysboys2之間的關聯:

delete people.boys.boys2;

物件之間的關聯關係圖如下:

顯然,並沒有不可達的節點出現。

此時,如果我們切斷boyfriend關係連線:

delete people.girls.girls1;

關係圖變為:

此時,雖然boys2girls1之間還存在girlfriend關係,但是,boys2以及變為不可達節點,將被垃圾回收機制收回。

可達孤島

let people = {
    boys:{
        boys1:{name:'xiaoming'},
        boys2:{name:'xiaojun'},
    },
    girls:{
        girls1:{name:'xiaohong'},
        girls2:{name:'huahua'},
    }
};
delete people.boys;
delete people.girls;

以上程式碼形成的參照層次圖如下:

此時,雖然虛線框內部的物件之間仍然存在相互參照的關係,但是這些物件同樣是不可達的,並會被垃圾回收機制刪除。這些節點已經和脫離了關係,變的不可達。

六、垃圾回收演演算法

參照計數

所謂參照計-數,顧名思義,就是每次物件被參照時都進行計數,增加參照就加一,刪除參照就減一,如果參照數變為0,那麼就被認定為垃圾,從而刪除物件回收記憶體。

舉個例子:

let user = {username:'xiaoming'};//物件被user變數參照,計數+1
let user2 = user;//物件被新的變數參照,計數+1
user = null;//變數不再參照物件,計數-1
user2 = null;//變數不再參照物件,奇數-1
//此時,物件參照數為0,會被刪除

雖然看起來參照計數方法非常合理,實際上,採用參照計數方法的記憶體回收機制存在明顯的漏洞。

例如:

let boy = {};
let girl = {};
boy.girlfriend = girl;
girl.boyfriend = boy;
boy = null;
girl = null;

以上程式碼在boygirl之間存在相互參照,計數刪掉boygirl內的參照,二者物件並不會被回收。由於迴圈參照的存在,兩個匿名物件的參照計數永遠不會歸零,也就產生了記憶體漏失。

C++中存在一個智慧指標shared_ptr)的概念,程式設計師可以通過智慧指標,利用物件解構函式釋放參照計數。但是對於迴圈參照的狀況就會產生記憶體漏失。

好在JavaScript已經採用了另外一種更為安全的策略,更大程度上避免了記憶體漏失的風險。

標記清除

標記清除mark and sweep)是JavaScript引擎採取的垃圾回收演演算法,其基本原理是從出發,廣度優先遍歷變數之間的參照關係,對於遍歷過的變數打上一個標記(優秀員工徽章),最後刪除沒有標記的物件。

演演算法基本過程如下:

  • 垃圾收集器找到所有的,並頒發優秀員工徽章(標記);
  • 然後它遍歷優秀員工,並將優秀員工參照的物件同樣打上優秀員工標記;
  • 反覆執行第2步,直至無新的優秀員工加入;
  • 沒有被標記的物件都會被刪除。

舉個栗子:

如果我們程式中存在如下圖所示的物件參照關係:

我們可以清晰的看到,在整個圖片的右側存在一個“可達孤島”,從出發,永遠無法到達孤島。但是垃圾回收器並沒有我們這種上帝視角,它們只會根據演演算法會首先把根節點打上優秀員工標記。

然後從優秀員工出發,找到所有被優秀員工參照的節點,如上圖中虛線框中的三個節點。然後把新找到的節點同樣打上優秀員工標記。

反覆執行查詢和標記的過程,直至所有能找到的節點都被成功標記。

最終達到下圖所示的效果:

由於在演演算法執行週期結束之後,右側的孤島仍然沒有標記,因此會被垃圾回收器任務無法到達這些節點,最終被清除。

如果學過資料結構和演演算法的童鞋可能會驚奇的發現,這不就是圖的遍歷嗎,類似於連通圖演演算法。

七、效能優化

垃圾回收是一個規模龐大的工作,尤其在程式碼量非常大的時候,頻繁執行垃圾回收演演算法會明顯拖累程式的執行。JavaScript演演算法在垃圾回收上做了很多優化,從而在保證回收工作正常執行的前提下,保證程式能夠高效的執行。

效能優化採取的策略通常包括以下幾點:

分代回收

JavaScript程式在執行過程中會維持相當量級的變數數目,頻繁掃描這些變數會造成明顯的開銷。但是這些變數在生命週期上各有特點,例如區域性變數會頻繁的建立,迅速的使用,然後丟棄,而全域性變數則會長久的佔據記憶體。JavaScript把兩類物件分開管理,對於快速建立、使用並丟棄的區域性變數,垃圾回收器會頻繁的掃描,保證這些變數在失去作用後迅速被清理。而對於哪些長久把持記憶體的變數,降低檢查它們的頻率,從而節約一定的開銷。

增量收集

增量式的思想在效能優化上非常常見,同樣可以用於垃圾回收。在變數數目非常大時,一次性遍歷所有變數並頒發優秀員工標記顯然非常耗時,導致程式在執行過程中存在卡頓。所以,引擎會把垃圾回收工作分成多個子任務,並在程式執行的過程中逐步執行每個小任務,這樣就會造成一定的回收延遲,但通常不會造成明顯的程式卡頓。

空閒收集

CPU即使是在複雜的程式中也不是一直都有工作的,這主要是因為CPU工作的速度非常快,外圍IO往往慢上幾個數量級,所以在CPU空閒的時候安排垃圾回收策略是一種非常有效的效能優化手段,而且基本不會對程式本身造成不良影響。這種策略就類似於系統的空閒時間升級一樣,使用者根本察覺不到後臺的執行。

八、總結

本文的主要任務是簡單的結束垃圾回收的機制、常用的策略和優化的手段,並不是為了讓大家深入瞭解引擎的後臺執行原理。

通過本文,你應該瞭解:

垃圾回收是JavaScript的特性之一,執行在後臺,無需我們操心;垃圾回收的策略是標記清除,按照可達性理論篩選並清除垃圾;標記清楚策略可以避免可達孤島帶來的記憶體漏失

到此這篇關於JavaScript垃圾回收機制(參照計數,標記清除,效能優化)的文章就介紹到這了,更多相關JS垃圾回收機制 內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com