當前位置: 妍妍網 > 碼農

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

2024-03-14碼農

前言

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

image.png

普通理解

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

這個時候,大部份同學肯定會這麽回答:」垃圾回收機制有兩種方式,一種是 參照法 ,一種是 標記法

參照法

就是判斷一個物件的參照數,參照數 為0 就回收,參照數 大於0 就不回收。請看以下程式碼

let obj1 = { name'林三心'age22 }
let obj2 = obj1
let obj3 = obj1

obj1 = null
obj2 = null
obj3 = null

螢幕擷取2021-08-12 下午10.23.45.png

參照法是有缺點的,下面程式碼執行完後,按理說 obj1和obj2 都會被回收,但是由於他們互相參照,各自參照數都是1,所以不會被回收,從而造成 記憶體泄漏

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

螢幕擷取2021-08-12 下午10.11.39.png

標記法

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

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

image.png

言歸正傳,想要判斷可不可達,就不得不說 可達性 了, 可達性 是什麽?就是從初始的 根物件(window或者global) 的指標開始,向下搜尋子節點,子節點被搜尋到了,說明該子節點的參照物件可達,並為其進行標記,然後接著遞迴搜尋,直到所有子節點被遍歷結束。那麽沒有被遍歷到節點,也就沒有被標記,也就會被當成沒有被任何地方參照,就可以證明這是一個需要被釋放記憶體的物件,可以被垃圾回收器回收。

// 可達
var name = '林三心'
var obj = {
arr: [123]
}
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

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

螢幕擷取2021-08-12 下午10.29.39.png

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

JavaScript記憶體管理

其實JavaScript記憶體的流程很簡單,分為3步:

  • 1、分配給 使用者 所需的記憶體

  • 2、 使用者 拿到這些記憶體,並使用記憶體

  • 3、 使用者 不需要這些記憶體了,釋放並歸還給系統

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

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

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

    上面這些 num,str,obj 就是就是 使用者 ,我們都知道,JavaScript數據型別分為 基礎數據型別 參照數據型別 :

  • 基礎數據型別 :擁有固定的大小,值保存在 棧記憶體 裏,可以透過值直接存取

  • 參照數據型別 :大小不固定(可以加內容), 棧記憶體 中存著指標,指向 堆記憶體 中的物件空間,透過參照來存取

  • image.png
  • 由於棧記憶體所存的基礎數據型別大小是固定的,所以棧記憶體的記憶體都是 作業系統自動分配和釋放回收的

  • 由於堆記憶體所存大小不固定,系統 無法自動釋放回收 ,所以需要 JS引擎來手動釋放這些記憶體

  • 為啥要垃圾回收

    在Chrome中,V8被限制了記憶體的使用 (64位元約1.4G/1464MB , 32位元約0.7G/732MB) ,為什麽要限制呢?

  • 表層原因:V8最初為瀏覽器而設計,不太可能遇到用大量記憶體的場景

  • 深層原因:V8的垃圾回收機制的限制(如果清理大量的記憶體垃圾是很耗時間,這樣會引起JavaScript執行緒暫停執行的時間,那麽效能和套用直線下降)

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

    V8的垃圾回收演算法

    1. 分代回收

    在JavaScript中,物件存活周期分為兩種情況

  • 存活周期很短:經過一次垃圾回收後,就被釋放回收掉

  • 存活周期很長:經過多次垃圾回收後,他還存在,賴著不走

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

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

    image.png

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

  • 副垃圾回收器 + Scavenge演算法 :主要負責新生代的垃圾回收

  • 主垃圾回收器 + Mark-Sweep && Mark-Compact演算法 :主要負責老生代的垃圾回收

  • 1.1 新生代

    在JavaScript中,任何物件的聲明分配到的記憶體,將會先被放置在新生代中,而因為大部份物件在記憶體中存活的周期很短,所以需要一個效率非常高的演算法。在新生代中,主要使用 Scavenge 演算法進行垃圾回收, Scavenge 演算法是一個典型的犧牲空間換取時間的復制演算法,在占用空間不大的場景上非常適用。

    Scavange演算法 將新生代堆分為兩部份,分別叫 from-space to-space ,工作方式也很簡單,就是將 from-space 中存活的活動物件復制到 to-space 中,並將這些物件的記憶體有序的排列起來,然後將 from-space 中的非活動物件的記憶體進行釋放,完成之後,將 from space to space 進行互換,這樣可以使得新生代中的這兩塊區域可以重復利用。

    image.png

    具體步驟為以下4步:

  • 1、標記活動物件和非活動物件

  • 2、復制 from-space 的活動物件到 to-space 中並進行排序

  • 3、清除 from-space 中的非活動物件

  • 4、將 from-space to-space 進行角色互換,以便下一次的 Scavenge演算法 垃圾回收

  • 那麽,垃圾回收器是怎麽知道哪些物件是活動物件,哪些是非活動物件呢?

    這就要不得不提一個東西了—— 可達性 。什麽是可達性呢?就是從初始的 根物件(window或者global) 的指標開始,向下搜尋子節點,子節點被搜尋到了,說明該子節點的參照物件可達,並為其進行標記,然後接著遞迴搜尋,直到所有子節點被遍歷結束。那麽沒有被遍歷到節點,也就沒有被標記,也就會被當成沒有被任何地方參照,就可以證明這是一個需要被釋放記憶體的物件,可以被垃圾回收器回收。

    新生代中的物件什麽時候變成老生代的物件?

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

    1.2 老生代

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

  • Scavenge演算法 是復制演算法,反復復制這些存活率高的物件,沒什麽意義,效率極低

  • Scavenge演算法 是以空間換時間的演算法,老生代是記憶體很大的空間,如果使用 Scavenge演算法 ,空間資源非常浪費,得不償失啊。。

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

    Mark-Sweep(標記清理)

    Mark-Sweep 分為兩個階段,標記和清理階段,之前的 Scavenge演算法 也有標記和清理,但是 Mark-Sweep演算法 Scavenge演算法 的區別是,後者需要復制後再清理,前者不需要, Mark-Sweep 直接標記活動物件和非活動物件之後,就直接執行清理了。

  • 標記階段:對老生代物件進行第一次掃描,對活動物件進行標記

  • 清理階段:對老生代物件進行第二次掃描,清除未標記的物件,即非活動物件

  • image.png

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

    Mark-Compact(標記整理)

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

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

    image.png

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

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

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

    3. Orinoco最佳化

    orinoco為V8的垃圾回收器的計畫代號,為了提升使用者體驗,解決 全停頓問題 ,它提出了 增量標記、懶性清理、並行、並列 的最佳化方法。

    3.1 增量標記(Incremental marking)

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

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

    image.png

    3.2 惰性清理(Lazy sweeping)

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

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

    3.3 並行(Concurrent)

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

    image.png

    3.4 並列

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

    image.png

    V8當前的垃圾回收機制

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

    副垃圾回收器

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

    image.png

    主垃圾回收器

    V8在老生代垃圾回收中,如果堆中的記憶體大小超過某個閾值之後,會啟用並行(Concurrent)標記任務。每個輔助執行緒都會去追蹤每個標記到的物件的指標以及對這個物件的參照,而在JavaScript程式碼執行時候,並行標記也在後台的輔助行程中進行,當堆中的某個物件指標被JavaScript程式碼修改的時候,寫入屏障(write barriers)技術會在輔助執行緒在進行並行標記的時候進行追蹤。

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

    image.png

    結語

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