深刻理解Chrome V8垃圾回收機制

最近,項目進入維護期,基本沒有什麼需求,比較閒,這讓我莫名的有了危機感,天天像是在混日子,感受這像是在溫水煮青蛙,已經畢業3年了,很怕本身到了5年經驗的時候,能力卻和3年經驗的時候同樣,沒什麼長進。因而開始整理本身的技術點,恰好查漏補缺,在收藏夾在翻出了一篇文章一名【合格】前端工程師的自檢清單,看到了裏面的兩個問題:html

  • JavaScript中的變量在內存中的具體存儲形式是什麼?
  • 瀏覽器的垃圾回收機制,如何避免內存泄漏?

而後各類查資料,就整理了這篇文章。前端

閱讀本文以後,你能夠了解到:node

  • JavaScript的內存是怎麼管理的?
  • Chrome是如何進行垃圾回收的?
  • Chrome對垃圾回收進行了哪些優化?

原文地址 歡迎stargit

JavaScript的內存管理

無論什麼程序語言,內存生命週期基本是一致的:github

  1. 分配你所須要的內存
  2. 使用分配到的內存(讀、寫)
  3. 不須要時將其釋放歸還

與其餘須要手動管理內存的語言不通,在JavaScript中,當咱們建立變量(對象,字符串等)的時候,系統會自動給對象分配對應的內存。算法

var n = 123; // 給數值變量分配內存
var s = "azerty"; // 給字符串分配內存

var o = {
  a: 1,
  b: null
}; // 給對象及其包含的值分配內存

// 給數組及其包含的值分配內存(就像對象同樣)
var a = [1, null, "abra"]; 

function f(a){
  return a + 2;
} // 給函數(可調用的對象)分配內存

// 函數表達式也能分配一個對象
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);

當系統發現這些變量再也不被使用的時候,會自動釋放(垃圾回收)這些變量的內存,開發者不用過多的關心內存問題。數組

雖然這樣,咱們開發過程當中也須要了解JavaScript的內存管理機制,這樣才能避免一些沒必要要的問題,好比下面代碼:瀏覽器

{}=={} // false
[]==[] // false
''=='' // true

在JavaScript中,數據類型分爲兩類,簡單類型和引用類型,對於簡單類型,內存是保存在棧(stack)空間中,複雜數據類型,內存是保存在堆(heap)空間中。前端工程師

  • 基本類型:這些類型在內存中分別佔有固定大小的空間,他們的值保存在棧空間,咱們經過按值來訪問的
  • 引用類型:引用類型,值大小不固定,棧內存中存放地址指向堆內存中的對象。是按引用訪問的。

而對於棧的內存空間,只保存簡單數據類型的內存,由操做系統自動分配和自動釋放。而堆空間中的內存,因爲大小不固定,系統沒法沒法進行自動釋放,這個時候就須要JS引擎來手動的釋放這些內存。併發

爲何須要垃圾回收

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

  1. 表層緣由是,V8最初爲瀏覽器而設計,不太可能遇到用大量內存的場景
  2. 深層緣由是,V8的垃圾回收機制的限制(若是清理大量的內存垃圾是很耗時間,這樣回引發JavaScript線程暫停執行的時間,那麼性能和應用直線降低)

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

Chrome 垃圾回收算法

在JavaScript中,其實絕大多數的對象存活週期都很短,大部分在通過一次的垃圾回收以後,內存就會被釋放掉,而少部分的對象存活週期將會很長,一直是活躍的對象,不須要被回收。爲了提升回收效率,V8 將堆分爲兩類新生代老生代,新生代中存放的是生存時間短的對象,老生代中存放的生存時間久的對象。

新生區一般只支持 1~8M 的容量,而老生區支持的容量就大不少了。對於這兩塊區域,V8 分別使用兩個不一樣的垃圾回收器,以便更高效地實施垃圾回收。

  • 副垃圾回收器 - Scavenge:主要負責新生代的垃圾回收。
  • 主垃圾回收器 - Mark-Sweep & Mark-Compact:主要負責老生代的垃圾回收。

新生代垃圾回收器 - Scavenge

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

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

簡單的描述就是:

  • 標記活動對象和非活動對象
  • 複製 from space 的活動對象到 to space 並對其進行排序
  • 釋放 from space 中的非活動對象的內存
  • 將 from space 和 to space 角色互換

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

有一個概念叫對象的可達性,表示從初始的根對象(window,global)的指針開始,這個根指針對象被稱爲根集(root set),從這個根集向下搜索其子節點,被搜索到的子節點說明該節點的引用對象可達,併爲其留下標記,而後遞歸這個搜索的過程,直到全部子節點都被遍歷結束,那麼沒有被標記的對象節點,說明該對象沒有被任何地方引用,能夠證實這是一個須要被釋放內存的對象,能夠被垃圾回收器回收。

