談談 GC:新的 Orinoco 垃圾收集器

過去這些年 V8 的垃圾回收器(GC)發生了不少的變化,Orinoco 項目採用了 stop-the-world 垃圾回收器,以使其變成了一個更加並行,併發和增量的垃圾回收器。html

不論什麼垃圾回收器都有一些按期須要去作的任務:算法

  1. 標記活動對象(live objects)和非活動對象(dead objects)
  2. 回收或者重用被非活動對象佔據的內存
  3. 合併或者整理內存(可選)

這些任務能夠按順序執行,也能夠任意交叉執行。一種方式是暫停JavaScript的執行,並在主線程上按順序執行這些任務。這可能會致使主線程上的JavaScript延遲執行,以及頁面渲染時 JavaScript 來不及執行致使的頁面空白或者卡頓問題(jank)。這個問題在以前的兩篇文章()已經討論過,這樣作同時也會下降JavaScript執行的吞吐量編程

主垃圾回收 —— 全量標記和整理

主垃圾回收從整個(heap)中收集垃圾。瀏覽器

主垃圾回收器主要有三個階段:標記(marking),清除(sweeping)和整理(compacting)

標記階段

找出哪些對象能夠被回收是垃圾回收中重要的一步。垃圾回收器經過可訪問性(reachability)來肯定對象的 「活躍度」(liveness)。這意味着任何對象若是在運行時是可訪問的(reachable),那麼必須保證這些對象應該在內存中保留,若是對象是不可訪問的(unreachable)那麼這些對象就可能被回收。數據結構

標記階段就是找到可訪問對象的一個過程;垃圾回收是從一組對象的指針(objects pointers)開始的,咱們將其稱之爲根集(root set),這其中包括了執行堆棧和全局對象;而後垃圾回收器會跟蹤每個指向 JavaScript 對象的指針,並將對象標記爲可訪問的,同時跟蹤對象中每個屬性的指針並標記爲可訪問的,這個過程會遞歸地進行,直到標記到運行時每個可訪問的對象。併發

清楚階段

清除階段就是將非活動對象佔用的內存空間添加到一個叫空閒列表(free-list)的數據結構中。一旦標記完成,垃圾回收器會找到不可訪問對象的內存空間,並將內存空間添加到相應的空閒列表中。空閒列表中的內存塊由大小來區分,爲何這樣作呢?爲了方便之後須要分配內存,就能夠快速的找到大小合適的內存空間並分配給新的對象。編程語言

整理階段

主垃圾回收器會經過一種叫作碎片啓發式(fragmentation heuristic)的算法來整理內存頁),你能夠將整理階段理解爲老式 PC 上的磁盤整理。那麼碎片啓發式算法是怎麼作的呢?咱們將活動對象複製到當前沒有被整理的其餘內存頁中(即被添加到空閒列表的內存頁);經過這種作法,咱們就能夠利用內存中高度小而分散的內存空間。佈局

垃圾回收器複製活動對象到當前沒有被整理的其餘內存頁中有一個潛在的缺點,咱們要分配內存空間給不少常駐內存( long-living)的對象時,複製這些對象會帶來很高的成本。這就是爲何咱們只選擇整理內存中高度分散的內存頁,而且對其餘內存頁咱們只進行清除而不是也一樣複製活動對象的緣由。優化

分代佈局

V8中的堆被劃分爲不一樣的區域,咱們將其稱之爲代(generations);這兩塊區域分別稱之爲老生代(old generation)和新生代(young generation),新生代又進一步分爲 ‘nursery’ 子代和 ‘intermediate’ 子代兩塊區域; 一個對象第一次分配內存時會被分配到新生代中的‘ nursery’ 子代;若是進過下一次垃圾回收這個對象還存在新生代中,這時候咱們移動到 ‘intermediate’ 子代,再通過下一次垃圾回收這個對象還在新生代,這時候咱們就會把這個對象移動到老生代。動畫

V8中堆分紅兩代,若是通過垃圾回收對象還存活的話會重新生代移動到老生代

在垃圾回收中有一個重要的術語:「代際假設」(The Generational Hypothesis);代際假說代表不少對象在內存中存在的時間很短(die young)。換句話說,從垃圾回收的角度來看,不少對象一經分配內存空間隨即就變成了不可訪問的。這個假說不只僅適用於 V8 和 JavaScript,一樣適用於大多數的動態語言。

V8分代堆佈局的設計主要是爲了利用對象存在生命週期的這個事實;垃圾回收實質上就是整理內存和移動內存中的對象,那這就意味着咱們應該多移動對象到空閒列表中的內存中去;這個看上去彷佛有點違反直覺,由於在垃圾回收的時候複製對象的成本很高。可是根據代際假說在垃圾回收中,在內存中存活下來的對象其實並非不少。因此從新分配內存給新建立的對象,這反而變成了隱式的垃圾;這就意味着咱們只需花費複製存活對象的成本,並不須要耗費成本去分配新的內存。

副垃圾回收器 —— 清道夫(Scavenger)

