JavaScript 垃圾回收機制

JavaScript 自動垃圾收集機制

垃圾回收又稱爲 GC(Garbage Collecation)。編寫 JavaScript 程序時,開發者不須要手工跟蹤內存的使用狀況,只要按照標準寫 JavaScript 代碼,JavaScript 程序運行所需內存的分配以及無用內存的回收徹底是自動管理。JavaScript 中自動垃圾回收機制的原理爲:javascript

找出那些再也不使用的變量,而後釋放其佔用的內存。
垃圾收集器會按照固定的時間間隔(或預約的收集時間)週期性地執行此操做。html

局部變量的正常生命週期

局部變量只在函數執行的過程當中存在
在函數執行過程當中,會爲局部變量在棧內存(或 堆內存)上分配相應的空間來存儲它們的值。在函數中使用這些變量,直至函數執行結束,此時能夠釋放局部變量的內存供未來須要時使用。
以上狀況下,較容易判斷變量是否有存在的必要,更復雜的狀況須要更精細的變量追蹤策略。
JavaScript 中的垃圾收集器必須跟蹤每一個變量是否有用,須要爲再也不有用的變量打上標記,用於未來回收其佔用的內存。標識無用變量的策略一般有兩個:標記清除引用計數java

JavaScript 中的棧內存與堆內存

上述過程當中,JavaScript 中變量分爲 基本類型值引用類型值python

  • 基本類型值 在內存中佔固定大小的空間,所以被保存在 棧內存 中;
  • 引用類型值 是對象,保存在 堆內存 中。包含引用類型值的變量實際包含並不是對象自己,而是指向該對象的指針。一個變量從另外一個變量複製引用類型的值時,複製的也是指向該對象的指針。

標記清除

標記清除(mark-and-sweep) 是 JavaScript 中最經常使用的垃圾回收方式。其執行機制以下:git

  • 當變量進入環境時,就將其標記爲「進入環境」
  • 當變量離開環境時將其標記爲「離開環境」

邏輯上,永遠不能釋放進入環境的變量所佔用的內存,由於執行流進入相應的環境時,可能會用到它們。
標記變量的方式有不少種,可使用標記位的形式記錄變量進入環境,也可單獨爲「進入環境」和「離開環境」添加變量列表來記錄變化。github

標記清除採用的收集策略爲:算法

  • JavaScript中的垃圾收集器運行時會給存儲在內存中的全部變量都加上標記;
  • 而後去掉環境中的變量以及被環境中的變量引用的變量的標記;
  • 此後,再被加上標記的變量被視爲準備刪除的變量;
  • 最後,垃圾收集器完成內存清除,銷燬那些帶標記的值並回收其佔用的內存空間。

2008年以前,IE、Firefox、Opera、Chrome 和 Safari 的 JavaScript實現使用的均爲 標記清除式的垃圾回收策略,區別可能在垃圾收集的時間間隔。segmentfault

引用計數

引用計數(reference counting) 是另外一種垃圾收集策略。引用計數的本質是 跟蹤記錄每一個值被引用的次數。其執行機制以下:數組

  • 當聲明一個變量並將一個引用類型值賦值給該變量時,這個值的引用次數爲1;
  • 若同一個值(變量)又被賦值給另外一個變量,則該值的引用次數加1;
  • 可是若是包含對這個值引用的變量又取得了另一個值,則這個值的引用次數減1;
  • 當這個值的引用次數爲0時,則沒法再訪問這個值,就可回收其佔用的內存空間。

垃圾收集器下次運行時,會釋放那些引用次數爲零的值所佔用的內存。
引用計數存在一個致命的問題: 循環引用。循環引用是指,對象 A 中包含一個指向對象 B 的指針,而對象 B 中也包含一個指向對象 A 的引用。下面的代碼就是標準的循環引用的例子:瀏覽器

function cycleRefernce() {
    var objectA = new Object();
    var objectB = new Object();
    
    objectA.someOtherObject = objectB;
    objectB.anotherObject = objectA;
}
複製代碼

上述例子中 objectA 和 objectB 經過各自屬性相互引用。按照引用計數的策略,兩個對象的引用次數均爲 2。若採用標記清除策略,函數執行完畢,對象離開做用域就不存在相互引用。但採用引用計數後,函數執行完,兩個對象的引用次數永不爲0,會一直存尊內存中,若屢次調用,致使大量內存得不到回收。

IE8瀏覽器 以前中有一部分對象並非原生的 JavaScript 對象,多是使用 C++ 以 COM 對象的形式實現的(BOM, DOM)。而 COM 對象的垃圾收集機制採用的是 引用計數策略。即便 IE 的 JavaScript 引擎是使用標記清除策略實現的,但 JavaScript 訪問 COM 對象仍然是基於 引用計數策略的。在這種狀況下,只要在 IE 中涉及 COM 對象,就可能存在循環引用的問題。

