弱引用和终结器

发布 · 更新 · 标记为 ECMAScript ES2021

通常,对对象的引用在 JavaScript 中是强引用,这意味着只要您拥有对该对象的引用,它就不会被垃圾回收。

const ref = { x: 42, y: 51 };
// As long as you have access to `ref` (or any other reference to the
// same object), the object won’t be garbage-collected.

目前,WeakMapWeakSet 是 JavaScript 中唯一可以弱引用对象的途径:将对象作为键添加到 WeakMapWeakSet 中不会阻止它被垃圾回收。

const wm = new WeakMap();
{
const ref = {};
const metaData = 'foo';
wm.set(ref, metaData);
wm.get(ref);
// → metaData
}
// We no longer have a reference to `ref` in this block scope, so it
// can be garbage-collected now, even though it’s a key in `wm` to
// which we still have access.

const ws = new WeakSet();
{
const ref = {};
ws.add(ref);
ws.has(ref);
// → true
}
// We no longer have a reference to `ref` in this block scope, so it
// can be garbage-collected now, even though it’s a key in `ws` to
// which we still have access.

注意:您可以将 WeakMap.prototype.set(ref, metaData) 视为向对象 ref 添加一个值为 metaData 的属性:只要您拥有对该对象的引用,您就可以获取元数据。一旦您不再拥有对该对象的引用,它就可以被垃圾回收,即使您仍然拥有对添加了该对象的 WeakMap 的引用。类似地,您可以将 WeakSet 视为 WeakMap 的特例,其中所有值都是布尔值。

JavaScript WeakMap 并不真正:它实际上强引用其内容,只要键还存在。WeakMap 只有在键被垃圾回收后才会弱引用其内容。这种关系更准确的名称是 短暂引用

WeakRef 是一个更高级的 API,它提供真正的弱引用,使您能够了解对象的生存期。让我们一起看一个例子。

例如,假设我们正在开发一个使用 WebSockets 与服务器通信的聊天 Web 应用程序。想象一个 MovingAvg 类,为了性能诊断目的,它会保留来自 WebSockets 的一组事件,以便计算延迟的简单移动平均值。

class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}

compute(n) {
// Compute the simple moving average for the last n events.
// …
}
}

它由一个 MovingAvgComponent 类使用,该类允许您控制何时开始和停止监控延迟的简单移动平均值。

class MovingAvgComponent {
constructor(socket) {
this.socket = socket;
}

start() {
this.movingAvg = new MovingAvg(this.socket);
}

stop() {
// Allow the garbage collector to reclaim memory.
this.movingAvg = null;
}

render() {
// Do rendering.
// …
}
}

我们知道,将所有服务器消息保存在 MovingAvg 实例中会占用大量内存,因此我们会在停止监控时将 this.movingAvg 设置为 null,以便垃圾回收器回收内存。

但是,在 DevTools 的内存面板中检查后,我们发现内存根本没有被回收!经验丰富的 Web 开发人员可能已经发现了错误:事件监听器是强引用,必须显式删除。

让我们用可达性图来明确这一点。在调用 start() 后,我们的对象图如下所示,其中实线箭头表示强引用。从 MovingAvgComponent 实例到所有通过实线箭头可达的对象都不会被垃圾回收。

在调用 stop() 后,我们从 MovingAvgComponent 实例到 MovingAvg 实例的强引用已删除,但通过套接字的监听器尚未删除。

因此,MovingAvg 实例中的监听器通过引用 this,会使整个实例保持活动状态,只要事件监听器没有被删除。

到目前为止,解决方案是通过 dispose 方法手动注销事件监听器。

class MovingAvg {
constructor(socket) {
this.events = [];
this.socket = socket;
this.listener = (ev) => { this.events.push(ev); };
socket.addEventListener('message', this.listener);
}

dispose() {
this.socket.removeEventListener('message', this.listener);
}

// …
}

这种方法的缺点是它是手动内存管理。MovingAvgComponent 以及 MovingAvg 类的所有其他用户都必须记住调用 dispose,否则会导致内存泄漏。更糟糕的是,手动内存管理是级联的:MovingAvgComponent 的用户必须记住调用 stop,否则会导致内存泄漏,依此类推。应用程序的行为不依赖于此诊断类的事件监听器,并且监听器在内存使用方面很昂贵,但在计算方面并不昂贵。我们真正想要的是让监听器的生存期在逻辑上与 MovingAvg 实例绑定,这样 MovingAvg 就可以像任何其他 JavaScript 对象一样使用,其内存会由垃圾回收器自动回收。

WeakRef 使得通过创建对实际事件监听器的弱引用,然后将该 WeakRef 包装在外部事件监听器中来解决这个难题成为可能。这样,垃圾回收器就可以清理实际的事件监听器以及它所保持活动状态的内存,例如 MovingAvg 实例及其 events 数组。

function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener);
const wrapper = (ev) => { weakRef.deref()?.(ev); };
socket.addEventListener('message', wrapper);
}

class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); };
addWeakListener(socket, this.listener);
}
}

注意:对函数的 WeakRef 必须谨慎对待。JavaScript 函数是 闭包,并且强引用包含函数内部引用的自由变量值的外部环境。这些外部环境可能包含其他闭包也引用的变量。也就是说,在处理闭包时,它们的内存通常以微妙的方式被其他闭包强引用。这就是 addWeakListener 是一个单独的函数,而 wrapper 不是 MovingAvg 构造函数的局部变量的原因。在 V8 中,如果 wrapperMovingAvg 构造函数的局部变量,并且与包装在 WeakRef 中的监听器共享词法作用域,则 MovingAvg 实例及其所有属性将通过包装监听器的共享环境变得可达,导致实例无法被回收。在编写代码时请牢记这一点。