V8 有兩個垃圾回收器,主垃圾回收器(Full Mark-Compact)從整個堆中回收垃圾,副垃圾回收器(Scavenger) 重新生代中回收垃圾。主垃圾回收器能夠頗有效的從整個堆中回收垃圾,可是代際假說告訴咱們新分配內存的對象也極有可能須要垃圾回收。

副垃圾回收器只重新生代中回收垃圾,倖存的對象老是會被分配到內存頁中去。V8 爲新生代內存採用了‘半空間’(semi-space)的設計,這意味着爲了作疏散(譯者注:移動對象)這一步驟(evacuation step),有一半的內存空間是空閒的。在清理時,初始的空閒區域稱之爲「To-Space」,複製對象過來的區域稱之爲「From-Space」;在最壞的狀況下,若是每個對象在清理的時候存活了下來,那咱們就要複製每個對象。

對於清理,咱們會維護一個額外的根集(root set),這個根集裏會存放一些從舊到新的引用。這些引用是在舊空間(old-space)中指向新生代中對象的指針。咱們使用「 寫屏障(write barriers)」來維護從舊到新的引用列表,而不是跟蹤整個堆中的每個對象變動。當堆和全局對象結合使用時,咱們知道每個在新生代中對象的引用,而無需追蹤整個老生代。

疏散步驟將全部的活動對象移動到連續的一塊內存中,這樣作的好處就是徹底移除內存碎片(清理非活動對象時留下的內存碎片);而後咱們把兩塊內存空間互換,即把 ‘To-Space’ 變成 ‘From-Space’,反之亦然。一旦垃圾回收完成,新分配的內存空間將從 ‘From-Space’ 下一個空閒內存地址開始。

副垃圾回收器移動活動對象到一個新的內存頁

若是僅僅是憑藉這一策略,咱們就會很快的耗盡新生代的內存空間;爲了新生代的內存空間不被耗盡,在下一次垃圾回收的時候,咱們會把活動對象移動(evacuate)到老生代,而不是 ‘To-Space’。

清理的最後一步是把移動後的對象的指針地址更新,每個被複制對象都會留下一個轉發地址(forwarding-address),用於更新指針以指向新的地址。

副垃圾回收器移動 ‘intermediate’ 子代的活動對象到老生代

副垃圾回收器在清理時,實際上執行三個步驟:標記,移動活動對象,和更新對象的指針;這些都是交錯進行,而不是在不一樣階段。

Orinoco

這些算法和優化在不少垃圾回收相關的文獻或着具備垃圾回收機制的編程語言中都是很是常見的,可是這些先進的垃圾回收機制已經通過了漫長髮展。測量垃圾回收所花費時間的一個重要指標就是執行垃圾回收時主線程掛起的時間。對於傳統的 ‘stop-the-world’ 垃圾回收器來講,垃圾回收所花費的時間能夠直接簡單相加。而這種垃圾回收的方式直接影響了用戶體驗,會直接致使頁面卡頓,渲染延遲等一系列問題。

V8 垃圾回收器 Orinoco 的 LOGO

Orinoco 是 V8 垃圾回收器項目的代號,它利用最新的和最好的垃圾回收技術來下降主線程掛起的時間, 好比:並行(parallel)垃圾回收,增量(incremental)垃圾回收和併發(concurrent)垃圾回收。這裏有一些術語在垃圾回收的上下文中有特定的含義,因此這是值得去詳細的探討的。

並行垃圾回收

並行是主線程和協助線程同時執行一樣的工做,可是這仍然是一種 ‘stop-the-world’ 的垃圾回收方式,可是垃圾回收所耗費的時間等於總時間除以參與的線程數量(加上一些同步開銷)。這是這三種技術中最簡單的 JavaScript 垃圾回收方式;由於沒有 JavaScript 的執行,所以只要確保同時只有一個協助線程在訪問對象就行了。

主線程和協助線程同在一時間作一樣的任務

增量垃圾回收

增量式垃圾回收是主線程間歇性的去作少許的垃圾回收的方式。咱們不會在增量式垃圾回收的時候執行整個垃圾回收的過程,只是整個垃圾回收過程當中的一小部分工做。作這樣的工做是極其困難的,由於 JavaScript 也在作增量式垃圾回收的時候同時執行,這意味着堆的狀態已經發生了變化,這有可能會致使以前的增量回收工做徹底無效。從圖中能夠看出並無減小主線程暫停的時間(事實上,一般會略微增長),只會隨着時間的推移而增加。但這仍然是解決問題的的好方法,經過 JavaScript 間歇性的執行,同時也間歇性的去作垃圾回收工做,JavaScript 的執行仍然能夠在用戶輸入或者執行動畫的時候獲得及時的響應。

垃圾回收任務交錯的進入主線程執行

併發垃圾回收

併發是主線程一直執行 JavaScript,而輔助線程在後臺徹底的執行垃圾回收。這種方式是這三種技術中最難的一種,JavaScript 堆裏面的內容隨時都有可能發生變化,從而使以前作的工做徹底無效。最重要的是,如今有讀/寫競爭(read/write races),主線程和輔助線程極有可能在同一時間去更改同一個對象。這種方式的優點也很是明顯,主線程不會被掛起,JavaScript 能夠自由地執行 ,儘管爲了保證同一對象同一時間只有一個輔助線程在修改而帶來的一些同步開銷。