爲避免出現循環引用,最好在不使用這些對象時,手動斷開 原生 JavaScript 對象 與 DOM 元素之間的鏈接。IE中的循環引用與手動斷開的操做以下所示:

var element = document.getElementById("some_element");
var myObject = new Object();
myObject.element = element;
element.someObject = myObject;
// 以上 存在循環引用
// ...... 
// 如下 手工斷開鏈接
myObject.element = null;
element.someObject =null;
複製代碼

將變量設置成 null 便可切斷變量與它以前引用的值之間的鏈接。下次垃圾收集器運行時,會刪除這些值並回收它們佔用的內存。
爲解決上述問題,IE9及以上版本把 BOM 和 DOM 對象都轉換成了真正的 JavaScript 對象,避免了兩種垃圾回收算法並存引發的問題。

垃圾回收的性能問題

垃圾收集器是週期運行的,肯定 垃圾收集的時間間隔 是個重要的問題。

IE7以前的垃圾收集器是根據內存分配量運行的,即 256 個變量、4096 個對象(數組)字面量或 64 KB 的字符串。達到這些臨界值的任何一個,垃圾收集器就會運行。因此就致使若是一個腳本含有不少變量,在整個生命週期中一直保有前面臨界值大小的變量,就會頻繁觸發垃圾回收,會存在嚴重的性能問題。

IE7 重寫了垃圾收集例程。新的工做方式爲:觸發垃圾收集的變量分配、字面量和數組元素的臨界值被調整爲 動態修正。初始值與以前版本相同,但若是垃圾收集例程回收的內存低於 15%,則臨界值加倍。若回收內存分配量超過 85%,則臨界值重置回默認值。

JavaScript V8 引擎的垃圾回收機制

在JavaScript腳本中,絕大多數對象的生存期很短,只有部分對象的生存期較長。因此,V8 中的垃圾回收主要使用的是 分代回收 (Generational collection)機制。

分代回收機制

V8 引擎將保存對象的 (heap) 進行了分代:

  • 對象最初會被分在 新生區(New Space) (1~8M),新生區的內存分配只須要保有一個指向內存區的指針,不斷根據內存大小進行遞增,當指針達到新生區的末尾,會有一次垃圾回收清理(小週期),清理掉新生區中再也不活躍的死對象。
  • 對於超過 2 個小週期的對象,則須要將其移動至 老生區(Old Space)。老生區在 標記-清除 或 標記-緊縮 的過程(大週期) 中進行回收。

大週期進行的並不頻繁。一次大週期一般是在移動足夠多的對象至老生區後纔會發生。

Scavenge 算法

因爲垃圾清理髮生的比較頻繁,清理的過程必須很快。V8 中的清理過程使用的是 Scavenge 算法,按照 經典的 Cheney 算法 實現的。Scavenge 算法的主要過程是:

  • 新生區被分爲兩個等大小的子區(semi-spaces):to-space 和 from-space;
  • 大多數的內存分配都是在 to-space 發生 (某些特定對象是在老生區);
  • 當 to-space 耗盡時,交換 to-space 和 from-space, 此時全部的對象都在 from-space;
  • 而後將 from-space 中活躍的對象複製到 to-space 或者老生區中;
  • 這些對象被直接壓到 to-space,提高了 Cache 的內存局部性,可以使內存分配簡潔快速。

算法的僞代碼描述以下:

def scavenge():
  swap(fromSpace, toSpace)
  allocationPtr = toSpace.bottom
  scanPtr = toSpace.bottom

  for i = 0..len(roots):
    root = roots[i]
    if inFromSpace(root):
      rootCopy = copyObject(&allocationPtr, root)
      setForwardingAddress(root, rootCopy)
      roots[i] = rootCopy

  while scanPtr < allocationPtr:
    obj = object at scanPtr
    scanPtr += size(obj)
    n = sizeInWords(obj)
    for i = 0..n:
      if isPointer(obj[i]) and not inOldSpace(obj[i]):
        fromNeighbor = obj[i]
        if hasForwardingAddress(fromNeighbor):
          toNeighbor = getForwardingAddress(fromNeighbor)
        else:
          toNeighbor = copyObject(&allocationPtr, fromNeighbor)
          setForwardingAddress(fromNeighbor, toNeighbor)
        obj[i] = toNeighbor

def copyObject(*allocationPtr, object):
  copy = *allocationPtr
  *allocationPtr += size(object)
  memcpy(copy, object, size(object))
  return copy
複製代碼

不能被忽視的寫屏障 Write barriers

若是新生區有某個對象,只有一個指向它的指針,剛好該指針在老生區的對象中,在垃圾回收以前咱們如何得知新生區的該對象是活躍的呢?
爲解決此問題,V8 在寫緩衝區有一個列表,其中記錄了全部老生區對象指向新生區的狀況。新生區對象誕生時不會有指向它的指針,當老生區的對象出現指向新生區對象的指針時,便記錄跨區指向,記錄行爲老是發生在寫操做中。