新生代中的對象何時變成老生代的對象呢?

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

老生代垃圾回收 - Mark-Sweep & Mark-Compact

新生代空間中的對象知足必定條件後,晉升到老生代空間中,在老生代空間中的對象都已經至少經歷過一次或者屢次的回收因此它們的存活機率會更大,若是這個時候再使用scavenge算法的話,會出現兩個問題:

  • scavenge爲複製算法,重複複製活動對象會使得效率低下
  • scavenge是犧牲空間來換取時間效率的算法,而老生代支持的容量較大,會出現空間資源浪費問題

因此在老生代空間中採用了 Mark-Sweep(標記清除) 和 Mark-Compact(標記整理) 算法。

Mark-Sweep

Mark-Sweep處理時分爲兩階段,標記階段和清理階段,看起來與Scavenge相似,不一樣的是,Scavenge算法是複製活動對象,而因爲在老生代中活動對象佔大多數,因此Mark-Sweep在標記了活動對象和非活動對象以後,直接把非活動對象清除。

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

看似一切 perfect,可是還遺留一個問題,被清除的對象遍及於各內存地址,產生不少內存碎片。

Mark-Compact

因爲Mark-Sweep完成以後,老生代的內存中產生了不少內存碎片,若不清理這些內存碎片,若是出現須要分配一個大對象的時候,這時全部的碎片空間都徹底沒法完成分配,就會提早觸發垃圾回收,而此次回收其實不是必要的。

爲了解決內存碎片問題,Mark-Compact被提出,它是在 Mark-Sweep的基礎上演進而來的,相比Mark-Sweep,Mark-Compact添加了活動對象整理階段,將全部的活動對象往一端移動,移動完成後,直接清理掉邊界外的內存。

全停頓 Stop-The-World

因爲垃圾回收是在JS引擎中進行的,而Mark-Compact算法在執行過程當中須要移動對象,而當活動對象較多的時候,它的執行速度不可能很快,爲了不JavaScript應用邏輯和垃圾回收器的內存資源競爭致使的不一致性問題,垃圾回收器會將JavaScript應用暫停,這個過程,被稱爲全停頓(stop-the-world)。

在新生代中,因爲空間小、存活對象較少、Scavenge算法執行效率較快,因此全停頓的影響並不大。而老生代中就不同,若是老生代中的活動對象較多,垃圾回收器就會暫停主線程較長的時間,使得頁面變得卡頓。

優化 Orinoco

orinoco爲V8的垃圾回收器的項目代號,爲了提高用戶體驗,解決全停頓問題,它利用了增量標記、懶性清理、併發、並行來下降主線程掛起的時間。

增量標記 - Incremental marking

爲了下降全堆垃圾回收的停頓時間,增量標記將本來的標記全堆對象拆分爲一個一個任務,讓其穿插在JavaScript應用邏輯之間執行,它容許堆的標記時的5~10ms的停頓。增量標記在堆的大小達到必定的閾值時啓用,啓用以後每當必定量的內存分配後,腳本的執行就會停頓並進行一次增量標記。

懶性清理 - Lazy sweeping

增量標記只是對活動對象和非活動對象進行標記,惰性清理用來真正的清理釋放內存。當增量標記完成後,假如當前的可用內存足以讓咱們快速的執行代碼,其實咱們是不必當即清理內存的,能夠將清理的過程延遲一下,讓JavaScript邏輯代碼先執行,也無需一次性清理完全部非活動對象內存,垃圾回收器會按需逐一進行清理,直到全部的頁都清理完畢。

增量標記與惰性清理的出現,使得主線程的最大停頓時間減小了80%,讓用戶與瀏覽器交互過程變得流暢了許多,從實現機制上,因爲每一個小的增量標價之間執行了JavaScript代碼,堆中的對象指針可能發生了變化,須要使用寫屏障技術來記錄這些引用關係的變化,因此也暴露出來增量標記的缺點:

  • 並無減小主線程的總暫停的時間,甚至會略微增長
  • 因爲寫屏障(Write-barrier)機制的成本,增量標記可能會下降應用程序的吞吐量

併發 - Concurrent

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

並行 - Parallel

並行式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代碼的執行。

結束

其實,大部分JavaScript開發人員並不須要考慮垃圾回收,可是瞭解一些垃圾回收的內部原理,能夠幫助你瞭解內存的使用狀況,根據內存使用觀察是否存在內存泄露,而防止內存泄露,是提高應用性能的一個重要舉措。

參考文獻

相關文章
相關標籤/搜索