For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/proposal-weakrefs.md.
  • 简体中文
  • WeakRefs S4

    中文标题:WeakRefs 提案

    提案概览
    提案速览

    该提案为 ECMAScript 添加了两个新 API:WeakRef 用于创建对对象的弱引用,以及 FinalizationRegistry 用于在对象被垃圾回收后运行用户定义的清理代码。它解决了内存管理用例,如弱缓存、外部资源泄漏检测和跨 worker 代理内存泄漏。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    WeakRefs TC39 提案

    状态

    简介

    WeakRef 提案包含两大主要新功能:

    1. 使用 WeakRef 类创建对象的弱引用
    2. 使用 FinalizationRegistry 类在对象被垃圾回收后运行用户定义的终结器

    这些接口可以根据用例独立或一起使用。

    有关开发人员参考文档,请参阅 reference.md

    注意事项

    本提案包含两个高级功能,WeakRefFinalizationRegistry。正确使用它们需要深思熟虑,如果可能,最好避免使用。

    垃圾收集器 很复杂。如果应用程序或库依赖 GC 及时、可预测地清理 WeakRef 或调用终结器,那么它们很可能会失望:清理可能会比预期晚很多,或者根本不会发生。可变性来源包括:

    • 一个对象可能比另一个对象更早被垃圾回收,即使它们同时变得不可达,例如,由于分代收集。
    • 垃圾收集工作可以使用增量和并发技术随时间分片进行。
    • 可以使用各种运行时启发式方法来平衡内存使用和响应能力。
    • JavaScript 引擎可能持有对看起来不可达对象的引用(例如,在闭包或内联缓存中)。
    • 不同的 JavaScript 引擎可能以不同的方式执行这些操作,或者同一引擎可能在版本间改变其算法。
    • 复杂的因素可能导致对象存活意想不到的长时间,例如与某些 API 一起使用时。

    重要的逻辑不应放在终结器的代码路径中。这样做可能会产生由内存管理错误或甚至是 JavaScript 垃圾收集器实现之间的差异引起的面向用户的问题。例如,如果数据仅从终结器持久保存,那么一个意外保持额外引用的错误可能导致数据丢失。

    因此,W3C TAG 设计原则 建议不要创建暴露垃圾收集的 API。最好将 WeakRef 对象和 FinalizationRegistry 对象用作避免过度内存使用或作为针对某些错误的备用方案,而不是作为清理外部资源或观察分配情况的常规方式。

    弱引用

    对对象的弱引用不足以保持对象存活:当对引用对象(即被弱引用引用的对象)的唯一剩余引用是弱引用时,垃圾收集可以自由地销毁引用对象并将其内存重新用于其他用途。然而,在对象实际被销毁之前,即使没有强引用,弱引用也可能返回该对象。

    弱引用的主要用途是实现保存大对象的缓存或映射,在这些场景中,希望大对象不会仅仅因为出现在缓存或映射中就保持存活。

    例如,如果您有许多大型二进制图像对象(例如,表示为 ArrayBuffer),您可能希望将名称与每个图像关联起来。现有的数据结构无法满足此需求:

    • 如果您使用 Map 将名称映射到图像,或将图像映射到名称,则图像对象将仅因为作为映射中的值或键而保持存活。
    • WeakMap 也不适合此目的:它们对其 是弱的,但在这种情况下,我们需要一个对其 是弱的结构。

    相反,我们可以使用一个其值为 WeakRef 对象的 Map,这些 WeakRef 对象指向 ArrayBuffer。这样,我们可以避免将这些 ArrayBuffer 对象在内存中保留比原本更长的时间:这是一种在图像对象仍然存在时找到它的方法,但如果它被垃圾回收,我们将重新生成它。这样,在某些情况下可以减少内存使用。

    // 此技术不完整;请参阅下文。
    function makeWeakCached(f) {
      const cache = new Map();
      return key => {
        const ref = cache.get(key);
        if (ref) {
          const cached = ref.deref();
          if (cached !== undefined) return cached;
        }
    
        const fresh = f(key);
        cache.set(key, new WeakRef(fresh));
        return fresh;
      };
    }
    
    var getImageCached = makeWeakCached(getImage);

    这种技术有助于避免在没有人再查看的 ArrayBuffer 上花费大量内存,但它仍然存在一个问题:随着时间的推移,Map 将填满指向其引用对象已被回收的 WeakRef 的字符串。解决此问题的一种方法是定期清理缓存并清除死条目。另一种方法是使用终结器,我们将在本文末尾回到这一点。

    此示例中可见 API 的几个要素:

    • WeakRef 构造函数接受一个参数,该参数必须是对象,并返回对它的弱引用。
    • WeakRef 实例有一个 deref 方法,返回两个值之一:
      • 如果仍然可用,则返回传入构造函数的对象。
      • 如果没有其他对象指向该对象且它已被垃圾回收,则返回 undefined

    终结器

    终结 是在对象对程序执行变得不可达后执行清理代码。用户定义的终结器支持多种新的用例,并有助于在管理垃圾收集器不了解的资源时防止内存泄漏。

    另一个注意事项

    终结器是棘手的事情,最好避免使用。它们可能在意外的时间被调用,或者根本不会被调用——例如,在关闭浏览器标签页或进程退出时不会调用它们。它们不能帮助垃圾收集器完成其工作;相反,它们是一种障碍。此外,它们会扰乱垃圾收集器的内部记账。GC 在认为需要时,在分配一定量之后决定扫描堆。可终结对象几乎总是代表对垃圾收集器不可见的分配量。结果可能是,具有可终结对象的系统的实际资源使用量高于 GC 认为的应该有的量。

    建议的规范允许符合要求的实现出于任何原因或无原因跳过调用终结回调。许多 JS 环境和实现可能省略终结回调的一些原因:

    • 如果程序关闭(例如,进程退出、关闭标签页、导航离开页面),终结回调通常不会在退出时运行。(讨论:#125
    • 如果 FinalizationRegistry 变得“死亡”(大约,不可达),那么针对它注册的终结回调可能不会运行。(讨论:#66

    尽管如此,有时终结器是解决问题的正确方法。以下示例展示了一些没有终结器将难以解决的重要问题。

    定位和响应外部资源泄漏

    终结器可以定位外部资源泄漏。例如,如果打开的文件被垃圾回收,底层操作系统资源可能会泄漏。尽管操作系统可能在进程退出时释放资源,但这种泄漏可能导致长时间运行的进程最终耗尽可用的文件句柄数。为了捕获这些错误,可以使用 FinalizationRegistry 来记录在关闭之前被垃圾回收的文件对象的存在。

    FinalizationRegistry 类表示一组使用公共终结回调注册的对象。此构造可用于告知开发人员有关从未关闭的文件的信息。

    class FileStream {
      static #cleanUp(heldValue) {
        console.error(`File leaked: ${file}!`);
      }
    
      static #finalizationGroup = new FinalizationRegistry(FileStream.#cleanUp);
    
      #file;
    
      constructor(fileName) {
        this.#file = new File(fileName);
        FileStream.#finalizationGroup.register(this, this.#file, this);
        // 急切地触发将文件内容异步读入 this.data
      }
    
      close() {
        FileStream.#finalizationGroup.unregister(this);
        File.close(this.#file);
        // 其他清理工作
      }
    
      async *[Symbol.iterator]() {
        // 从 this.#file 读取数据
      }
    }
    
    const fs = new FileStream('path/to/some/file');
    
    for await (const data of fs) {
      // 做一些事情
    }
    fs.close();

    注意,通过终结器自动关闭文件不是一个好主意,因为这种技术不可靠,可能导致资源耗尽。相反,建议显式释放资源(例如,通过 try/finally)。因此,此示例记录错误而不是透明地关闭文件。

    此示例展示了整个 FinalizationRegistry API 的用法:

    • 可以通过调用 FinalizationRegistryregister 方法来引用对象的终结器。在这种情况下,向 register 方法传递了三个参数:
      • 我们关心其生命周期的对象。这里,那是 this,即 FileStream 对象。
      • 一个持有值,在终结器中清理该对象时用来表示该对象。这里,持有值是底层的 File 对象。(注意:持有值不应引用弱目标,因为那会阻止目标被收集。)
      • 一个注销令牌,当不再需要终结器时传递给 unregister 方法。这里我们使用 this,即 FileStream 对象本身,因为 FinalizationRegistry 不持有对注销令牌的强引用。
    • FinalizationRegistry 构造函数接受一个回调作为参数。该回调使用持有值调用。

    终结回调在对象被垃圾回收 之后 调用,这种模式有时被称为“事后”。因此,FinalizerRegistry 回调使用单独的持有值调用,而不是原始对象——对象已经消失,所以不能使用它。

    在上述代码示例中,fs 对象将在 close 方法中被注销,这意味着终结器不会被调用,也不会有错误日志语句。注销可用于避免其他类型的“双重释放”场景。

    将 WebAssembly 内存暴露给 JavaScript

    每当您有一个由 WebAssembly 中的某些内容支持的 JavaScript 对象时,您可能希望在对象消失时运行自定义清理代码(在 WebAssembly 或 JavaScript 中)。先前的提案 暴露了一个弱引用的集合,其想法是通过定期检查它们是否仍然存活来采取终结操作。本提案包含了一流的终结器概念,以便为开发人员提供一种避免重复扫描的方法。

    例如,假设您有一个大的 WebAssembly.Memory 对象,并且您想创建一个分配器,将它的固定大小的部分提供给 JavaScript。在某些情况下,显式释放此内存可能是可行的,但通常,JavaScript 代码会自由地传递引用,而不考虑所有权。因此,能够依赖垃圾收集器来释放此内存是有帮助的。可以使用 FinalizationRegistry 来释放内存。

    function makeAllocator(size, length) {
      const freeList = Array.from({length}, (v, i) => size * i);
      const memory = new ArrayBuffer(size * length);
      const finalizationGroup = new FinalizationRegistry(
        held => freeList.unshift(held));
      return { memory, size, freeList, finalizationGroup };
    }
    
    function allocate(allocator) {
      const { memory, size, freeList, finalizationGroup } = allocator;
      if (freeList.length === 0) throw new RangeError('out of memory');
      const index = freeList.shift();
      const buffer = new Uint8Array(memory, index * size, size);
      finalizationGroup.register(buffer, index);
      return buffer;
    }

    此代码使用了 FinalizationRegistry API 的几个特性:

    • 可以通过调用 FinalizationRegistryregister 方法来引用对象的终结器。在这种情况下,向 register 方法传递两个参数:
      • 我们关心其生命周期的对象。这里,那是 Uint8Array
      • 一个持有值,在终结器中清理该对象时用来表示该对象。在这种情况下,持有值是一个整数,对应于 WebAssembly.Memory 对象内的偏移量。
    • FinalizationRegistry 构造函数接受一个回调作为参数。该回调使用持有值调用。

    FinalizationRegistry 回调可能被多次调用,每个注册的死亡对象调用一次,并带有相关的持有值。该回调不会在其他 JavaScript 代码执行期间调用,而是在“轮次之间”调用。引擎可以自由地批处理调用,并且批处理调用仅在所有 Promise 被处理后才运行。引擎如何批处理回调是实现相关的,不应依赖这些回调如何与 Promise 工作交错进行。

    避免跨 worker 代理的内存泄漏

    在使用 web workers 的浏览器中,程序员可以创建具有多个 JavaScript 进程的系统,从而具有多个隔离的堆和多个垃圾收集器。开发人员通常希望能够从其他进程寻址“远程”对象,例如,能够从 worker 操作 DOM。解决此问题的常见方法是实现代理库;两个例子是 Comlinkvia.js

    在具有代理和进程的系统中,远程代理需要保持本地对象存活,反之亦然。通常,这是通过让每个进程维护一个将远程描述符映射到已被代理的每个本地对象的表来实现的。然而,当不再有远程代理时,应从表中删除这些条目。使用 WeakRef 提案中的终结功能,像 via.js 这样的库可以在代理变得可收集时发送消息,通知对象的进程该对象不再被远程引用。没有终结,via.js 和其他远程代理系统必须退回到泄漏内存或手动资源管理。

    注意:这种设置无法跨 worker 收集循环。如果在每个 worker 中,本地对象持有对远程对象的代理的引用,那么本地对象的远程描述符会阻止远程对象的代理被收集。当代理库之外的代码不再引用它们时,没有任何对象可以被自动收集。为避免泄漏,必须显式打破跨隔离堆的循环。

    一起使用 WeakRef 对象和 FinalizationRegistry 对象

    有时一起使用 WeakRefFinalizationRegistry 是有意义的。有几种数据结构想要弱指向一个值,并在该值消失时进行某种清理。但是请注意,弱引用在其对象被收集时会被清除,但关联的 FinalizationRegistry 清理处理程序仅在稍后的任务中运行;在同一对象上使用弱引用和终结器的编程习惯需要注意这种差距。

    弱缓存

    在本 README 的初始示例中,makeWeakCached 使用了一个其值包装在 WeakRef 实例中的 Map。这允许缓存的值被收集,但以映射中条目的形式泄漏内存。更完整的 makeWeakCached 版本使用终结器来修复此内存泄漏。

    // 修复后不会泄漏内存的版本。
    function makeWeakCached(f) {
      const cache = new Map();
      const cleanup = new FinalizationRegistry(key => {
        // 请参阅下文关于并发注意事项的说明。
        const ref = cache.get(key);
        if (ref && !ref.deref()) cache.delete(key);
      });
    
      return key => {
        const ref = cache.get(key);
        if (ref) {
          const cached = ref.deref();
          // 请参阅下文关于并发注意事项的说明。
          if (cached !== undefined) return cached;
        }
    
        const fresh = f(key);
        cache.set(key, new WeakRef(fresh));
        cleanup.register(fresh, key);
        return fresh;
      };
    }
    
    var getImageCached = makeWeakCached(getImage);

    此示例说明了关于终结器的两个重要注意事项:

    1. 终结器在“主”程序和清理回调之间引入了并发。弱缓存清理函数必须检查“主”程序是否在缓存值被收集到清理函数运行之间重新向映射添加了条目,以避免删除活动条目。同样,在引用映射中查找键时,可能值已被收集,但清理回调尚未运行。
    2. 鉴于终结器的行为可能令人惊讶,最好将它们部署在防止误用的精心设计的抽象后面,例如上面的 makeWeakCached。在整个代码库中散布大量的 FinalizationRegistry 使用是一种代码坏味道。

    可迭代的 WeakMaps

    在某些高级情况下,WeakRef 对象和 FinalizationRegistry 对象可以成为非常有效的补充。例如,WeakMaps 有一个限制,即无法迭代或清除。WeakRefs 提案使得创建“可迭代 + 可清除的 WeakMap”成为可能:

    此类“可迭代的 WeakMap”已经用于现有的 DOM API,例如 document.getElementsByClassNamedocument.getElementsByTagName,它们返回实时的 HTMLCollection。因此,WeakRef 提案添加了有助于解释现有 Web 平台功能的缺失功能Issue #17 描述了类似的用例。

    class IterableWeakMap {
      #weakMap = new WeakMap();
      #refSet = new Set();
      #finalizationGroup = new FinalizationRegistry(IterableWeakMap.#cleanup);
    
      static #cleanup({ set, ref }) {
        set.delete(ref);
      }
    
      constructor(iterable) {
        for (const [key, value] of iterable) {
          this.set(key, value);
        }
      }
    
      set(key, value) {
        const ref = new WeakRef(key);
    
        this.#weakMap.set(key, { value, ref });
        this.#refSet.add(ref);
        this.#finalizationGroup.register(key, {
          set: this.#refSet,
          ref
        }, ref);
      }
    
      get(key) {
        const entry = this.#weakMap.get(key);
        return entry && entry.value;
      }
    
      delete(key) {
        const entry = this.#weakMap.get(key);
        if (!entry) {
          return false;
        }
    
        this.#weakMap.delete(key);
        this.#refSet.delete(entry.ref);
        this.#finalizationGroup.unregister(entry.ref);
        return true;
      }
    
      *[Symbol.iterator]() {
        for (const ref of this.#refSet) {
          const key = ref.deref();
          if (!key) continue;
          const { value } = this.#weakMap.get(key);
          yield [key, value];
        }
      }
    
      entries() {
        return this[Symbol.iterator]();
      }
    
      *keys() {
        for (const [key, value] of this) {
          yield key;
        }
      }
    
      *values() {
        for (const [key, value] of this) {
          yield value;
        }
      }
    }
    
    
    const key1 = { a: 1 };
    const key2 = { b: 2 };
    const keyValuePairs = [[key1, 'foo'], [key2, 'bar']];
    const map = new IterableWeakMap(keyValuePairs);
    
    for (const [key, value] of map) {
      console.log(`key: ${JSON.stringify(key)}, value: ${value}`);
    }
    // key: {"a":1}, value: foo
    // key: {"b":2}, value: bar
    
    for (const key of map.keys()) {
      console.log(`key: ${JSON.stringify(key)}`);
    }
    // key: {"a":1}
    // key: {"b":2}
    
    for (const value of map.values()) {
      console.log(`value: ${value}`);
    }
    // value: foo
    // value: bar
    
    map.get(key1);
    // → foo
    
    map.delete(key1);
    // → true
    
    for (const key of map.keys()) {
      console.log(`key: ${JSON.stringify(key)}`);
    }
    // key: {"b":2}

    请记住谨慎使用像这个可迭代 WeakMap 这样强大的构造。使用类似语义设计的 Web API 被广泛认为是遗留错误。最好避免在您的应用程序中暴露垃圾收集时机,并且仅在问题无法以其他方式合理解决时才使用弱引用和终结器。

    WeakMaps 仍然是基础

    简单地使用 MapWeakRef 对象作为键是无法重新创建 WeakMap 的:如果此类映射中的值引用其键,则条目无法被收集。真正的 WeakMap 实现使用ephemerons 来允许垃圾收集器处理此类循环。
    这就是 IterableWeakMap 示例 将值保留在 WeakMap 中而仅将 WeakRef 放在 Set 中以进行迭代的原因。如果值被添加到 Map 中,例如 this.#refMap.set(ref, value),那么以下内容将泄漏:

    let key = { foo: 'bar' };
    const map = new IterableWeakMap(key, { data: 123, key });

    终结器的调度和多个 .deref() 调用的一致性

    在某些情况下,实现可能会稍后调用终结回调或根本不调用。WeakRefs 提案与宿主环境(例如,HTML,Node.js)合作,精确定义 FinalizationRegistry 回调的调度方式。目的是加粗垃圾收集可观察性的粒度,降低程序过于依赖任何特定实现的细节的可能性。

    HTML 定义 中,回调被安排到 事件循环中排队任务。这意味着,在 Web 上,终结器永远不会中断同步 JavaScript,也不会与 Promise 反应交错。相反,它们仅在 JavaScript 让出给事件循环后运行。

    WeakRefs 提案保证在特定时间段内多次调用 WeakRef.prototype.deref() 返回相同的结果:要么都返回 undefined,要么都返回对象。在 HTML 中,此时间段持续到 微任务检查点,这是 HTML 在 JavaScript 执行栈变空之后、所有 Promise 反应运行之后执行微任务检查点。

    历史文档

    Champion

    • Dean Tribble
    • Mark Miller
    • Till Schneidereit
    • Sathya Gunasekaran
    • Daniel Ehrenberg

    状态