標記-清除算法 與 標記-緊縮算法

由於新生區的內存通常都不大,因此使用 Scavenge 算法進行垃圾回收效果比較好。老生區通常佔用內存較大,所以採用的是 標記-清除(Mark-Sweep)算法 與 標記-緊縮(Mark-Compact)算法。

兩種算法都包括兩個階段:標記階段,清除或緊縮階段。

標記階段

在標記階段,堆上全部的活躍對象都會被發現而且標記。

  • 每一頁都包含用來標記的位圖
  • 位圖都要佔據空間 (3.1% on 32-bit, 1.6% on 64-bit systems)
  • 使用兩位二進制標記對象的狀態
  • 狀態爲白(white), 它還沒有被垃圾回收器發現
  • 狀態爲灰(gray), 它已被垃圾回收器發現,但它的鄰接對象仍未所有處理完畢
  • 狀態爲黑(black), 它不只被垃圾回收器發現,並且其全部鄰接對象也都處理完畢

標記算法的核心是 深度優先搜索具體過程爲:

  • 在標記的初期,位圖是空的,全部對象也都是白的。
  • 從根可達的對象會被染色爲灰色,並被放入標記用的一個單獨分配的雙端隊列。
  • 標記階段的每次循環,GC會將一個對象從雙端隊列中取出,染色爲黑,而後將它的鄰接對象染色爲灰,並把鄰接對象放入雙端隊列。
  • 這一過程在雙端隊列爲空且全部對象都變黑時結束。
  • 特別大的對象,如長數組,可能會在處理時分片,以防溢出雙端隊列。若是雙端隊列溢出了,則對象仍然會被染爲灰色,但不會再被放入隊列(這樣他們的鄰接對象就沒有機會再染色了)。
  • 所以當雙端隊列爲空時,GC仍然須要掃描一次,確保全部的灰對象都成爲了黑對象。對於未被染黑的灰對象,GC會將其再次放入隊列,再度處理。

標記算法結束後,全部的活躍對象都被染成黑色,全部的死對象還是白的。下一步就能夠清除或者緊縮了。

清除 或 緊縮 算法

標記算法執行後,能夠選擇清除 或是緊縮,這兩個算法均可以收回內存,並且二者都做用於頁級(V8 中的內存頁是 1MB 的連續內存塊)

清除算法掃描連續存放的死對象,將其變爲空閒空間,並將其添加到空閒內存鏈表中。清除算法只須要遍歷頁的位圖,搜索連續的白對象。[每一頁都包含數個空閒內存鏈表,其分別表明小內存區(<256字)、中內存區(<2048字)、大內存區(<16384字)和超大內存區(其它更大的內存)]

緊縮算法會嘗試將對象從碎片頁(包含大量小空閒內存的頁)中遷移整合在一塊兒,來釋放內存。這些對象會被遷移到另外的頁上,所以也可能會新分配一些頁。而遷出後的碎片頁就返還給操做系統。

對目標碎片頁中的每一個活躍對象,在空閒內存鏈表中分配一塊其它頁的區域,將該對象複製至新頁,並在碎片頁中的該對象上寫上轉發地址。
遷出過程當中,對象中的舊地址會被記錄下來,這樣在遷出結束後V8會遍歷它所記錄的地址,將其更新爲新的地址。因爲標記過程當中也記錄了不一樣頁之間的指針,此時也會更新這些指針的指向。

增量標記 與 惰性清除

對於一個堆很大,活躍對象有不少的腳本時,標記-清除 與 標記-緊縮 的效率可能會很慢,爲減小垃圾回收引發的停頓,引入了 增量標記(Incremental marking) 和 惰性清理(lazy sweeping)。

增量標記容許堆的標記(前面的標記階段)發生在幾回5-10毫秒的小停頓中。增量標記在堆的大小達到必定的閾值時啓用,啓用以後每當必定量的內存分配後,腳本的執行就會停頓並進行一次增量標記。就像普通的標記同樣,增量標記也是一個深度優先搜索,並一樣採用白灰黑機制來分類對象。
增量標記與普通標記的區別是,添加了從黑對象到白對象的指針,爲此須要再次啓用寫屏障中,在記錄 老->新 的同時,記錄 黑->白。在進行清除時,一旦在寫屏障中發現這樣的指針,黑對象會被從新染色爲灰對象,從新放回到雙端隊列中。

惰性清理是指在標記完成後,並不急着釋放空間,無需一次清理全部的頁,垃圾回收器會視狀況逐一清理,直到全部頁都清理完成。

餘下的涉及垃圾回收原理的部分留着後面繼續整理。(平行標記 與 併發標記)

文章說明

此文章最初記錄在本人2019.02.21的博客 JavaScript垃圾回收機制中,如今轉出來供你們一塊兒學習交流,若有不許確的地方請你們批評指正。

參考資料

相關文章
相關標籤/搜索