JS垃圾回收,這次可以看懂了(帶圖警告)

龍小胖發表於2020-03-20

開篇勸退

先告訴閱讀本篇文章的同學本人水平有限,菜鳥一隻。

  • 分享內容是沒人不知道的javascript垃圾回收機制
  • 如果面試官問你垃圾回收是什麼,你只能用10句話以內就表述完,那麼這篇文章很適合你。
  • 將會具體解釋Node使用的V8引擎是如何高效的使用記憶體、如何執行垃圾回收以及對各種變數記憶體的具體操作。

提示:本篇文章不適合熟讀各種原始碼的大佬閱讀,會浪費時間

V8的記憶體限制

我們先抑後揚,Node不同於其他後端語言,Node在對系統的記憶體使用中,只能使用到系統的部分記憶體,比如64位系統只能使用1.4GB,32位系統只能使用0.7GB。隨之到來的問題是Node採用單執行緒,就導致每個執行緒無法對大的記憶體物件進行處理,比如將一個2GB的檔案讀入記憶體進行字串分析處理,即使你有16G的實體記憶體。

V8的物件分配

在javascript中我們的基本型別儲存在棧中,所有物件都分配給了堆處理。 我們每賦值一個物件,該物件的記憶體就會分配在堆中。如果已申請堆所剩記憶體不足以分配新的物件,將會繼續申請新記憶體,直到堆的大小超過V8的記憶體大小限制為止。

在這裡插入圖片描述
至於V8的記憶體限制,起源於V8本身是chrome為瀏覽器設計而生,而瀏覽器中對於網頁來說,V8控制的記憶體綽綽有餘。還源於V8設計者對於V8的垃圾回收機制的限制,官方以1.5GB的垃圾回收堆記憶體為例,V8執行一個小的垃圾回收要使用50毫秒以上,做一次常規非增量式垃圾回收要在1秒以上。

最關鍵的,javascript的垃圾回收會對javascript執行執行緒形成阻塞,作為一個開發人員你應該能夠清楚時長1秒的程式阻塞,對你的專案效能的影響,故此V8的設計者採用了對堆記憶體進行限制的策略。

V8的記憶體分代

V8的垃圾回收策略主要基於分代,那麼怎麼分代呢?

在V8中,主要將記憶體分為新生代老生代兩類。新生代指的是那些存活時間較短的物件,老生代指的是存活時間較長的或者常駐記憶體的物件。而新生代加老生代的物件所佔空間大小就是V8的堆的整體大小。

補充知識點:V8提供了設定新生代和老生代最大記憶體值的方式,從而可以調整V8的整體記憶體限制,使用更多的記憶體空間。

使用--max-old-space-size來調整老生代最大空間和--max-new-space-size來調整新生代最大空間,但是該操 作需要在Node程式啟動時就設定才有效。

V8的主要垃圾回收演算法

Scanvenge演算法

Scanvenge是一種複製形式的垃圾回收演算法,是應用於新生代物件中的一種垃圾回收演算法,演算法首先將堆記憶體一分為二,兩部分空間一半用來分配賦值的物件,叫做From空間,另一半處於空閒的叫做To空間。

為什麼要有一半空間用來閒置呢?這不是讓我們的可用記憶體更小了嗎?

當我們為堆分配物件時,會將分配物件放到From空間中儲存,在V8的垃圾和回收過程中,會首先檢查From中存活的物件(什麼是存活的物件,就是指那些還被繼續引用沒有完全釋放的物件),V8會將From中存活的物件夫婦複製到To空間中,同時清理掉已經被釋放的物件空間。完成該過程From空間和To空間即完成了角色對換,也就是在下一次回收中,之前的From空間變成了To空間,之前的To空間變成了From空間。

這樣我們來重新定義一下:

用來存放物件的一半是From空間,處於閒置狀態的一半是To空間。

