V8垃圾回收機制總結

前方提醒: 篇幅較長,點個贊或者收藏一下,能夠在下一次閱讀時方便查找node

V8的垃圾回收機制

JavaScript是由垃圾回收機制自動進行內存管理的,在咱們編寫代碼的過程當中不須要像C/C++程序員那樣時刻關注內存的分配和釋放問題。在chrome瀏覽器或者node中,這些工做都是交給V8的垃圾回收器自動完成的。程序員

接下來咱們來了解一下V8是如何幫助咱們進行垃圾回收的。算法

1. V8是如何存儲數據的?

在瞭解垃圾回收以前,須要先了解數據是如何保存的。chrome

V8中將內存分爲棧空間堆空間數組

  • 棧空間中的變量保存原始類型的數據和引用類型的數據在堆空間中的地址
  • 堆空間中保存引用類型的數據
var a = 1;
var b = {num: 2};
var c = 3;
var d = b;
複製代碼

上述代碼在內存中的保存形式如圖:瀏覽器

image.png

咱們所要討論的垃圾回收都是基於堆空間的。markdown

2. 什麼是"垃圾"?

有些數據被使用以後,就不在被須要了,但仍是保存在內存中,這樣的無用數據就是垃圾併發

修改上面的代碼:函數

var a = 1;
var b = {num: 2};
var c = 3;
var d = b;
b = {num: 4};
d = b;
複製代碼

此時,棧空間和堆空間變爲以下的狀況:oop

image.png

堆空間中地址爲0x001的對象沒有被任何變量所引用,它就變成了垃圾數據,能夠被垃圾回機制回收。

2.1 如何判斷須要回收的內容

目前V8採用的可訪問性(reachability)算法來判斷對象是不是活動對象,這個算法是將一些GC Root根對象)做爲初始存活的對象的集合,從GC Roots對象出發,遍歷GC Root中的全部對象:

  • 經過GC Root能訪問到的對象,咱們就認爲該對象是可訪問的,那麼必須保證這些對象應該在內存中保留,這些對象爲活動對象
  • 經過GC Roots不能訪問到的對象就可能被回收,這些對象爲非活動對象

GC Root有不少,一般包括瞭如下幾種:

  • 全局對象window、global
  • DOM樹,由能夠經過遍歷文檔到達的全部原DOM節點組成
  • 存放在棧上變量
window.test = new Object();
window.test.a = [];
複製代碼

執行上述代碼,內存中的狀況以下圖所示:

image.png

再將另外一個對象賦值給a屬性:

window.test.a = new Object();
複製代碼

image.png

此時堆中的數組就成爲了爲非活動對象,由於咱們沒法從一個GC Root遍歷到這個數組,垃圾回收機制會把它自動清理。

3. 代際假說(The Generational Hypothesis)

代際假說是垃圾回收領域中一個重要的術語,它有如下兩個特色:

  • 一是大部分對象在內存中存在的時間很短,就是說不少對象一經分配內存,當即被使用後,很快就不在被須要了。好比函數局部變量,或者塊級做用域中的變量
  • 二是不死的對象,會存活得好久。好比全局變量、 window、DOM等對象

V8的垃圾回收機制,就是創建在代際假說的基礎之上的。接下來,咱們來分析下 V8 是如何實現垃圾回收的。

4. 新生代和老生代

在實際的應用中,對象生存週期長短不一,不一樣的垃圾回收算法只針對特定狀況具備最好的效果,針對這種狀況,V8將堆內存分爲新生代老生代兩個區域,新生代中存放的是生存時間短的對象,老生代中存放生存時間久的對象。V8對兩個區域使用不一樣的垃圾回收器,以便達到最好的效果。

  • 新生代在64位系統中大小爲64M,32位系統中大小爲32M
  • 老生代在64位系統中大小爲1400M,32位系統中大小爲700M
  • 副垃圾回收器 - Minor GC,主要負責新生代的垃圾回收
  • 主垃圾回收器 - Major GC,主要負責老生代的垃圾回收

image.png

5. 新生代的垃圾回收

