内存管理
低级语言(如 C)具有手动内存管理原语,例如 malloc()
和 free()
。相比之下,JavaScript 在创建对象时自动分配内存,并在不再使用它们时释放内存(垃圾回收)。这种自动化可能是造成混淆的潜在来源:它可能让开发人员误以为他们不需要担心内存管理。
内存生命周期
无论编程语言如何,内存生命周期几乎总是相同的
- 分配所需的内存
- 使用分配的内存(读取、写入)
- 在不再需要时释放分配的内存
第二部分在所有语言中都是明确的。第一部分和最后一部分在低级语言中是明确的,但在像 JavaScript 这样的高级语言中大多是隐式的。
JavaScript 中的分配
值初始化
为了不让程序员担心分配问题,JavaScript 会在最初声明值时自动分配内存。
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,
);
通过函数调用分配
一些函数调用会导致对象分配。
const d = new Date(); // allocates a Date object
const e = document.createElement("div"); // allocates a DOM element
一些方法会分配新的值或对象
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 对象引用其 原型(隐式引用)及其属性值(显式引用)。
在这种情况下,“对象”的概念扩展到比常规 JavaScript 对象更广泛的内容,还包含函数作用域(或全局词法作用域)。
引用计数垃圾回收
注意:现在没有现代 JavaScript 引擎再使用引用计数进行垃圾回收了。
这是最简单的垃圾回收算法。该算法将问题从确定对象是否仍然需要简化为确定对象是否还有其他对象引用它。如果指向对象的引用为零,则称该对象为“垃圾”或可回收。
例如
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.
在循环引用方面存在一个限制。在下面的示例中,创建了两个对象,其属性相互引用,从而创建了一个循环。在函数调用完成后,它们将超出作用域。此时,它们变得不需要,并且应回收其分配的内存。但是,引用计数算法不会认为它们是可回收的,因为这两个对象中的每一个都至少有一个指向它们的引用,导致它们都没有被标记为垃圾回收。循环引用是内存泄漏的常见原因。
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 标头等)更是如此。
可以使用标志增加可用堆内存的最大量
node --max-old-space-size=6000 index.js
我们还可以使用标志和 Chrome 调试器 公开垃圾回收器以调试内存问题
node --expose-gc --inspect index.js
辅助内存管理的数据结构
尽管 JavaScript 不会直接公开垃圾回收器 API,但该语言提供了一些间接观察垃圾回收并可用于管理内存使用情况的数据结构。
WeakMaps 和 WeakSets
WeakMap
和 WeakSet
是数据结构,其 API 与其非弱对应物非常相似:Map
和 Set
。WeakMap
允许您维护键值对的集合,而 WeakSet
允许您维护唯一值的集合,两者都具有高效的添加、删除和查询功能。
WeakMap
和 WeakSet
的名称源于“弱持有”值的概念。如果 x
被 y
弱持有,这意味着尽管您可以通过 y
访问 x
的值,但标记-清除算法不会将 x
视为可达的,除非其他内容“强持有”它。除这里讨论的之外,大多数数据结构都强持有传入的对象,以便您随时检索它们。只要程序中没有其他内容引用键,WeakMap
和 WeakSet
的键就可以被垃圾回收(对于 WeakMap
对象,其值也将可以被垃圾回收)。这是由两个特征确保的
WeakMap
和WeakSet
只能存储对象或符号。这是因为只有对象会被垃圾回收——原始值始终可以被伪造(即,1 === 1
但{} !== {}
),使其永远保留在集合中。注册的符号(如Symbol.for("key")
)也可以被伪造,因此无法被垃圾回收,但使用Symbol("key")
创建的符号是可以被垃圾回收的。众所周知的符号,如Symbol.iterator
,包含在一个固定的集合中,并且在程序的整个生命周期中都是唯一的,类似于内在对象,如Array.prototype
,因此它们也允许作为键。WeakMap
和WeakSet
不可迭代。这阻止您使用Array.from(map.keys()).length
来观察对象的存活性,或获取任意键(否则该键应该可以被垃圾回收)。(垃圾回收应该尽可能地不可见。)
在 WeakMap
和 WeakSet
的典型解释中(如上所述),通常暗示键首先被垃圾回收,从而释放值以进行垃圾回收。但是,请考虑值引用键的情况
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
将指向一个不存在的地址,这是非法的。为了解决这个问题,WeakMap
和 WeakSet
的条目不是实际引用,而是短暂对象,这是标记-清除机制的增强功能。Barros 等人 对该算法进行了很好的总结(第 4 页)。引用一段话
短暂对象是对弱对的改进,其中键和值都不能被归类为弱或强。键的连接性决定了值的连接性,但值的连接性不影响键的连接性。[…] 当垃圾回收提供对短暂对象的支持时,它会分为三个阶段而不是两个(标记和清除)。
作为一个粗略的心智模型,将 WeakMap
视为以下实现
警告:这不是一个 polyfill,也与引擎中实现的方式(它挂接到垃圾回收机制上)相差甚远。
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 的更多信息,请参阅键控集合指南。
弱引用和终结注册表
注意:WeakRef
和 FinalizationRegistry
提供了对垃圾回收机制的直接洞察。 尽可能避免使用它们,因为运行时语义几乎完全没有保证。
所有以对象为值的变量都是对该对象的引用。但是,此类引用是强引用——它们的存在将阻止垃圾回收器将该对象标记为可回收。一个WeakRef
是对对象的弱引用,它允许对象被垃圾回收,同时仍然能够在其生命周期内读取对象的内容。
WeakRef
的一个用例是缓存系统,它将字符串 URL 映射到大型对象。我们不能为此目的使用 WeakMap
,因为 WeakMap
对象的键被弱持有,但其值没有——如果您访问一个键,您将始终确定性地获取该值(因为访问键意味着它仍然存在)。在这里,如果键对应的值不再存在,我们很乐意获取 undefined
(因为我们可以重新计算它),但我们不希望无法访问的对象保留在缓存中。在这种情况下,我们可以使用普通的 Map
,但每个值都是对象的 WeakRef
而不是实际的对象值。
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
可以进行清理。
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
块。WeakRef
和 FinalizationRegistry
仅用于优化长时间运行程序的内存使用。
有关 WeakRef
和 FinalizationRegistry
API 的更多信息,请参阅其参考页面。