記憶體管理

像 C 這樣的低階語言,具有手動記憶體管理原語,例如 malloc()free()。相比之下,JavaScript 會在建立物件時自動分配記憶體,並在它們不再使用時釋放記憶體(垃圾回收)。這種自動性是一個潛在的混淆來源:它可能會給開發人員留下一個錯誤的印象,即他們不需要擔心記憶體管理。

記憶體生命週期

無論使用哪種程式語言,記憶體生命週期幾乎總是相同的

  1. 分配所需的記憶體
  2. 使用已分配的記憶體(讀、寫)
  3. 在不再需要時釋放已分配的記憶體

第二部分在所有語言中都是明確的。第一部分和最後一部分在低階語言中是明確的,但在像 JavaScript 這樣的高階語言中大部分是隱式的。

JavaScript 中的記憶體分配

值初始化

為了不讓程式設計師操心記憶體分配,JavaScript 會在值首次宣告時自動分配記憶體。

js
const n = 123; // allocates memory for a number
const s = "string"; // allocates memory for a string

const o = {
  a: 1,
  b: null,
}; // allocates memory for an object and contained values

// (like object) allocates memory for the array and
// contained values
const a = [1, null, "str2"];

function f(a) {
  return a + 2;
} // allocates a function (which is a callable object)

// function expressions also allocate an object
someElement.addEventListener("click", () => {
  someElement.style.backgroundColor = "blue";
});

透過函式呼叫進行分配

一些函式呼叫會產生物件分配。

js
const d = new Date(); // allocates a Date object

const e = document.createElement("div"); // allocates a DOM element

一些方法會分配新的值或物件

js
const s = "string";
const s2 = s.substring(0, 3); // s2 is a new string
// Since strings are immutable values,
// JavaScript may decide to not allocate memory,
// but just store the [0, 3] range.

const a = ["yeah yeah", "no no"];
const a2 = ["generation", "no no"];
const a3 = a.concat(a2);
// new array with 4 elements being
// the concatenation of a and a2 elements.

使用值

使用值基本上意味著在已分配的記憶體中進行讀寫。這可以透過讀取或寫入變數的值、物件屬性的值,甚至將引數傳遞給函式來實現。

在不再需要記憶體時釋放

大多數記憶體管理問題都發生在此階段。此階段最困難的方面是確定何時不再需要已分配的記憶體。

低階語言要求開發人員手動確定程式中何時不再需要已分配的記憶體並將其釋放。

一些高階語言,例如 JavaScript,使用一種稱為垃圾回收 (GC) 的自動記憶體管理形式。垃圾回收器的目的是監控記憶體分配並確定何時不再需要一塊已分配的記憶體並將其回收。這個自動過程是一個近似值,因為確定特定記憶體是否仍然需要的普遍問題是不可判定的

垃圾回收

如上所述,自動查詢某些記憶體“不再需要”的普遍問題是不可判定的。因此,垃圾回收器實現了對普遍問題解決方案的限制。本節將解釋理解主要垃圾回收演算法及其各自限制所需的概念。

參考

垃圾回收演算法所依賴的主要概念是引用的概念。在記憶體管理的上下文中,如果一個物件可以訪問另一個物件(無論是隱式還是顯式),則稱該物件引用另一個物件。例如,JavaScript 物件引用其原型(隱式引用)及其屬性值(顯式引用)。

在此上下文中,“物件”的概念擴充套件到比普通 JavaScript 物件更廣泛的範圍,並且還包含函式作用域(或全域性詞法作用域)。

引用計數垃圾回收

注意:現代 JavaScript 引擎不再使用引用計數進行垃圾回收。

這是最簡單的垃圾回收演算法。該演算法將問題從確定物件是否仍然需要簡化為確定物件是否仍然有任何其他物件引用它。如果一個物件沒有指向它的引用,則稱該物件為“垃圾”或可回收。

例如

js
let x = {
  a: {
    b: 2,
  },
};
// 2 objects are created. One is referenced by the other as one of its properties.
// The other is referenced by virtue of being assigned to the 'x' variable.
// Obviously, none can be garbage-collected.

let y = x;
// The 'y' variable is the second thing that has a reference to the object.

x = 1;
// Now, the object that was originally in 'x' has a unique reference
// embodied by the 'y' variable.

let z = y.a;
// Reference to 'a' property of the object.
// This object now has 2 references: one as a property,
// the other as the 'z' variable.

y = "mozilla";
// The object that was originally in 'x' has now zero
// references to it. It can be garbage-collected.
// However its 'a' property is still referenced by
// the 'z' variable, so it cannot be freed.

z = null;
// The 'a' property of the object originally in x
// has zero references to it. It can be garbage collected.

