贈你 13 張圖,助你 20 分鐘打敗了「V8 垃圾回收機制」!!!

前言

大家好,我是林三心。前兩天,無意中看到了 B 站上一個講V8垃圾回收 機制的視頻,感興趣的我看了一下,感覺有點難懂,於是我就在想,大家是不是跟我一樣對V8垃圾回收機制這方面的知識都比較懵,或者說看過這方面的知識,但是看不懂。所以,我思考了三天,想了一下如何才能用最通俗的話,講最難的知識點。

普通理解

我相信大部分同學在面試中常常被問到:” 說一說 V8 垃圾回收機制吧 “

這個時候,大部分同學肯定會這麼回答:” 垃圾回收機制有兩種方式,一種是引用法,一種是標記法

引用法

就是判斷一個對象的引用數,引用數爲0就回收,引用數大於0就不回收。請看以下代碼

let obj1 = { name: '林三心', age: 22 }
let obj2 = obj1
let obj3 = obj1


obj1 = null
obj2 = null
obj3 = null

引用法是有缺點的,下面代碼執行完後,按理說obj1和obj2都會被回收,但是由於他們互相引用,各自引用數都是 1,所以不會被回收,從而造成內存泄漏

function fn () {
  const obj1 = {}
  const obj2 = {}
  obj1.a = obj2
  obj2.a = obj1
}
fn()

標記法

標記法就是,將可達的對象標記起來,不可達的對象當成垃圾回收。

那問題來了,可不可達,通過什麼來判斷呢?(這裏的可達,可不是可達鴨)

言歸正傳,想要判斷可不可達,就不得不說可達性了,可達性是什麼?就是從初始的根對象(window或者global)的指針開始,向下搜索子節點,子節點被搜索到了,說明該子節點的引用對象可達,併爲其進行標記,然後接着遞歸搜索,直到所有子節點被遍歷結束。那麼沒有被遍歷到節點,也就沒有被標記,也就會被當成沒有被任何地方引用,就可以證明這是一個需要被釋放內存的對象,可以被垃圾回收器回收。

// 可達
var name = '林三心'
var obj = {
  arr: [1, 2, 3]
}
console.log(window.name) // 林三心
console.log(window.obj) // { arr: [1, 2, 3] }
console.log(window.obj.arr) // [1, 2, 3]
console.log(window.obj.arr[1]) // 2


function fn () {
  var age = 22
}
// 不可達
console.log(window.age) // undefined

普通的理解其實是不夠的,因爲垃圾回收機制(GC)其實不止這兩個算法,想要更深入地瞭解V8垃圾回收機制,就繼續往下看吧!!!

JavaScript 內存管理

其實 JavaScript 內存的流程很簡單,分爲 3 步:

那麼這些使用者是誰呢?舉個例子:

var num = ''
var str = '林三心'


var obj = { name: '林三心' }
obj = { name: '林胖子' }

上面這些num,str,obj就是就是使用者,我們都知道,JavaScript 數據類型分爲基礎數據類型引用數據類型:

爲啥要垃圾回收

在 Chrome 中,V8 被限制了內存的使用(64位約1.4G/1464MB , 32位約0.7G/732MB),爲什麼要限制呢?

前面說到棧內的內存,操作系統會自動進行內存分配和內存釋放,而堆中的內存,由 JS 引擎(如 Chrome 的 V8)手動進行釋放,當我們的代碼沒有按照正確的寫法時,會使得 JS 引擎的垃圾回收機制無法正確的對內存進行釋放(內存泄露),從而使得瀏覽器佔用的內存不斷增加,進而導致 JavaScript 和應用、操作系統性能下降。

V8 的垃圾回收算法

1. 分代回收

在 JavaScript 中,對象存活週期分爲兩種情況

那麼問題來了,對於存活週期短的,回收掉就算了,但對於存活週期長的,多次回收都回收不掉,明知回收不掉,卻還不斷地去做回收無用功,那豈不是很消耗性能?

對於這個問題,V8 做了分代回收的優化方法,通俗點說就是:V8 將堆分爲兩個空間,一個叫新生代,一個叫老生代,新生代是存放存活週期短對象的地方,老生代是存放存活週期長對象的地方

新生代通常只有1-8M的容量,而老生代的容量就大很多了。對於這兩塊區域,V8 分別使用了不同的垃圾回收器和不同的回收算法,以便更高效地實施垃圾回收

1.1 新生代