在這裡插入圖片描述
Scanvenge演算法明顯的缺點就是隻能使用堆記憶體的一半,但是隨之帶來的好處就是它在時間效率上的優異的表現,屬於典型的犧牲空間換取時間的演算法。 需要強調的是,開頭提到的Scanvenge演算法是應用於新生代物件中的一種垃圾回收演算法,因為新生代物件中的生命週期較短的特性,也契合於該演算法優先時間考慮的特性。

怎樣算生命週期較長的物件?

當一個物件經過多次複製依然存活時,它將會被認為是生命週期較長的物件。這種生命週期較長的物件隨後會被移動到老生代物件中,採用新的演算法(Mark-Sweep&Mark-Compact)進行管理,這個過程稱為晉升。

通過上圖可以瞭解到,物件進行垃圾回收是怎樣從From到To之間轉換的,那麼這個晉升的過程在哪兒體現呢?

在預設情況下,V8對新生代物件進行從From到To空間進行復制時,會先檢查它的記憶體地址來判斷這個物件是否已經經歷過一次Scanvenge回收。如果已經經歷過,那麼會將該物件從From空間直接複製到老生代空間,如果沒有,才會將其複製到To空間。

物件晉升的條件主要有兩個,一個是物件是否經歷過Scanvenge回收一個是To空間的記憶體佔用超過限制

以上,我們講述的就是一個新生代物件如何晉升為老生代物件的第一個條件“物件是否經歷過Scanvenge回收”,那麼第二個條件也許你會更困惑,超出限制?多少算在限制?怎麼超出?

假設一個物件像剛才說的沒有經歷過Scanvenge回收,要將它複製到To空間之前,還要再進行一次檢查。檢查To空間是否已經使用了超過25%,如果To空間超過25%,該物件將直接被晉升到老生代空間進行管理。

完整看一下這個流程:

物件晉升後,該物件即成為老生代中的存活週期較長的物件,所以我們可以重新對老生代進行定義:老生代物件為存活週期較長或常駐記憶體的物件,或為新生代物件回收中溢位的物件

至於為什麼設定25%的原因是,當一次Scanvenge回收完成時,To空間變為From空間,如果新的From空間使用佔比過高,將對接下來的記憶體分配到這個新的From空間過程存在很大的影響。

Mark-Sweep&Mark-Compact演算法

接下來,講一下老生代中的物件使用的回收演算法,這種演算法(Mark-Sweep)也是我們常說的垃圾回收中的標記清除演算法。

首先,老生代空間不會一分為二,老生代空間進行垃圾回收時,首先是標記階段。V8會在標記階段遍歷老生代空間中的所有物件,並標記存活的物件(即還沒有被完全釋放的物件),在隨後的清除階段,會將所有未標記的老生代物件全部回收。

再來張圖:

如果你稍微有點強迫症,你就發現這張圖有點問題。Mark-Sweep在執行完清除之後,導致記憶體空間出現不連續的情況,就像你的磁碟分析圖一樣。

這樣會帶來的一個問題就是,當你需要分配一個較大的物件時,剩餘的記憶體因為碎片化的原因,沒有任何一個記憶體碎片足以分配給這個大的物件記憶體空間,就會導致提前觸發垃圾回收,而這次回收是不必要的。

所以Mark-Compact演算法隨之而生,Mark-Compact比Mark-Sweep增加了一個整理的概念,它的回收執行順序是標記—整理—清除。Mark-Compact所謂的整理概念是指在物件同樣被標記為存活後,會將活著的物件往一端移動,移動完成後在直接清理掉死亡的物件記憶體。

不要暈,來張圖,你就可以的:

在這裡插入圖片描述

兩種差別顯而易見,Mark-Compact演算法執行後的記憶體空間更合理。但是因為Mark-Compact演算法需要移動物件,隨之導致的就是它的執行速度沒有Mark-Sweep快。

所以在V8中主要使用Mark-Sweep演算法,只有在空間不足以對新生代中晉升過來的物件進行分配時,才會使用Mark-Compact演算法進行回收。

回收演算法 Scanvenge Mark-Sweep Mark-Compact
速度 最快 中等 最慢
空間開銷 雙倍空間(無碎片) 少(有碎片) 少(無碎片)
是否移動物件

Incremental Marking演算法