當涉及到迴圈引用時存在侷限性。在以下示例中,建立了兩個物件,其屬性相互引用,從而形成一個迴圈。它們將在函式呼叫完成後超出作用域。此時它們變得不再需要,並且應回收其已分配的記憶體。然而,引用計數演算法不會認為它們是可回收的,因為這兩個物件中的每一個都至少有一個引用指向它們,導致它們都沒有被標記為垃圾回收。迴圈引用是記憶體洩漏的常見原因。

js
function f() {
  const x = {};
  const y = {};
  x.a = y; // x references y
  y.a = x; // y references x

  return "azerty";
}

f();

標記清除演算法

該演算法將“不再需要物件”的定義簡化為“物件不可達”。

該演算法假定已知一組稱為的物件。在 JavaScript 中,根是全域性物件。垃圾回收器會定期從這些根開始,查詢從這些根引用的所有物件,然後是所有從這些物件引用的物件,依此類推。從根開始,垃圾回收器會找到所有可達物件並回收所有不可達物件。

該演算法是對前一個演算法的改進,因為沒有引用的物件實際上是不可達的。反之則不成立,正如我們在迴圈引用中看到的那樣。

目前,所有現代引擎都配備了標記清除垃圾回收器。過去幾年在 JavaScript 垃圾回收領域(分代/增量/併發/並行垃圾回收)所做的所有改進都是該演算法的實現改進,而不是對垃圾回收演算法本身或其對“不再需要物件”定義簡化的改進。

這種方法的直接好處是迴圈不再是問題。在上面的第一個示例中,函式呼叫返回後,這兩個物件不再被全域性物件中可達的任何資源引用。因此,垃圾回收器會發現它們不可達並回收其已分配的記憶體。

然而,手動控制垃圾回收的能力仍然缺失。有時,手動決定何時釋放哪些記憶體會很方便。為了釋放物件的記憶體,需要將其明確地設定為不可達。在 JavaScript 中也不可能透過程式設計觸發垃圾回收——而且核心語言中可能永遠不會有,儘管引擎可能會透過選擇性加入標誌暴露 API。

配置引擎的記憶體模型

JavaScript 引擎通常提供暴露記憶體模型的標誌。例如,Node.js 提供了額外的選項和工具,暴露了底層的 V8 機制,用於配置和除錯記憶體問題。這種配置可能在瀏覽器中不可用,更不用說網頁(透過 HTTP 標頭等)了。

可用堆記憶體的最大量可以透過一個標誌來增加

bash
node --max-old-space-size=6000 index.js

我們還可以使用一個標誌和 Chrome 偵錯程式來暴露垃圾回收器以除錯記憶體問題

bash
node --expose-gc --inspect index.js

有助於記憶體管理的資料結構

儘管 JavaScript 不直接暴露垃圾回收器 API,但該語言提供了幾種間接觀察垃圾回收並可用於管理記憶體使用的資料結構。

WeakMaps 和 WeakSets

WeakMapWeakSet 是 API 與其非弱對應項(MapSet)非常相似的資料結構。WeakMap 允許你維護一個鍵值對集合,而 WeakSet 允許你維護一個唯一值集合,兩者都具有高效的新增、刪除和查詢操作。

WeakMapWeakSet 的名稱來源於弱引用值的概念。如果 xy 弱引用,這意味著雖然你可以透過 y 訪問 x 的值,但如果沒有任何其他東西強引用 x,則標記清除演算法不會將 x 視為可達。大多數資料結構,除了這裡討論的那些,都強引用傳入的物件,以便你可以隨時檢索它們。只要程式中沒有其他東西引用鍵,WeakMapWeakSet 的鍵就可以被垃圾回收(對於 WeakMap 物件,其值也將有資格進行垃圾回收)。這由兩個特性保證

  • WeakMapWeakSet 只能儲存物件或符號。這是因為只有物件才能被垃圾回收——原始值總是可以被偽造(也就是說,1 === 1{} !== {}),這使得它們永遠留在集合中。註冊符號(如 Symbol.for("key"))也可以被偽造,因此不能被垃圾回收,但用 Symbol("key") 建立的符號是可以被垃圾回收的。眾所周知的符號,如 Symbol.iterator,以固定集合形式存在,並且在程式的整個生命週期中都是唯一的,類似於諸如 Array.prototype 這樣的內建物件,因此它們也允許作為鍵。
  • WeakMapWeakSet 是不可迭代的。這可以防止你使用 Array.from(map.keys()).length 來觀察物件的活躍度,或者獲取應該被垃圾回收的任意鍵。(垃圾回收應該儘可能不可見。)

WeakMapWeakSet 的典型解釋中(例如上面),通常暗示鍵首先被垃圾回收,從而也使值可以被垃圾回收。然而,考慮值引用鍵的情況

js
const wm = new WeakMap();
const key = {};
wm.set(key, { key });
// Now `key` cannot be garbage collected,
// because the value holds a reference to the key,
// and the value is strongly held in the map!