在 JavaScript 中,任何對象的聲明分配到的內存,將會先被放置在新生代中,而因爲大部分對象在內存中存活的週期很短,所以需要一個效率非常高的算法。在新生代中,主要使用Scavenge算法進行垃圾回收,Scavenge算法是一個典型的犧牲空間換取時間的複製算法,在佔用空間不大的場景上非常適用。

Scavange算法將新生代堆分爲兩部分,分別叫from-spaceto-space,工作方式也很簡單,就是將from-space中存活的活動對象複製到to-space中,並將這些對象的內存有序的排列起來,然後將from-space中的非活動對象的內存進行釋放,完成之後,將from space 和to space進行互換,這樣可以使得新生代中的這兩塊區域可以重複利用。

具體步驟爲以下 4 步:

那麼,垃圾回收器是怎麼知道哪些對象是活動對象,哪些是非活動對象呢?

這就要不得不提一個東西了——可達性。什麼是可達性呢?就是從初始的根對象(window或者global)的指針開始,向下搜索子節點,子節點被搜索到了,說明該子節點的引用對象可達,併爲其進行標記,然後接着遞歸搜索,直到所有子節點被遍歷結束。那麼沒有被遍歷到節點,也就沒有被標記,也就會被當成沒有被任何地方引用,就可以證明這是一個需要被釋放內存的對象,可以被垃圾回收器回收。

新生代中的對象什麼時候變成老生代的對象?

在新生代中,還進一步進行了細分。分爲nursery子代intermediate子代兩個區域,一個對象第一次分配內存時會被分配到新生代中的nursery子代,如果經過下一次垃圾回收這個對象還存在新生代中,這時候我們將此對象移動到intermediate子代,在經過下一次垃圾回收,如果這個對象還在新生代中,副垃圾回收器會將該對象移動到老生代中,這個移動的過程被稱爲晉升

1.2 老生代

新生代空間的對象,身經百戰之後,留下來的老對象,成功晉升到了老生代空間裏,由於這些對象都是經過多次回收過程但是沒有被回收走的,都是一羣生命力頑強,存活率高的對象,所以老生代裏,回收算法不宜使用Scavenge算法,爲啥呢,有以下原因:

所以老生代裏使用了Mark-Sweep算法(標記清理)Mark-Compact算法(標記整理)

Mark-Sweep(標記清理)

Mark-Sweep分爲兩個階段,標記和清理階段,之前的Scavenge算法也有標記和清理,但是Mark-Sweep算法Scavenge算法的區別是,後者需要複製後再清理,前者不需要,Mark-Sweep直接標記活動對象和非活動對象之後,就直接執行清理了。

由上圖,我想大家也發現了,有一個問題:清除非活動對象之後,留下了很多零零散散的空位

Mark-Compact(標記整理)

Mark-Sweep算法執行垃圾回收之後,留下了很多零零散散的空位,這有什麼壞處呢?如果此時進來了一個大對象,需要對此對象分配一個大內存,先從零零散散的空位中找位置,找了一圈,發現沒有適合自己大小的空位,只好拼在了最後,這個尋找空位的過程是耗性能的,這也是Mark-Sweep算法的一個缺點

這個時候Mark-Compact算法出現了,他是Mark-Sweep算法的加強版,在Mark-Sweep算法的基礎上,加上了整理階段,每次清理完非活動對象,就會把剩下的活動對象,整理到內存的一側,整理完成後,直接回收掉邊界上的內存

2. 全停頓 (Stop-The-World)

說完 V8 的分代回收,咱們來聊聊一個問題。JS 代碼的運行要用到 JS 引擎,垃圾回收也要用到 JS 引擎,那如果這兩者同時進行了,發生衝突了咋辦呢?答案是,垃圾回收優先於代碼執行,會先停止代碼的執行,等到垃圾回收完畢,再執行 JS 代碼。這個過程,稱爲全停頓

由於新生代空間小,並且存活對象少,再配合Scavenge算法,停頓時間較短。但是老生代就不一樣了,某些情況活動對象比較多的時候,停頓時間就會較長,使得頁面出現了卡頓現象

3. Orinoco 優化

orinoco 爲 V8 的垃圾回收器的項目代號,爲了提升用戶體驗,解決全停頓問題,它提出了增量標記、懶性清理、併發、並行的優化方法。

3.1 增量標記 (Incremental marking)

