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

Sunshine_Lin發表於2022-01-07

前言

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

image.png

普通理解

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

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

引用法

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

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

obj1 = null
obj2 = null
obj3 = null

截圖2021-08-12 下午10.23.45.png

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

function fn () {
  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: [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

截圖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-spaceto-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-spaceto-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

結語

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

後續會出一篇講專案中記憶體洩漏的文章,敬請期待!!!

我是林三心,一個熱心的前端菜鳥程式設計師。如果你上進,喜歡前端,想學習前端,那我們們可以交朋友,一起摸魚哈哈,摸魚群,加我請備註【思否】

image.png

相關文章