2022 年 10 月 14 日

垃圾回收

JavaScript 中的記憶體管理會自動且無形地執行。我們會建立基本型別、物件、函式…這些都會佔用記憶體。

當某個東西不再需要時會發生什麼事?JavaScript 引擎如何發現並清除它?

可達性

JavaScript 中記憶體管理的主要概念是可達性

簡單來說,「可達」的值是可以透過某種方式存取或使用的。它們保證會儲存在記憶體中。

  1. 有一組內建的可達值,由於顯而易見的原因,它們無法被刪除。

    例如

    • 目前執行的函式、其局部變數和參數。
    • 目前巢狀呼叫鏈中的其他函式、其局部變數和參數。
    • 全域變數。
    • (還有一些其他的內部物件)

    這些值稱為

  2. 任何其他值如果可以透過參考或參考鏈從根存取,則會被視為可存取。

    例如,如果有一個物件在全域變數中,而且該物件有一個屬性在參考另一個物件,那個物件會被視為可存取。而且它所參考的那些物件也會可存取。稍後會提供詳細範例。

JavaScript 引擎中有一個背景處理程序,稱為垃圾收集器。它會監控所有物件,並移除那些已變成不可存取的物件。

一個簡單範例

以下是最簡單的範例

// user has a reference to the object
let user = {
  name: "John"
};

這裡的箭頭描繪一個物件參考。全域變數 "user" 參考物件 {name: "John"} (為了簡潔,我們將其稱為 John)。John 的 "name" 屬性儲存一個基本型別,因此它會繪製在物件內部。

如果 user 的值被覆寫,則參考會遺失

user = null;

現在 John 變成不可存取。沒有辦法存取它,沒有任何參考指向它。垃圾收集器會將資料視為垃圾並釋放記憶體。

兩個參考

現在我們想像我們將 user 的參考複製到 admin

// user has a reference to the object
let user = {
  name: "John"
};

let admin = user;

現在如果我們執行相同動作

user = null;

…那麼物件仍然可透過 admin 全域變數存取,因此它必須保留在記憶體中。如果我們也覆寫 admin,那麼它就可以被移除。

相互連結的物件

現在是一個更複雜的範例。家庭

function marry(man, woman) {
  woman.husband = man;
  man.wife = woman;

  return {
    father: man,
    mother: woman
  }
}

let family = marry({
  name: "John"
}, {
  name: "Ann"
});

函式 marry 透過提供物件彼此的參考來「結婚」,並傳回一個包含兩者的新物件。

產生的記憶體結構

就目前而言,所有物件都是可存取的。

現在我們移除兩個參考

delete family.father;
delete family.mother.husband;

僅刪除這兩個參考中的一個是不夠的,因為所有物件仍然可存取。

但是如果我們刪除兩個,那麼我們可以看到 John 沒有任何進入的參考了

外出的參考並不重要。只有進入的參考才能讓物件可存取。因此,John 現在不可存取,而且會從記憶體中移除,連同所有也變成無法存取的資料。

垃圾收集後

無法存取的島嶼

整個相互連結物件的島嶼有可能無法存取,並從記憶體中移除。

來源物件與上述相同。然後

family = null;

記憶體中的圖片會變成

這個範例說明了可存取性的概念有多重要。

很明顯地,John 和 Ann 仍然連結,兩者都有內部參照。但這還不夠。

先前的 "family" 物件已從根目錄取消連結,不再有參照,因此整個島嶼無法存取,且將會被移除。

內部演算法

基本的垃圾回收演算法稱為「標記並清除」。

定期執行下列「垃圾回收」步驟

  • 垃圾回收器取得根目錄並「標記」(記住)它們。
  • 然後它會拜訪並「標記」它們的所有參照。
  • 然後它會拜訪已標記的物件並標記它們的參照。所有已拜訪的物件都會被記住,這樣未來就不會拜訪同一個物件兩次。
  • …以此類推,直到拜訪完所有可從(根目錄)存取的參照。
  • 移除所有未標記的物件。

例如,我們的物件結構如下所示

我們可以清楚地看到右側有一個「無法存取的島嶼」。現在讓我們看看「標記並清除」垃圾回收器如何處理它。

第一步標記根目錄

然後我們追蹤它們的參照並標記被參照的物件

…並繼續追蹤進一步的參照,只要有可能

現在,在過程中無法拜訪的物件會被視為無法存取,並將會被移除

我們也可以將這個過程想像成從根目錄灑出一大桶油漆,它會流經所有參照並標記所有可存取的物件。然後移除未標記的物件。

這就是垃圾回收運作的概念。JavaScript 引擎會套用許多最佳化,讓它執行得更快,且不會對程式碼執行造成任何延遲。

一些最佳化

  • 世代收集 – 物件會分成兩組:「新的」和「舊的」。在典型的程式碼中,許多物件的生命週期很短:它們出現、執行工作,然後快速消失,因此追蹤新的物件並在這種情況下清除它們的記憶體是有意義的。那些存活夠久的物件會變成「舊的」,且較少被檢查。
  • 增量收集 – 如果有許多物件,且我們嘗試一次走訪並標記整個物件組,這可能會花費一些時間,並在執行中造成明顯的延遲。因此,引擎會將所有現有物件組分成多個部分。然後一個接著一個清除這些部分。會有許多小型垃圾回收,而不是一次總回收。這需要在它們之間進行一些額外的簿記來追蹤變更,但我們會得到許多微小的延遲,而不是一次大的延遲。
  • 閒置時間收集 – 垃圾收集器嘗試僅在 CPU 閒置時執行,以減少對執行造成的可能影響。

還有其他垃圾收集演算法的最佳化和變體。儘管我很想在此處描述它們,但我必須暫停,因為不同的引擎實作不同的調整和技術。更重要的是,隨著引擎的開發,事情會發生變化,因此在沒有實際需求的情況下深入「預先」研究可能不值得。當然,除非這是純粹興趣的問題,那麼下面會有一些連結給您。

摘要

要知道的主要事項

  • 垃圾收集會自動執行。我們無法強制或阻止它。
  • 物件在可到達時會保留在記憶體中。
  • 被參照與可到達(從根部)並不相同:一組相互連結的物件可能會整體上變得不可到達,如我們在上面的範例中所見。

現代引擎實作進階的垃圾收集演算法。

一本通論書籍「垃圾收集手冊:自動記憶體管理的藝術」(R. Jones 等人)涵蓋其中一些。

如果您熟悉低階程式設計,關於 V8 垃圾收集器的更詳細資訊請參閱文章 V8 導覽:垃圾收集

V8 部落格 也會不時發布有關記憶體管理變更的文章。當然,要進一步瞭解垃圾收集,您最好先準備瞭解 V8 內部結構,並閱讀 Vyacheslav Egorov 的部落格,他曾擔任 V8 工程師之一。我說:「V8」,因為網路上有最好的文章涵蓋它。對於其他引擎,許多方法都很類似,但垃圾收集在許多方面有所不同。

當您需要低階最佳化時,深入瞭解引擎會很有幫助。在您熟悉這門語言之後,將其規劃為下一步會很明智。

教學地圖

留言

留言前請先閱讀…
  • 如果您有改進建議,請 提交 GitHub 問題 或提交拉取請求,而不是留言。
  • 如果您無法理解文章中的某個內容,請詳細說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,若要插入多行程式碼,請將它們包覆在 <pre> 標籤中,若要插入超過 10 行程式碼,請使用沙盒 (plnkrjsbincodepen…)