新生代中的大部分對象在內存中存活的週期很短,且回收頻繁,因此須要一個效率很是高的算法。副垃圾回收器使用Scavenge算法進行處理,該算法把新生代空間對半劃分爲兩個區域,一半是From空間,處於使用狀態;一半是To空間,處於閒置狀態。

image.png

在新生代分配內存很是容易,咱們只須要保存一個指向內存區的指針,不斷根據新對象的大小進行指針的遞增便可。當該指針到達了新生代內存區的末尾,就須要一次清理。

5.1 Scavenge算法

Scavenge算法是一個空間換時間的複製算法,在佔用空間不大的場景上很是適用。 新加入的對象會存放到From空間,當From空間快被寫滿時,就須要執行一次垃圾清理操做,大體的步驟以下:

  1. 檢查From空間的活動對象,將其複製到To空間
  2. 釋放掉From空間中的非活動對象
  3. 完成複製和內存釋放後,將From空間和To空間的角色進行對換

Scavenge算法僞代碼:

def scavenge(): // From和To進行交換 swap(fromSpace, toSpace) // 在To空間中維護兩個指針allocationPtr和scanPtr // allocationPtr指向新對象要複製到的地方 // scanPtr指向即將要進行掃描的對象 allocationPtr = toSpace.bottom
    scanPtr = toSpace.bottom
    
    // 處理根對象可以直接訪問的對象
    for i = 0..len(roots):
        root = roots[i]
        if inFromSpace(root):
            // 將根對象能直接訪問到的對象root複製到To空間中allocationPtr指向的地方,並根據root的大小更新allocationPtr
            rootCopy = copyObject(&allocationPtr, root)
            // 更新root的地址
            setForwardingAddress(root, rootCopy)
            roots[i] = rootCopy

    // 採用BFS的遍歷方式,開始遍歷全部能到達的對象
    while scanPtr < allocationPtr:
        obj = object at scanPtr
        // 每處理一個對象,scanPtr就向後移動
        scanPtr += size(obj)
        n = sizeInWords(obj)
        // 處理obj的全部子節點
        for i = 0..n:
            if isPointer(obj[i]) and not inOldSpace(obj[i]):
                fromNeighbor = obj[i]
                // 若是對象已經被複制到To空間,取它在To空間的地址
                if hasForwardingAddress(fromNeighbor):
                  toNeighbor = getForwardingAddress(fromNeighbor)
                // 若是對象不在To空間,將其複製到To空間allocationPtr所指的位置,並根據該對象的大小更新allocationPtr
                else:
                  toNeighbor = copyObject(&allocationPtr, fromNeighbor)
                  setForwardingAddress(fromNeighbor, toNeighbor)
                obj[i] = toNeighbor
    // 當scanPtr == allocationPtr時,全部能到達的對象被處理完成,都被複制到了To空間,此時From空間將被清理

def copyObject(*allocationPtr, object):
  copy = *allocationPtr
  // 根據對象大小更新allocationPtr
  *allocationPtr += size(object)
  // 將object複製到copy指向的位置,也就是更新以前的allocationPtr位置
  memcpy(copy, object, size(object))
  return copy
複製代碼

Scavenge算法過程:

  • 上述僞代碼若是很差理解的話,能夠看以下的例子:
var A = {C: {}};
var B = {
 D: {
     F: {},
     G: {}
 },
 E: {}
};
複製代碼
  • 該代碼表示的引用關係如圖:

image.png

  • 再執行:
delete A.C;
複製代碼
  • 變成了下圖所示的狀況:

image.png

  • 當使用Scavenge算法開始進行垃圾回收前,To空間的狀況以下所示:

image.png

  • 開始進行垃圾回收後:

    1.將根對象能到達的A、B複製到To,並後移allocationPtr