咱們前面不斷強調了先標記,後清除,而增量標記就是在標記這個階段進行了優化。我舉個生動的例子:路上有很多垃圾,害得路人都走不了路,需要清潔工打掃乾淨才能走。前幾天路上的垃圾都比較少,所以路人們都等到清潔工全部清理乾淨才通過,但是後幾天垃圾越來越多,清潔工清理的太久了,路人就等不及了,跟清潔工說:“你打掃一段,我就走一段,這樣效率高”。

大家把上面例子裏,清潔工清理垃圾的過程——標記過程,路人——JS代碼,一一對應就懂了。當垃圾少量時不會做增量標記優化,但是當垃圾達到一定數量時,增量標記就會開啓:標記一點,JS代碼運行一段,從而提高效率

3.2 惰性清理 (Lazy sweeping)

上面說了,增量標記只是針對標記階段,而惰性清理就是針對清除階段了。在增量標記之後,要進行清理非活動對象的時候,垃圾回收器發現了其實就算是不清理,剩餘的空間也足以讓 JS 代碼跑起來,所以就延遲了清理,讓 JS 代碼先執行,或者只清理部分垃圾,而不清理全部。這個優化就叫做惰性清理

整理標記和惰性清理的出現,大大改善了全停頓現象。但是問題也來了:增量標記是標記一點,JS運行一段,那如果你前腳剛標記一個對象爲活動對象,後腳 JS 代碼就把此對象設置爲非活動對象,或者反過來,前腳沒有標記一個對象爲活動對象,後腳 JS 代碼就把此對象設置爲活動對象。總結起來就是:標記和代碼執行的穿插,有可能造成對象引用改變,標記錯誤現象。這就需要使用寫屏障技術來記錄這些引用關係的變化

3.3 併發 (Concurrent)

併發式 GC 允許在在垃圾回收的同時不需要將主線程掛起,兩者可以同時進行,只有在個別時候需要短暫停下來讓垃圾回收器做一些特殊的操作。但是這種方式也要面對增量回收的問題,就是在垃圾回收過程中,由於 JavaScript 代碼在執行,堆中的對象的引用關係隨時可能會變化,所以也要進行寫屏障操作。

3.4 並行

並行式 GC 允許主線程和輔助線程同時執行同樣的 GC 工作,這樣可以讓輔助線程來分擔主線程的 GC 工作,使得垃圾回收所耗費的時間等於總時間除以參與的線程數量(加上一些同步開銷)。

V8 當前的垃圾回收機制

2011 年,V8 應用了增量標記機制。直至 2018 年,Chrome64 和 Node.js V10 啓動併發標記(Concurrent),同時在併發的基礎上添加並行(Parallel)技術,使得垃圾回收時間大幅度縮短。

副垃圾回收器

V8 在新生代垃圾回收中,使用並行(parallel)機制,在整理排序階段,也就是將活動對象從from-to複製到space-to的時候,啓用多個輔助線程,並行的進行整理。由於多個線程競爭一個新生代的堆的內存資源,可能出現有某個活動對象被多個線程進行復制操作的問題,爲了解決這個問題,V8 在第一個線程對活動對象進行復制並且複製完成後,都必須去維護複製這個活動對象後的指針轉發地址,以便於其他協助線程可以找到該活動對象後可以判斷該活動對象是否已被複制。

主垃圾回收器

V8 在老生代垃圾回收中,如果堆中的內存大小超過某個閾值之後,會啓用併發(Concurrent)標記任務。每個輔助線程都會去追蹤每個標記到的對象的指針以及對這個對象的引用,而在 JavaScript 代碼執行時候,併發標記也在後臺的輔助進程中進行,當堆中的某個對象指針被 JavaScript 代碼修改的時候,寫入屏障(write barriers)技術會在輔助線程在進行併發標記的時候進行追蹤。

當併發標記完成或者動態分配的內存到達極限的時候,主線程會執行最終的快速標記步驟,這個時候主線程會掛起,主線程會再一次的掃描根集以確保所有的對象都完成了標記,由於輔助線程已經標記過活動對象,主線程的本次掃描只是進行 check 操作,確認完成之後,某些輔助線程會進行清理內存操作,某些輔助進程會進行內存整理操作,由於都是併發的,並不會影響主線程 JavaScript 代碼的執行。

結語

讀懂了這篇文章,下次面試官問你的時候,你就可以不用傻乎乎地說:“引用法和標記法”。而是可以更全面地,更細緻地征服面試官了。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/NWDYnksHpqnsbh6qCAWsuA