我们首先创建事件监听器并将其分配给 this.listener,以便它被 MovingAvg 实例强引用。换句话说,只要 MovingAvg 实例还存在,事件监听器也存在。

然后,在 addWeakListener 中,我们创建一个 WeakRef,其目标是实际的事件监听器。在 wrapper 内部,我们对其进行 deref。由于 WeakRef 不会阻止其目标被垃圾回收(如果目标没有其他强引用),因此我们必须手动对其进行解引用以获取目标。如果目标在此期间已被垃圾回收,则 deref 会返回 undefined。否则,将返回原始目标,即我们随后使用 可选链 调用的 listener 函数。

由于事件监听器被包装在一个 WeakRef 中,因此对它的唯一强引用是 MovingAvg 实例上的 listener 属性。也就是说,我们已经成功地将事件监听器的生存期与 MovingAvg 实例的生存期绑定在一起。

回到可达性图,在使用 WeakRef 实现调用 start() 后,我们的对象图如下所示,其中虚线箭头表示弱引用。

在调用 stop() 后,我们删除了对监听器的唯一强引用

最终,在垃圾回收发生后,MovingAvg 实例和监听器将被回收

但这里仍然存在一个问题:我们通过将 listener 包装在一个 WeakRef 中添加了一层间接性,但 addWeakListener 中的包装器仍然存在泄漏,原因与 listener 最初泄漏的原因相同。当然,这是一个较小的泄漏,因为只有包装器在泄漏,而不是整个 MovingAvg 实例,但它仍然是一个泄漏。解决这个问题的方法是 WeakRef 的配套功能 FinalizationRegistry。使用新的 FinalizationRegistry API,我们可以注册一个回调,以便在垃圾回收器清除注册对象时运行。此类回调被称为终结器

注意:终结器回调不会在垃圾回收事件监听器后立即运行,因此不要将其用于重要的逻辑或指标。垃圾回收和终结器回调的时机是未指定的。事实上,一个永远不进行垃圾回收的引擎将完全符合标准。但是,可以安全地假设引擎进行垃圾回收,并且终结器回调将在稍后的某个时间被调用,除非环境被丢弃(例如,选项卡关闭或工作线程终止)。在编写代码时请牢记这种不确定性。

我们可以使用 FinalizationRegistry 注册一个回调,以便在内部事件监听器被垃圾回收时从套接字中删除 wrapper。我们的最终实现如下所示

const gListenersRegistry = new FinalizationRegistry(({ socket, wrapper }) => {
socket.removeEventListener('message', wrapper); // 6
});

function addWeakListener(socket, listener) {
const weakRef = new WeakRef(listener); // 2
const wrapper = (ev) => { weakRef.deref()?.(ev); }; // 3
gListenersRegistry.register(listener, { socket, wrapper }); // 4
socket.addEventListener('message', wrapper); // 5
}

class MovingAvg {
constructor(socket) {
this.events = [];
this.listener = (ev) => { this.events.push(ev); }; // 1
addWeakListener(socket, this.listener);
}
}

注意:gListenersRegistry 是一个全局变量,以确保终结器被执行。FinalizationRegistry 不会被注册在其上的对象保持活动状态。如果注册表本身被垃圾回收,则其终结器可能不会运行。

我们创建了一个事件监听器并将其分配给 this.listener,以便它被 MovingAvg 实例强引用 (1)。然后,我们将执行工作的事件监听器包装在一个 WeakRef 中,使其可被垃圾回收,并且不会通过 this 将其对 MovingAvg 实例的引用泄漏出去 (2)。我们创建了一个包装器,它会 deref WeakRef 以检查它是否还存在,如果存在,则调用它 (3)。我们将内部监听器注册到 FinalizationRegistry 上,将持有值 { socket, wrapper } 传递给注册 (4)。然后,我们将返回的包装器作为事件监听器添加到 socket 上 (5)。在 MovingAvg 实例和内部监听器被垃圾回收后,终结器可能会运行,并将持有值传递给它。在终结器内部,我们也删除了包装器,使与使用 MovingAvg 实例相关的所有内存都可被垃圾回收 (6)。

有了这一切,我们最初的 MovingAvgComponent 实现既不会泄漏内存,也不需要任何手动处置。

不要过度使用 #

在了解了这些新功能后,您可能很想对所有东西都使用 WeakRef。但是,这可能不是一个好主意。有些事情明确地不适合使用 WeakRef 和终结器。

一般来说,避免编写依赖于垃圾回收器清理 WeakRef 或在任何可预测的时间调用终结器的代码——这是不可能的!此外,对象是否可以被垃圾回收可能取决于实现细节,例如闭包的表示,这些细节既微妙又可能在不同的 JavaScript 引擎之间甚至同一个引擎的不同版本之间有所不同。具体来说,终结器回调

  • 可能不会在垃圾回收后立即发生。
  • 可能不会按与实际垃圾回收相同的顺序发生。
  • 可能根本不会发生,例如,如果浏览器窗口被关闭。

因此,不要将重要的逻辑放在终结器的代码路径中。它们对于响应垃圾回收执行清理很有用,但您不能可靠地使用它们来,例如,记录有关内存使用的有意义的指标。对于这种情况,请参阅 performance.measureUserAgentSpecificMemory

WeakRef 和终结器可以帮助您节省内存,并且在作为渐进增强手段谨慎使用时效果最佳。由于它们是高级用户功能,我们预计大多数使用情况会发生在框架或库中。

WeakRef 支持 #