垃圾回收任務徹底發生在後臺,主線程能夠自由的執行 JavaScript

V8 裏面當前使用的幾種垃圾回收機制

Scavenging

現今,V8 在新生代垃圾回收中使用並行清理,每一個協助線程會將全部的活動對象都移動到 ‘To-Space’。在每一次嘗試將活動對象移動到 ‘To-Space’ 的時候必須通確保原子化的讀和寫以及比較和交換操做。不一樣的協助線程都有可能經過不一樣的路徑找到相同的對象,並嘗試將這個對象移動到 ‘To-Space’;不管哪一個協助線程成功移動對象到 ‘To-Space’,都必須更新這個對象的指針,而且去維護移動這個活動對象所留下的轉發地址。以便於其餘協助線程能夠找到該活動對象更新後的指針。爲了快速的給倖存下來的活動對象分配內存,清理任務會使用線程局部分配緩衝區。

並行清理在主線程和多個協助線程之間分配清理任務

Major GC

V8 中的主垃圾回收器主要使用併發標記,一旦堆的動態分配接近極限的時候,將啓動併發標記任務。每一個輔助線程都會去追蹤每一個標記到的對象的指針以及對這個對象的引用。在 JavaScript 執行的時候,併發標記在後臺進行。寫入屏障(write barriers)技術在輔助線程在進行併發標記的時候會一直追蹤每個 JavaScript 對象的新引用。

主垃圾回收器併發的去標記和清除對象,並行的去整理內存和更新活動對象的指針

當併發標記完成或者動態分配到達極限的時候,主線程會執行最終的快速標記步驟;在這個階段主線程會被暫停,這段時間也就是主垃圾回收器執行的全部時間。在這個階段主線程會再一次的掃描根集以確保全部的對象都完成了標記;而後輔助線程就會去作更新指針和整理內存的工做。並不是全部的內存頁都會被整理,以前提到的加入到空閒列表的內存頁就不會被整理。在暫停的時候主線程會啓動併發清理的任務,這些任務都是併發執行的,並不會影響並行內存頁的整理工做和 JavaScript 的執行。

空閒時垃圾回收器

JavaScript 是沒法去直接訪問垃圾回收器的,這些都是在V8的實現中已經定義好的。可是 V8 確實提供了一種機制讓Embedders(嵌入V8的環境)去觸發垃圾回收,即使 JavaScript 自己不能直接去觸發垃圾回收。垃圾回收器會發布一些 「空閒時任務(Idle Tasks)」,雖然這些任務都是可選的,但最終這些任務會被觸發。像 Chrome 這些嵌入了 V8 的環境會有一些空閒時間的概念。好比:在 Chrome 中,以每秒60幀的速度去執行一些動畫,瀏覽器大約有16.6毫秒的時間去渲染動畫的每一幀,若是動畫提早完成,那麼 Chrome 在下一幀以前的空閒時間去觸發垃圾回收器發佈的空閒時任務。

空閒時垃圾回收器,利用主線程上的空閒時間主動的去執行垃圾回收工做

若是想知道空閒時垃圾回收器更詳細的內容,請看這篇文章 our in-depth publication on idle-time GC

順便談談

V8 的垃圾回收器項目自立項以來已經走過了漫長的道路。向現有的垃圾回收器添加並行、併發和增量垃圾回收技術通過了不少年的努力,而且也已經取得了一些成效。將大量的移動對象的任務轉移到後臺進行,大大減小了主線程暫停的時間,改善了頁面卡頓,讓動畫,滾動和用戶交互更加流暢。Scavenger 回收器將新生代的垃圾回收時間減小了大約 20% - 50%,空閒時垃圾回收器在 Gmail 網頁應用空閒的時候將 JavaScript 堆內存減小了 45%。併發標記清理能夠減小大型 WebGL 遊戲的主線程暫停時間,最多能夠減小 50%。

可是任重而道遠,減小垃圾收集致使主線程暫停的時間,爲用戶提供流暢的體驗是很是重要的,咱們正在研究更高級的技術。最重要的是 Blink(Chrome 的渲染引擎)也有一個垃圾回收器(Oilpan),咱們正在改善兩個垃圾回收器之間的協做,並準備將一些新技術從 V8 的垃圾回收器(Orinoco)移植到 Oilpan 上。

大部分 JavaScript 開發人員並不須要考慮垃圾回收,可是瞭解一些垃圾回收的內部原理,能夠幫助你瞭解內存的使用狀況,以及採起合適的編範式。好比:從 V8 堆內存的分代結構和垃圾回收器的角度來看,建立生命週期較短的對象的成本是很是低的,可是對於生命週期較長的對象來講成本是比較高的。這些模式是適用於不少動態編程語言的,而不只僅是 JavaScript。

原文地址

譯文地址

相關文章
相關標籤/搜索