如果 key 作為實際引用儲存,它將建立一個迴圈引用,並使鍵和值都不符合垃圾回收的條件,即使沒有其他東西引用 key——因為如果 key 被垃圾回收,這意味著在某個特定時刻,value.key 將指向一個不存在的地址,這是非法的。為了解決這個問題,WeakMapWeakSet 的條目不是實際引用,而是易失物,這是標記清除機制的增強。Barros 等人對該演算法進行了很好的總結(第 4 頁)。引用一段話

易失物是弱對的改進,其中鍵和值都不能被歸類為弱或強。鍵的連通性決定了值的連通性,但值的連通性不影響鍵的連通性。……當垃圾回收支援易失物時,它分三個階段而不是兩個階段(標記和清除)進行。

作為一個粗略的思維模型,可以將 WeakMap 視為以下實現

警告:這既不是 polyfill,也與它在引擎中的實現方式(它連線到垃圾回收機制)相去甚遠。

js
class MyWeakMap {
  #marker = Symbol("MyWeakMapData");
  get(key) {
    return key[this.#marker];
  }
  set(key, value) {
    key[this.#marker] = value;
  }
  has(key) {
    return this.#marker in key;
  }
  delete(key) {
    delete key[this.#marker];
  }
}

如你所見,MyWeakMap 從未實際持有鍵的集合。它只是為每個傳入的物件新增元資料。然後,物件可以透過標記清除進行垃圾回收。因此,不可能遍歷 WeakMap 中的鍵,也不可能清除 WeakMap(因為這也依賴於整個鍵集合的知識)。

有關其 API 的更多資訊,請參閱鍵控集合指南。

WeakRefs 和 FinalizationRegistry

注意:WeakRefFinalizationRegistry 提供了對垃圾回收機制的直接內省。儘可能避免使用它們,因為執行時語義幾乎完全無法保證。

所有以物件為值的變數都是對該物件的引用。然而,這些引用是引用——它們的存在會阻止垃圾回收器將物件標記為符合回收條件。一個 WeakRef 是對物件的弱引用,它允許物件被垃圾回收,同時在其生命週期內仍能讀取物件的內容。

WeakRef 的一個用例是快取系統,它將字串 URL 對映到大型物件。我們不能為此目的使用 WeakMap,因為 WeakMap 物件的是弱引用的,但其不是——如果你訪問一個鍵,你總是會確定性地獲取該值(因為可以訪問鍵意味著它仍然存在)。在這裡,我們可以接受一個鍵返回 undefined(如果相應的值不再存在),因為我們可以重新計算它,但我們不希望不可達物件保留在快取中。在這種情況下,我們可以使用普通的 Map,但每個值都是物件的 WeakRef,而不是實際的物件值。

js
function cached(getter) {
  // A Map from string URLs to WeakRefs of results
  const cache = new Map();
  return async (key) => {
    if (cache.has(key)) {
      const dereferencedValue = cache.get(key).deref();
      if (dereferencedValue !== undefined) {
        return dereferencedValue;
      }
    }
    const value = await getter(key);
    cache.set(key, new WeakRef(value));
    return value;
  };
}

const getImage = cached((url) => fetch(url).then((res) => res.blob()));

FinalizationRegistry 提供了一種更強大的機制來觀察垃圾回收。它允許你註冊物件並在它們被垃圾回收時收到通知。例如,對於上面示例的快取系統,即使 blob 本身可以被回收,持有它們的 WeakRef 物件卻不能——隨著時間的推移,Map 可能會累積大量無用的條目。使用 FinalizationRegistry 可以在這種情況下執行清理。

js
function cached(getter) {
  // A Map from string URLs to WeakRefs of results
  const cache = new Map();
  // Every time after a value is garbage collected, the callback is
  // called with the key in the cache as argument, allowing us to remove
  // the cache entry
  const registry = new FinalizationRegistry((key) => {
    // Note: it's important to test that the WeakRef is indeed empty.
    // Otherwise, the callback may be called after a new object has been
    // added with this key, and that new, alive object gets deleted
    if (!cache.get(key)?.deref()) {
      cache.delete(key);
    }
  });
  return async (key) => {
    if (cache.has(key)) {
      return cache.get(key).deref();
    }
    const value = await getter(key);
    cache.set(key, new WeakRef(value));
    registry.register(value, key);
    return value;
  };
}

const getImage = cached((url) => fetch(url).then((res) => res.blob()));

由於效能和安全方面的考慮,無法保證何時呼叫回撥,或者是否會呼叫回撥。它應該只用於清理——非關鍵的清理。還有其他方法可以實現更確定的資源管理,例如 try...finally,它將始終執行 finally 塊。WeakRefFinalizationRegistry 僅用於最佳化長時間執行程式中的記憶體使用。

有關 WeakRefFinalizationRegistry 的 API 的更多資訊,請參閱它們的參考頁面。