image.png 2.查看scanPtr指向的A對象,因爲A沒有指向其餘對象,因此將scanPtr後移 image.png 3.查看scanPtr指向的B對象,發現B可以訪問到D、E,將D和E依次複製到To空間,並移動allocationPtr和scanPtr
image.png
4.查看scanPtr指向的D對象,發現D可以訪問到F、G,將F和G依次複製到To空間,並移動allocationPtr和scanPtr
image.png 5.查看scanPtr指向的E對象,發現其沒有指向其餘對象,因此將scanPtr後移 image.png 6.依次查看F對象和G對象,發現其都沒有指向其餘對象,繼續將scanPtr後移
image.png 7.scanPtr和allocationPtr相等時,說明可訪問的對象都已經被處理完成,From空間中剩餘的C變量將被釋放

Scavenge算法的優缺點:

  • 優勢:只複製活動對象,生命週期短的場景種活動對象只佔不多一部分,因此執行效率很高;複製過程當中就能完成內存整理,避免產生內存碎片
  • 缺點:浪費一半新生代的空間

5.2 晉升策略

在必定的條件下,須要把存活週期長的對象移動到老生代中,也就是完成了對象的晉升。在從From空間複製到To空間前,會進行下面的步驟:

  1. 檢查對象的內存地址來判斷這個對象是否已經經歷過一次Scavenge回收,若是是,則複製到老生代中,不然複製到To空間中
  2. 若是To空間已經被使用了超過25%,這個對象直接被複制到老生代

image.png

6. 老生代的垃圾回收

Scavenge算法會浪費一半空間,所以Scavenge算法並不適用於老生代空間,V8在老生代中的垃圾回收是採用了標記 - 清除Mark- Sweep)和標記 - 整理Mark - Compact)兩種算法相結合的方式進行的。

6.1 標記 - 清除(Mark-Sweep)

顧名思義, 標記 - 清除算法分爲兩個過程:

  • 標記過程:從根對象觸發,對全部能夠訪問到的對象進行標記,沒有訪問到的對象就是要回收的數據
  • 清除過程:直接清除掉上一步中沒有被標記到的對象

image.png 因爲清除階段只是清除未被標記的對象,這部分對象在老生代中佔比很小,因此標記 - 清除算法的效率較高。

6.2 標記 - 整理 (Mark-Compact)

標記 - 清除算法執行後,內存中會產生大量不連續的內存碎片,這樣會致使內存中沒有足夠的連續內存分配給較大的對象,因而V8又引入了另一種算法:標記 - 整理Mark - Compact)。

它的標記過程和標記 - 清除算法一致,但接下來標記 - 整理算法不是直接對未標記的對象進行清除,而是讓全部標記過的對象都移向內存的一端,而後直接清理掉這一端以外的內存,起到了整理內存的做用。

image.png

6.3 結合使用兩種算法

因爲標記 - 整理算法須要移動對象,所以它的速度不會很快,V8結合了標記 - 清除和標記 - 整理算法,主要採用標記 - 清除算法,若是空間不足的時候,才使用標記整理。

7. V8垃圾回收的優化策略

接下來學習V8是如何優化垃圾回收的執行效率的。

7.1 全停頓(Stop-The-World)

最初,爲了不js邏輯和垃圾回收器看到的狀況不一致的問題,V8採用了垃圾回收時將js執行暫停下來的方式,等待垃圾回收結束後才恢復js的執行,這種行爲被成爲全停頓Stop-The-World)。

image.png

這種方式的劣勢明顯,它會阻塞js的執行,若是垃圾回收佔用的時間較長,就會形成頁面明顯的卡頓。爲了解決全停頓的問題,V8添加並行、增量、併發等技術對垃圾回收機制進行了優化。

接下來分別針對這三種優化方式作出解釋。

7.2 並行回收(Parallel)

並行方式是主線程在執行垃圾回收的任務的同時,使用多個輔助線程來並行處理,這樣就會加快垃圾回收的執行速度。

image.png

新生代中副垃圾回收器採用的就是並行方式,它在主線程執行垃圾回收的過程當中,啓動了多個輔助線程來負責垃圾清理操做,這些輔助線程同時將From空間中的數據移動到To區域。但本質上並行方式仍是一種"全停頓",所以還不能知足對性能要求更高的老生代垃圾回收。

7.3 增量回收 (Incremental)

