記憶體管理

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

記憶體生命週期

無論程式語言如何,記憶體生命週期幾乎總是相同的

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

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

JavaScript 中的分配

值初始化

為了不讓程式設計師擔心分配問題,JavaScript 會在最初宣告值時自動分配記憶體。

js
const n = 123; // allocates memory for a number
const s = "azerty"; // 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, "abra"];

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";
  },
  false,
);

透過函式呼叫分配

一些函式呼叫會導致物件分配。

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

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

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

js
const s = "azerty";
const s2 = s.substr(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 = ["ouais ouais", "nan nan"];
const a2 = ["generation", "nan nan"];
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 與其非弱對應物非常相似:MapSetWeakMap 允許您維護鍵值對的集合,而 WeakSet 允許您維護唯一值的集合,兩者都具有高效的新增、刪除和查詢功能。

WeakMapWeakSet 的名稱源於“弱持有”值的概念。如果 xy 弱持有,這意味著儘管您可以透過 y 訪問 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 的更多資訊,請參閱鍵控集合指南。

弱引用和終結登錄檔

注意: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 的更多資訊,請參閱其參考頁面。