Javascript 垃圾回收

2023-02-17 10:38 更新

對于開發(fā)者來說,JavaScript 的內存管理是自動的、無形的。我們創(chuàng)建的原始值、對象、函數……這一切都會占用內存。

當我們不再需要某個東西時會發(fā)生什么?JavaScript 引擎如何發(fā)現它并清理它?

可達性(Reachability)

JavaScript 中主要的內存管理概念是 可達性。

簡而言之,“可達”值是那些以某種方式可訪問或可用的值。它們一定是存儲在內存中的。

  1. 這里列出固有的可達值的基本集合,這些值明顯不能被釋放。
  2. 比方說:

    • 當前執(zhí)行的函數,它的局部變量和參數。
    • 當前嵌套調用鏈上的其他函數、它們的局部變量和參數。
    • 全局變量。
    • (還有一些內部的)

    這些值被稱作 根(roots)。

  3. 如果一個值可以通過引用鏈從根訪問任何其他值,則認為該值是可達的。
  4. 比方說,如果全局變量中有一個對象,并且該對象有一個屬性引用了另一個對象,則  對象被認為是可達的。而且它引用的內容也是可達的。下面是詳細的例子。

在 JavaScript 引擎中有一個被稱作 垃圾回收器 的東西在后臺執(zhí)行。它監(jiān)控著所有對象的狀態(tài),并刪除掉那些已經不可達的。

一個簡單的例子

這里是一個最簡單的例子:

// user 具有對這個對象的引用
let user = {
  name: "John"
};


這里的箭頭描述了一個對象引用。全局變量 "user" 引用了對象 {name:"John"}(為簡潔起見,我們稱它為 John)。John 的 "name" 屬性存儲一個原始值,所以它被寫在對象內部。

如果 user 的值被重寫了,這個引用就沒了:

user = null;


現在 John 變成不可達的了。因為沒有引用了,就不能訪問到它了。垃圾回收器會認為它是垃圾數據并進行回收,然后釋放內存。

兩個引用

現在讓我們想象下,我們把 user 的引用復制給 admin

// user 具有對這個對象的引用
let user = {
  name: "John"
};

let admin = user;


現在如果執(zhí)行剛剛的那個操作:

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 現在是不可達的,并且將被從內存中刪除,同時 John 的所有數據也將變得不可達。

經過垃圾回收:


無法到達的島嶼

幾個對象相互引用,但外部沒有對其任意對象的引用,這些對象也可能是不可達的,并被從內存中刪除。

源對象與上面相同。然后:

family = null;

內存內部狀態(tài)將變成:


這個例子展示了可達性概念的重要性。

顯而易見,John 和 Ann 仍然連著,都有傳入的引用。但是,這樣還不夠。

前面說的 "family" 對象已經不再與根相連,沒有了外部對其的引用,所以它變成了一座“孤島”,并且將被從內存中刪除。

內部算法

垃圾回收的基本算法被稱為 “mark-and-sweep”。

定期執(zhí)行以下“垃圾回收”步驟:

  • 垃圾收集器找到所有的根,并“標記”(記住)它們。
  • 然后它遍歷并“標記”來自它們的所有引用。
  • 然后它遍歷標記的對象并標記 它們的 引用。所有被遍歷到的對象都會被記住,以免將來再次遍歷到同一個對象。
  • ……如此操作,直到所有可達的(從根部)引用都被訪問到。
  • 沒有被標記的對象都會被刪除。

例如,使我們的對象有如下的結構:


我們可以清楚地看到右側有一個“無法到達的島嶼”?,F在我們來看看“標記和清除”垃圾收集器如何處理它。

第一步標記所有的根:


然后,我們跟隨它們的引用標記它們所引用的對象:


……如果還有引用的話,繼續(xù)標記:


現在,無法通過這個過程訪問到的對象被認為是不可達的,并且會被刪除。


我們還可以將這個過程想象成從根溢出一大桶油漆,它流經所有引用并標記所有可到達的對象。然后移除未標記的。

這是垃圾收集工作的概念。JavaScript 引擎做了許多優(yōu)化,使垃圾回收運行速度更快,并且不會對代碼執(zhí)行引入任何延遲。

一些優(yōu)化建議:

  • 分代收集(Generational collection)—— 對象被分成兩組:“新的”和“舊的”。在典型的代碼中,許多對象的生命周期都很短:它們出現、完成它們的工作并很快死去,因此在這種情況下跟蹤新對象并將其從內存中清除是有意義的。那些長期存活的對象會變得“老舊”,并且被檢查的頻次也會降低。
  • 增量收集(Incremental collection)—— 如果有許多對象,并且我們試圖一次遍歷并標記整個對象集,則可能需要一些時間,并在執(zhí)行過程中帶來明顯的延遲。因此,引擎將現有的整個對象集拆分為多個部分,然后將這些部分逐一清除。這樣就會有很多小型的垃圾收集,而不是一個大型的。這需要它們之間有額外的標記來追蹤變化,但是這樣會帶來許多微小的延遲而不是一個大的延遲。
  • 閑時收集(Idle-time collection)—— 垃圾收集器只會在 CPU 空閑時嘗試運行,以減少可能對代碼執(zhí)行的影響。

還有其他垃圾回收算法的優(yōu)化和風格。盡管我想在這里描述它們,但我必須打住了,因為不同的引擎會有不同的調整和技巧。而且,更重要的是,隨著引擎的發(fā)展,情況會發(fā)生變化,所以在沒有真實需求的時候,“提前”學習這些內容是不值得的。當然,除非你純粹是出于興趣。我在下面給你提供了一些相關鏈接。

總結

主要需要掌握的內容:

  • 垃圾回收是自動完成的,我們不能強制執(zhí)行或是阻止執(zhí)行。
  • 當對象是可達狀態(tài)時,它一定是存在于內存中的。
  • 被引用與可訪問(從一個根)不同:一組相互連接的對象可能整體都不可達,正如我們在上面的例子中看到的那樣。

現代引擎實現了垃圾回收的高級算法。

《The Garbage Collection Handbook: The Art of Automatic Memory Management》(R. Jones 等人著)這本書涵蓋了其中一些內容。

如果你熟悉底層(low-level)編程,關于 V8 引擎垃圾回收器的更詳細信息請參閱文章 V8 之旅:垃圾回收。

V8 博客 還不時發(fā)布關于內存管理變化的文章。當然,為了學習更多垃圾收集的相關內容,你最好通過學習 V8 引擎內部知識來進行準備,并閱讀一個名為 Vyacheslav Egorov 的 V8 引擎工程師的博客。我之所以說 “V8”,因為網上關于它的文章最豐富的。對于其他引擎,許多方法是相似的,但在垃圾收集上許多方面有所不同。

當你需要底層的優(yōu)化時,對引擎有深入了解將很有幫助。在熟悉了這門編程語言之后,把熟悉引擎作為下一步計劃是明智之選。


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號