垃圾回收是JavaScript
的隱藏機制,我們通常不需要為垃圾回收勞心費力,只需要專注功能的開發就好了。但這並不代表我們在寫JavaScript
的時候就可以高枕無憂了,伴隨著我們實現的功能越來越複雜,程式碼量越積越大,效能問題就變的越來越突出。如何寫出執行速度更快,而且佔用記憶體更小的程式碼是程式設計師永無止境的追求。優秀的程式設計師總是能在極為有限的資源下,達到驚人的效果,這也正式芸芸眾生和高高在上的神祗之間的差異。
程式碼執行在電腦的記憶體中,我們在程式碼中定義的所有變數、物件、函數都會在記憶體中佔用一定的記憶體空間。在電腦中,記憶體空間是非常緊張的資源,我們必須時時刻刻注意記憶體的佔用量,畢竟記憶體條非常貴!如果一個變數、函數或物件在創建之後不再被後繼的程式碼執行所需要,那麼它就可以被稱為垃圾。
雖然從直覺上理解垃圾的定義非常容易,但是對於一個電腦程式來說,我們很難在某一時刻斷定當前存在的變數、函數或物件在未來不再使用。為了降低電腦記憶體的開銷,同時保證電腦程式正常執行,我們通常規定滿足以下任一條件的物件或變數為垃圾:
沒有被引用的變數或物件相當於一座沒有門的房子,我們永遠都無法進入其中,因此不可能在用到它們。無法存取的物件之間雖然具備連通性,但是仍然無法從外部進入其中,因此也無法再次被利用。滿足以上條件的物件或變量,在程式未來執行過程中絕對不會再次被採用,因此可以放心的當作垃圾回收。
當我們透過以上定義明確了需要丟棄的對象,是否就代表剩餘的變數、物件中就沒有垃圾了呢?
不是的!我們目前分辨出的垃圾只是所有垃圾的一部分,仍然會有其他垃圾不符合以上條件,但是也不會再被使用了。
這是否可以說滿足以上定義的垃圾是“絕對垃圾”,其他隱藏在程序中的為“相對垃圾”呢?
垃圾回收機制( GC,Garbage Collection
)負責在程式執行過程中回收無用的變數和記憶體佔用的空間。一個物件雖然沒有再次使用的可能,但是仍然存在於記憶體中的現像被稱為記憶體洩漏。記憶體洩漏是非常危險的現象,尤其在長時間運行的程式中。如果一個程式出現了記憶體洩漏,它佔用的記憶體空間就會越來越多,直到耗盡記憶體。
字串、物件和陣列沒有固定的大小,所以只有當它們大小已知時才能對它們進行動態的儲存分配。 JavaScript程式每次建立字串、陣列或物件時,解釋器都要分配記憶體才儲存這個實體。只要像這樣動態地分配了內存,最終都要釋放這些內存以便它們能夠被再次利用;否則,JavaScript的解釋器將會消耗完系統中所有可用的內存,造成系統崩潰。
JavaScript
的垃圾回收機制會間歇性的檢查沒有用途的變數、物件(垃圾),並釋放條它們佔用的空間。
不同的程式語言採用不同的垃圾回收策略,例如C++
就沒有垃圾回收機制,所有的記憶體管理靠程式設計師本身的技能,這也就造成了C++
比較難以掌握的現況。 JavaScript
採用可達性管理內存,從字面意思上看,可達的意思是可以到達,也就是指程式可以透過某種方式存取、使用的變數和對象,這些變數所佔用的記憶體是不可以被釋放的。
JavaScript
規定了一個固有的可達值集合,集合中的值天生就是可達的:
以上變數稱為根,是可達性樹的頂層節點。
如果一個變數或則對象,直接或間接的被根變數應用,則認為這個變數是可達的。
換一個說法,如果一個值能夠透過根訪問到(例如, Abcde
),那麼這個值就是可達的。
let people = { boys:{ boys1:{name:'xiaoming'}, boys2:{name:'xiaojun'}, }, girls:{ girls1:{name:'xiaohong'}, girls2:{name:'huahua'}, }};
以上程式碼創造了一個對象,並賦值給了變數people
,變數people
包含了兩個物件boys
和girls
, boys
和girls
中又分別包含了兩個子物件。這也就創建了一個包含了3
層引用關係的資料結構(不考慮基礎類型資料的情況),如下圖:
其中, people
節點由於是全域變量,所以天然可達。 boys
和girls
節點由於被全域變數直接引用,構成間接可達。 boys1
、 boys2
、 girls1
和girls2
由於被全域變數間接應用,可以透過people.boys.boys
來訪問,因此也屬於可達變數。
如果我們在以上程式碼的後面加上以下程式碼:
people.girls.girls2 = null;people.girls.girls1 = people.boys.boys2;
那麼,以上引用層次圖將會變成如下形式:
其中, girls1
和girls2
由於和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引用girls1people.girls.girls1.boyfriend = people.boys.boys2; //girls1引用boys2
以上代碼在boys2
和girls1
之間創建了一個相互關聯的關係,關係結構圖如下:
此時,如果我們切斷boys
和boys2
之間的關聯:
delete people.boys.boys2;
物件之間的關聯關係圖如下:
顯然,並沒有不可達的節點出現。
此時,如果我們切斷boyfriend
關係連結:
delete people.girls.girls1;
關係圖變成:
此時,雖然boys2
和girls1
之間還存在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;
以上程式碼在boy
和girl
之間有互相引用,計數刪掉boy
和girl
內的引用,二者物件不會被回收。由於循環引用的存在,兩個匿名物件的參考計數永遠不會歸零,也產生了記憶體洩漏。
在C++
中存在一個智慧指針( shared_ptr
)的概念,程式設計師可以透過智慧指針,利用物件析構函數釋放引用計數。但是對於循環引用的狀況就會產生記憶體洩漏。
還好JavaScript
已經採用了另外一種更安全的策略,更大程度上避免了記憶體洩漏的風險。
標記清除( mark and sweep
)是JavaScript
引擎採取的垃圾回收演算法,其基本原理是從根出發,廣度優先遍歷變數之間的引用關係,對於遍歷過的變數打上一個標記(优秀员工徽章
),最後刪除沒有標記的物件。
演算法基本流程如下:
2
步,直至無新的優秀員工加入;舉個栗子:
如果我們程式中存在如下圖所示的物件引用關係:
我們可以清晰的看到,在整個圖片的右側存在一個“可達孤島”,從根出發,永遠無法到達孤島。但是垃圾回收器並沒有我們這種上帝視角,它們只會根據演算法會先把根節點打上優秀員工標記。
然後從優秀員工出發,找出所有被優秀員工引用的節點,如上圖虛線框框中的三個節點。然後把新找到的節點同樣打上優秀員工標記。
重複執行查找和標記的過程,直至所有能找到的節點都被成功標記。
最終達到下圖所示的效果:
由於在演算法執行週期結束之後,右側的孤島仍然沒有標記,因此會被垃圾回收器任務無法到達這些節點,最終被清除。
如果學過資料結構和演算法的童鞋可能會驚訝的發現,這不就是圖的遍歷嗎,類似連通圖演算法。
垃圾回收是一個規模龐大的工作,尤其在程式碼量非常大的時候,頻繁執行垃圾回收演算法會明顯拖累程式的執行。 JavaScript
演算法在垃圾回收上做了很多優化,從而在確保回收工作正常執行的前提下,保證程式能夠有效率的執行。
效能最佳化採取的策略通常包括以下幾點:
JavaScript
程式在執行過程中會維持相當量級的變數數目,頻繁掃描這些變數會造成明顯的開銷。但是這些變數在生命週期上各有特點,例如局部變數會頻繁的創建,迅速的使用,然後丟棄,而全局變數則會長久的佔據記憶體。 JavaScript
把兩類物件分開管理,對於快速創建、使用並丟棄的局部變量,垃圾回收器會頻繁的掃描,保證這些變量在失去作用後迅速被清理。而對於哪些長久把持記憶體的變量,降低檢查它們的頻率,從而節省一定的開銷。
增量式的想法在效能優化上非常常見,同樣可以用於垃圾回收。在變數數目非常大時,一次遍歷所有變數並頒發優秀員工標記顯然非常耗時,導致程式在執行過程中存在卡頓。所以,引擎會把垃圾回收工作分成多個子任務,並在程式執行的過程中逐步執行每個小任務,這樣就會造成一定的回收延遲,但通常不會造成明顯的程式卡頓。
CPU
即使是在複雜的程序中也不是一直都有工作的,這主要是因為CPU
工作的速度非常快,外圍IO
往往慢上幾個數量級,所以在CPU
空閒的時候安排垃圾回收策略是一種非常有效的效能優化手段,而且基本上不會對程式本身造成不良影響。這種策略就類似系統的空閒時間升級一樣,使用者根本察覺不到後台的執行。
本文的主要任務是簡單的結束垃圾回收的機制、常用的策略和優化的手段,並不是為了讓大家深入了解引擎的後台執行原理。
透過本文,你應該了解:
JavaScript
的特性之一,執行在後台,無需我們操心;