内存管理

像 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 的更多信息,请参阅它们的参考页面。