因為垃圾回收會阻塞javascript的執行,故此老生代物件又因為其佔用空間大,存活物件多的特點,對其進行標記,整理,回收的過程引起的阻塞要遠遠比新生代物件回收過程一起的阻塞要嚴重的多,Incremental Marking演算法成為了優化老生代物件耗時的演算法選擇。

為了降低老生代空間垃圾回收帶來的停頓影響,V8 採用了增量標記(incremental marking)的演算法。將原本一口氣停頓完成的來及回收過程拆分為許多小“步進”,每做完一“步進”就讓JavaScript應用邏輯繼續執行一小會兒,垃圾回收與應用邏輯交替執行直到標記階段完成。取得的效果就是,將老生代空間垃圾回收的最大停頓時間可以減少到原本的1/6左右。

有點暈,不要怕,我們有圖:

在這裡插入圖片描述

V8 後續還引入了延遲清理(lazy sweeping)、增量式整理(incremental compaction)、併發標記 等技術,其實看名字你也能理解大概,可以自行查閱。

擴充套件知識

相信上面的東西已經讓你們明白,V8的垃圾回收機制是如何執行的。但是你還需要知道我們的程式碼中是如何觸發、影響垃圾回收的,這就不得不掏出老生常談的作用域和閉包。

作用域

在javascript中作用域有全域性作用域和區域性作用域,在這裡,我們著重關注作用域對垃圾回收的影響。

假設一個函式呼叫產生的作用域:

var foo = function(){
	var local = {}
}
複製程式碼

這是一個函式表示式,foo()函式在每次呼叫時會建立一個作用域,同時也會在該作用域建立一個區域性變數local。函式執行結束,該作用域也會隨之銷燬,同時該作用域中宣告的區域性變數也會隨作用域銷燬而銷燬。在這個例項中,由於區域性變數引用的物件存活週期較短,將會分配在新生代空間的From中。作用域銷燬後,其中的變數也隨之被釋放,該物件所佔用的空間在下次垃圾回收時將會被清理。

作用域鏈

var foo = function(){
	var local = 100
	var bar = function(){
		console.log(local)
	}
}
複製程式碼

在這個例項中,bar()中執行console,在當前函式作用於查詢不到local變數,將會繼續向上查詢,查詢上級最近的作用域,如果找到變數local,就會停止查詢。如果找不到會一直查詢到全域性作用域,如果該變數在所有作用域都不存在,將會丟擲未定義錯誤。

變數的主動釋放

var a = { sex : 10 };
b = 200;
window.c = 300; 
複製程式碼

如果變數是全域性變數,需要注意的是全域性作用域中的變數不會執行垃圾回收過程,此類物件將會常駐記憶體(在老生代空間)。如果需要釋放該類物件空間,只能通過delete或重新賦值變數為undefind或者null來釋放物件的引用。

閉包

什麼叫閉包,歷史爭議問題啊。我們暫可以將能使外部作用域訪問內部作用於中的變數的方法叫做閉包。

function foo(){
    var bar = function(){
        var local = "區域性變數";
        return function(){
            return local;
        }
    }
    var baz = bar();
    console.log(baz())
}
複製程式碼

閉包對垃圾回收帶來的影響也隨之出現,一旦有變數引用中間函式,這個中間函式將無法被釋放,同時也會是該作用域無法釋放,自然作用域中的變數也不會被釋放並回收,除非不在被引用,該函式才會被逐漸釋放。

不得不說全域性變數和閉包是專案中不可缺少的角色,但是需對該類變數謹慎使用,防止在你的專案中這種無法輕易被釋放的變數所佔記憶體越來越多,結果就是你不想看到的記憶體洩露。

結束了

這篇文章是重新回顧了一遍《深入淺出Node.js》後,結合第5章的記憶體控制裡的內容和概念分享給大家的一篇基礎小文。這是一本13年的書,只能說書不怕老,內容清晰,很有助於大家對一下常用的概念進行很細緻的瞭解。

有補充的評論加個知識點,沒補充捧個贊也好。

謝謝。

相關文章