2011年,V8 從又引入了增量回收Incremental)的方式。垃圾回收器不須要一次執行完整的垃圾回收過程,每次只執行整個垃圾回收過程當中的一小部分工做,好比每次標記一部分數據,能夠參考下圖:

image.png

主線程中,js和垃圾回收交替執行,能夠避免單次垃圾回收時間過長形成的卡頓問題。

增量回收Incremental)會帶來兩個問題:

  • 垃圾回收和js切換執行,暫停垃圾回收時須要保存當時的標記結果,切換回來以後須要知道從哪一個位置繼續執行
  • 切換到js執行後,js代碼的執行可能會修改以前已經標記好的數據,形成影響

針對上面的兩個問題,V8引入了三色標記法寫屏障機制Write-barrier)來解決。

7.3.1 三色標記法

爲了解決增量回收中垃圾回收恢復執行時不知道從哪一個位置繼續開始執行的問題,V8採用黑、白、灰三色標記法

  • 標記爲黑色表示這個節點已經被訪問到了,並且該節點的子節點都已經標記完成了
  • 標記爲灰色表示這個節點被訪問到了,但子節點還沒被標記處理,也代表了目前正在處理這個節點
  • 白色表示這個節點沒有被訪問到,若是在本輪垃圾回收結束時仍是白色,那麼這塊數據就會被收回。初始階段,全部節點都是白色。

垃圾回收器能夠根據當前是否存在灰色節點來判斷整個標記是否完成。若是沒有灰色節點了,就能夠清理掉白色節點了。若是還有灰色標記,當再次恢復垃圾回收時,便從灰色的節點開始繼續執行。

image.png

image.png

image.png

7.3.2 寫屏障機制(Write-barrier)

垃圾回收器將某個節點標記爲黑色後,js代碼執後又爲該黑色節點增長了一個節點,因爲新增節點都是白色,垃圾回收器不會再次將這個白色節點進行標記了,它就會被垃圾回收器回收。

執行了:

window.a = Object()
window.a.b = Object()
window.a.b.c=Object() 
複製代碼

image.png

又只執行了:

window.a.b = Object() // d
複製代碼

image.png

這時就出現了黑色節點指向白色節點的問題,會形成新增節點的誤回收。寫屏障機制就是要強制保證黑色節點不能指向白色節點。

在執行object.field = value時,V8就插入寫屏障代碼,強制將value標記爲灰色。

// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}
複製代碼

7.4 併發回收 (Concurrent)

併發是指主線程不斷執行js代碼,而輔助線程則在後臺徹底執行垃圾回收。

image.png

併發回收主要有如下兩個問題:

  • 當主線程執行js代碼時,堆中的內容隨時都有可能發生變化,從而使得輔助線程以前作的工做徹底無效
  • 主線程和輔助線程極有可能在同一時間去更改同一個對象,這就須要額外實現讀寫鎖的一些功能

可是權衡利弊,並行回收這種方式的效率仍是遠高於其餘方式。

7.5 三種方式組合

並行、增量、併發三種方式在V8的實際應用中不是單獨存在的,V8的主垃圾回收器融合了這三種機制。

image.png

  • 主垃圾回收器主要使用併發標記,在主線程執行js時,輔助線程就開始執行標記任務了,因此說標記是在輔助線程中完成的
  • 標記完成後,開始執行並行清理。主線程在執行清理操做時,多個輔助線程也在執行清理操做。
  • 主垃圾回收器還採用了增量標記的方式,清理的任務會穿插在各類js代碼執行之間

8. 總結

本文從V8的數據存儲、內存分代、垃圾回收算法、優化策略幾個方面進行了講解,雖然內容很多,但仍是忽略不少細節,V8垃圾回收機制的細節很是複雜。咱們大多數開發人員在開發JavaScript時不須要需考慮垃圾回收,可是瞭解一些垃圾回收的內部知識能夠幫助咱們考慮內存使用的狀況,更好的進行內存問題的分析、排查和解決,讓咱們在快節奏的技術迭代中把握本質。

參考資料

相關文章
相關標籤/搜索