跳到主要内容

V8 垃圾回收机制与内存泄露

问题

请介绍 V8 的垃圾回收机制,以及 JavaScript 中常见的内存泄露场景。

面试速答版

V8 的垃圾回收机制是怎样的? V8 用分代回收,因为「大多数对象生命很短」(弱分代假说):

  • 新生代(约 1~8MB,分 From/To 两个 Semi-space):用 Scavenge(复制算法),把存活对象从 From 复制到 To 然后角色互换。新生代回收快,但浪费一半空间。
  • 老生代(数百 MB):用 Mark-Sweep(标记清除)+ Mark-Compact(标记整理),避免内存碎片。
  • 晋升机制:经历过两次 Scavenge 仍存活、或者 To 空间使用率超 25% 的对象,会被晋升到老生代。
  • 优化点:增量标记(Incremental Marking)把 GC 拆成小段穿插执行;并发标记(Concurrent Marking)放到独立线程,减少主线程 STW(Stop-The-World)卡顿。

JavaScript 常见的内存泄漏场景? 核心是「该回收的对象还被引用着」:

  • 意外的全局变量function f() { x = 1 } 漏写 var/let,挂到 window 上不释放。
  • 未清理的定时器/事件监听setInterval 回调里引用 DOM,组件卸载没 clearInterval / removeEventListener
  • 闭包持有大对象:闭包引用的外部变量不会被 GC,尤其在 React 函数组件里要小心。
  • DOM 引用:JS 里缓存了 DOM 节点,DOM 已从树上移除,节点和子树仍无法回收(Detached DOM)。
  • Map/缓存无限增长:用普通 Map 做缓存,key 是对象时考虑改用 WeakMap/WeakSet,key 没人引用时自动回收。
  • 排查工具:Chrome DevTools 的 Memory 面板拍 Heap Snapshot,对比两次快照找 Detached/Retainer。

答案

V8 垃圾回收机制概述

V8 采用**分代式垃圾回收(Generational GC)策略,将内存分为新生代(Young Generation)老生代(Old Generation)**两个区域。

GC 怎么知道一个对象是不是「垃圾」?

在讲算法之前,先解决一个根本问题:JS 引擎怎么判断一个对象还有没有用?

答案是「可达性分析」——从一组叫 GC Roots 的「根」出发,沿着对象之间的引用关系往下走,走得到的就是活的,走不到的就是垃圾。这一步在新生代和老生代都叫「标记(Mark)」。

A、B、C 从 Root 出发能走到 → 存活;D、E 跟 Root 之间断了链路 → 垃圾,可以回收

GC Roots 包括
  • 全局对象(window / global / globalThis
  • 当前调用栈里的局部变量、参数
  • 活动的闭包变量
  • C++ 原生代码持有的引用(DOM 节点、Map/Set 内部表等)

搞清楚「怎么找到存活对象」之后,剩下的问题就是「找到之后怎么处理」。新生代和老生代选了两套完全不同的策略,原因下面会讲。

新生代垃圾回收:标记 + 复制搬家 + 双空间互换

新生代用于存放生存时间短的对象,默认大小约 1~8MB(V8 现代版本会动态调整,64 位常见 16MB)。它采用基于 Cheney 算法Scavenge(复制) 策略。

内存布局:From / To 双空间

新生代被一分为二:

  • From Space:当前正在使用、对外分配的那一半
  • To Space:闲置备用的那一半

新对象总是分配在 From Space。等到 From Space 快满了,就触发一次 Scavenge。

三步走:标记 → 复制搬家 → 角色互换

  1. 标记:从 GC Roots 出发遍历 From Space,找出所有可达对象(A、C、E)
  2. 复制搬家:边标记边把存活对象紧挨着复制到 To Space(自动整理,没碎片)
  3. 角色互换:From Space 整片直接当垃圾扔掉(不用一个个清理!),原来的 To 变 From、原来的 From 变 To,下一轮继续

为什么新生代用「复制搬家」?

关键洞察是「弱分代假说」:新生代里 90%+ 的对象都活不过一次 GC(短命的临时变量、函数参数、循环里的中间值……)。

既然垃圾占绝大多数、存活的只是少数派,那就不要去一个个清理垃圾,而是只搬走少数派、把剩下的整片扔掉

维度复制算法的好处
回收速度时间复杂度只跟「存活对象数量」相关,跟垃圾数量无关。10000 个对象里只活 100 个,那就只搬 100 个
没有碎片复制时按顺序紧挨着放,自动就是连续内存,新对象分配只要把指针往后挪一格(bump pointer),极快
实现简单"整片清空"比"挨个标记空闲块"快得多

代价就是「永远有一半内存闲着」,但新生代本来就只有几 MB,浪费 4MB 换来极致的回收速度,完全值得

一句话记住

新生代是**「少数派搬家」**——存活的少,那就把少的搬走,剩下的整片清空。

对象晋升(Promotion):什么时候搬到老生代?

不是所有对象都活该被反复在 From/To 之间搬来搬去——能活过一次 GC 的对象,大概率会活很久(典型的就是缓存、单例、长期持有的 DOM 引用)。把它们搬到老生代,不再参与新生代的频繁回收。

满足以下任一条件的对象会被**晋升(Promotion)**到老生代:

  1. 经历过一次 Scavenge 还活着——已经证明了「不是短命鬼」
  2. 本次复制后 To Space 使用率超过 25%——避免新生代很快又满,影响后续分配
// 短命对象:函数返回就死,根本不会晋升
function tempCalc(): number {
const buf = new Array(1000); // 在 From Space 里出生
return buf.length; // 函数结束 → 下次 GC 时是垃圾,整片扔掉
}

// 长命对象:第一次 GC 后存活 → 第二次 GC 时晋升到老生代
const cache: Record<string, unknown> = {};
function addToCache(key: string, value: unknown): void {
cache[key] = value; // 全局 cache 引用着 → 一直可达 → 晋升
}

老生代垃圾回收:标记 + 原地清除 + 整理碎片

老生代存放经过晋升的长寿对象大对象,空间是新生代的几十到几百倍(默认上限约 1.4GB,64 位)。这里的对象特征跟新生代完全反过来:大多数都是活的,垃圾才是少数派

为什么老生代不能继续用「复制搬家」?

如果还用 Scavenge,会出大问题:

  1. 空间浪费扛不住:新生代浪费 4MB 没事,老生代浪费几百 MB 谁也受不了
  2. 复制成本爆炸:复制算法的耗时正比于「存活对象数」,老生代存活率 90%+,搬一次的代价远超清理一次
  3. 大对象搬不动:一个几 MB 的 Buffer 反复复制,性能直接崩盘

所以老生代换成 Mark-Sweep(标记清除)+ Mark-Compact(标记整理) 组合。核心思路是「对象别动,原地把垃圾清掉」。

主力算法:Mark-Sweep(标记 + 原地清除)

  1. 标记:跟新生代一样,从 GC Roots 开始遍历,标记所有可达对象
  2. 原地清除:把没标记的对象所占的内存块加入「空闲列表(Free List)」,下次分配新对象时从这个列表里找空位

优点:存活对象不动(指针不用更新、CPU 缓存不失效),单次回收快。 缺点:空闲块大小不一,散落在内存各处,时间长了就成了「奶酪状」的内存碎片——明明剩 100MB,却找不到一块连续的 10MB 来放新对象。

兜底算法:Mark-Compact(标记 + 整理碎片)

当碎片严重到分配不出大对象时,V8 切换到 Mark-Compact:

Mark-Sweep 之后(碎片化):
[A][空][C][空空][E][空][G][空空空]
↑ 大对象想申请连续 5 格,找不到

Mark-Compact 整理后:
[A][C][E][G][空空空空空空空空]
↑ 连续 8 格可用,分配丝滑
  1. 标记:同上
  2. 整理:把存活对象全部往内存一端挤,更新所有引用它们的指针
  3. 清除:边界后面整片当空闲

代价:要移动对象、还要修正所有指针指向,比 Mark-Sweep 慢很多。所以 V8 平时主要用 Mark-Sweep,只在碎片实在严重时才偶尔做一次 Mark-Compact——典型的「大多数情况图快,少数情况图整齐」。

一句话记住

老生代是**「多数派原地清理」**——存活的多,搬不动也不该搬,那就标记完原地把垃圾抠掉,碎片多了再偶尔整理一次。

新生代 vs 老生代:策略为什么完全相反?

把上面的设计动机摆到一起,就一目了然了:

维度新生代老生代
对象寿命假设90%+ 是垃圾90%+ 是活的
算法选择复制(Scavenge)标记清除 + 偶尔整理
存活对象处理搬走(成本 ∝ 存活数)不动(成本 ∝ 总对象数)
空闲内存处理整片扔掉加入空闲列表,按需分配
碎片问题天然没有(复制时已紧凑)会有,靠 Mark-Compact 解决
空间利用率50%(永远一半闲置)接近 100%
触发频率高(每次 From 满就触发)低(增长到阈值才触发)
单次耗时极短(毫秒级)较长(几十~几百 ms)

核心洞察:分代回收的本质,就是承认对象有两类完全不同的群体,用两套针对各自特征最优的策略。强行统一只会两头不讨好。

Mark-Sweep 和 Mark-Compact 的取舍

性能权衡
  • Mark-Sweep:速度快,但产生碎片
  • Mark-Compact:消除碎片,但速度慢(需要移动对象)
  • V8 在多数情况下使用 Mark-Sweep,只在碎片严重时使用 Mark-Compact

增量标记(Incremental Marking)

为了避免 GC 造成的长时间停顿(Stop-The-World),V8 使用增量标记

  • 将标记工作分成多个小步骤
  • 每次只标记一小部分对象
  • 标记过程与 JavaScript 执行交替进行

并发标记(Concurrent Marking)

V8 进一步优化,在后台线程中执行标记工作:

  • 主线程:继续执行 JavaScript 代码
  • 辅助线程:并发执行标记任务
  • 写屏障(Write Barrier):跟踪并发期间的对象修改

常见内存泄露场景

1. 意外的全局变量

未声明的变量会自动成为全局变量,不会被回收。

// ❌ 错误示例:意外的全局变量
function createLeak(): void {
// 忘记使用 let/const/var
leakedData = new Array(1000000); // 自动成为 window.leakedData
}

// ✅ 正确示例
function createNoLeak(): void {
const data = new Array(1000000); // 函数执行完后可被回收
}
避免方法

启用严格模式:'use strict',未声明变量会报错

2. 被遗忘的定时器

定时器如果不清除,其回调函数引用的变量无法回收。

// ❌ 错误示例:未清除的定时器
class DataFetcher {
private data: unknown[] = [];

startFetching(): void {
setInterval(() => {
this.data.push(new Array(10000)); // 持续累积
}, 1000);
}
}

// ✅ 正确示例:及时清除定时器
class DataFetcherFixed {
private data: unknown[] = [];
private timerId: NodeJS.Timeout | null = null;

startFetching(): void {
this.timerId = setInterval(() => {
this.data.push(new Array(10000));
}, 1000);
}

stopFetching(): void {
if (this.timerId) {
clearInterval(this.timerId);
this.timerId = null;
this.data = []; // 释放数据
}
}
}

3. 闭包引用

闭包会持有外部变量的引用,即使不再需要也不会释放。

// ❌ 错误示例:闭包导致内存泄露
function createClosure() {
const largeData = new Array(1000000).fill('data');

return function smallTask() {
// 即使只用一个简单值,整个 largeData 也无法释放
return largeData.length;
};
}

const task = createClosure(); // largeData 一直存在内存中

// ✅ 正确示例:只保留需要的数据
function createClosureFixed() {
const largeData = new Array(1000000).fill('data');
const length = largeData.length; // 提取需要的值

return function smallTask() {
return length; // 只持有 length,largeData 可被回收
};
}

4. DOM 引用

移除的 DOM 节点如果还被 JavaScript 引用,无法回收。

// ❌ 错误示例:持有 DOM 引用
class TodoList {
private elements: Map<string, HTMLElement> = new Map();

addTodo(id: string, text: string): void {
const li = document.createElement('li');
li.textContent = text;
document.getElementById('todoList')?.appendChild(li);
this.elements.set(id, li); // 持有引用
}

removeTodo(id: string): void {
const element = this.elements.get(id);
element?.remove(); // DOM 移除了
// ❌ 但 Map 中还保留引用,导致内存泄露
}
}

// ✅ 正确示例:同步清理引用
class TodoListFixed {
private elements: Map<string, HTMLElement> = new Map();

addTodo(id: string, text: string): void {
const li = document.createElement('li');
li.textContent = text;
document.getElementById('todoList')?.appendChild(li);
this.elements.set(id, li);
}

removeTodo(id: string): void {
const element = this.elements.get(id);
element?.remove();
this.elements.delete(id); // ✅ 同步删除引用
}
}

5. 事件监听器未移除

添加的事件监听器如果不移除,会导致相关对象无法回收。

// ❌ 错误示例:未移除事件监听器
class ButtonHandler {
private data = new Array(100000);

attachListener(): void {
const button = document.getElementById('myButton');
button?.addEventListener('click', () => {
console.log(this.data.length);
});
// ❌ 即使 ButtonHandler 实例不再使用,监听器仍持有 this 引用
}
}

// ✅ 正确示例:移除事件监听器
class ButtonHandlerFixed {
private data = new Array(100000);
private clickHandler = (): void => {
console.log(this.data.length);
};

attachListener(): void {
const button = document.getElementById('myButton');
button?.addEventListener('click', this.clickHandler);
}

detachListener(): void {
const button = document.getElementById('myButton');
button?.removeEventListener('click', this.clickHandler);
}

destroy(): void {
this.detachListener();
this.data = []; // 清理数据
}
}

6. 循环引用(已少见)

现代 JavaScript 引擎能处理简单循环引用,但复杂场景仍需注意。

// ⚠️ 可能有问题:与 DOM 的循环引用
interface DOMData {
element: HTMLElement;
data: unknown[];
}

function createCircularReference(): void {
const element = document.getElementById('myDiv');
const data: DOMData = {
element: element!,
data: new Array(100000)
};

// 在 DOM 上保存引用
(element as any).data = data; // 循环引用
}

// ✅ 使用 WeakMap 避免循环引用
const domDataMap = new WeakMap<HTMLElement, unknown[]>();

function createNoCircularReference(): void {
const element = document.getElementById('myDiv');
if (element) {
domDataMap.set(element, new Array(100000));
// WeakMap 允许 element 被回收时,数据自动清理
}
}

7. 控制台日志

开发环境中的 console.log 也会持有对象引用。

// ⚠️ 生产环境问题
function processLargeData(): void {
const bigData = new Array(1000000).fill('test');
console.log('Processing data:', bigData); // 持有引用
// bigData 无法被回收(如果控制台未清空)
}

// ✅ 生产环境移除日志
const isDev = process.env.NODE_ENV === 'development';

function processLargeDataFixed(): void {
const bigData = new Array(1000000).fill('test');
if (isDev) {
console.log('Processing data:', bigData);
}
// 或使用日志库,生产环境自动禁用
}

内存泄露检测工具

Chrome DevTools Memory Profiler

使用步骤
  1. 打开 Chrome DevTools → Memory 标签
  2. 选择 Heap snapshot(堆快照)
  3. 执行可能泄露的操作
  4. 再次拍摄快照
  5. 对比两次快照,找出增长的对象

关键指标

  • Shallow Size:对象自身占用的内存
  • Retained Size:对象及其引用的所有对象占用的内存
  • Retainers:谁持有该对象的引用

Node.js 内存分析

// 查看内存使用情况
const memUsage = process.memoryUsage();
console.log({
rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`, // 常驻内存
heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`, // 堆总大小
heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`, // 已用堆内存
external: `${Math.round(memUsage.external / 1024 / 1024)}MB` // C++ 对象内存
});

// 手动触发 GC(需要 --expose-gc 参数)
if (global.gc) {
global.gc();
console.log('GC triggered');
}

最佳实践总结

防止内存泄露的建议
  1. 及时清理:定时器、事件监听器、DOM 引用
  2. 使用 WeakMap/WeakSet:存储对象引用(允许自动回收)
  3. 避免全局变量:尽量使用局部变量、启用严格模式
  4. 优化闭包:只保留必要的外部变量
  5. 定期监控:使用性能分析工具检测内存增长
  6. 生产环境移除日志:避免 console.log 持有大对象
  7. 组件销毁时清理:React/Vue 组件卸载时清理资源

常见面试问题

Q1: V8 为什么要分代回收?

答案

基于弱分代假说(Weak Generational Hypothesis)

  1. 大部分对象生命周期很短(临时变量、函数参数等)
  2. 少部分对象生命周期很长(全局对象、缓存等)

分代回收的优势

  • 新生代:使用快速的 Scavenge 算法,频繁回收短周期对象
  • 老生代:使用完整的标记算法,较少回收长周期对象
  • 性能优化:针对不同特征的对象使用不同策略,整体效率更高

对比表格:

策略新生代老生代
对象特征生命周期短(90% 以上)生命周期长
回收频率高(毫秒级)低(秒级)
算法Scavenge(复制)Mark-Sweep/Compact
单次耗时极短(<10ms)较长(100ms+)

Q2: 什么是 Stop-The-World?V8 如何优化?

答案

Stop-The-World (STW) 是指垃圾回收时暂停所有 JavaScript 执行,会导致:

  • 页面卡顿
  • 动画掉帧
  • 接口响应延迟

V8 的优化策略

  1. 增量标记(Incremental Marking)

    • 将标记工作拆分成多个小步骤
    • 与 JavaScript 执行交替进行
    • 减少单次停顿时间
  2. 并发标记(Concurrent Marking)

    • 在后台线程执行标记
    • 主线程继续运行 JavaScript
    • 通过写屏障跟踪变化
  3. 并发清除(Concurrent Sweeping)

    • 在后台线程清除未标记对象
    • 不阻塞主线程
  4. 惰性清除(Lazy Sweeping)

    • 按需清除内存页
    • 分散清除工作

对比表格:

技术停顿时间实现复杂度V8 版本
传统 GC100-200ms-
增量标记每次 5-10msV8 v4.0+
并发标记<5msV8 v6.6+
并发清除<1msV8 v7.0+

Q3: WeakMap 和 Map 在内存管理上有什么区别?

答案

核心区别:WeakMap 的键是弱引用,不阻止垃圾回收。

// Map:强引用,阻止回收
const map = new Map();
let obj = { data: new Array(1000000) };
map.set(obj, 'value');

obj = null; // ❌ 对象无法被回收,Map 仍持有引用

// WeakMap:弱引用,允许回收
const weakMap = new WeakMap();
let obj2 = { data: new Array(1000000) };
weakMap.set(obj2, 'value');

obj2 = null; // ✅ 对象可以被回收,WeakMap 不会阻止

对比表格

特性MapWeakMap
键类型任意类型只能是对象
引用类型强引用弱引用(键)
可枚举✅ 可遍历❌ 不可遍历
垃圾回收手动清理自动回收
使用场景通用缓存DOM 元数据、临时关联

实际应用场景

// 为 DOM 元素关联数据(无需手动清理)
const domMetadata = new WeakMap<HTMLElement, { clicks: number }>();

function trackClicks(element: HTMLElement): void {
element.addEventListener('click', () => {
const meta = domMetadata.get(element) || { clicks: 0 };
meta.clicks++;
domMetadata.set(element, meta);
});
}

// 当 DOM 元素被移除时,metadata 自动被回收

Q4: 如何排查生产环境的内存泄露?

答案

排查步骤

  1. 监控内存趋势

    // 定期上报内存使用情况
    setInterval(() => {
    const mem = performance.memory;
    if (mem) {
    reportMetrics({
    usedJSHeapSize: mem.usedJSHeapSize,
    totalJSHeapSize: mem.totalJSHeapSize,
    jsHeapSizeLimit: mem.jsHeapSizeLimit
    });
    }
    }, 60000); // 每分钟上报
  2. 使用 Chrome DevTools

    • 录制 Heap Snapshot(堆快照)
    • 对比多个时间点的快照
    • 查看 Retainers(持有者)找到泄露源
  3. Performance Monitor

    • 监控 JS heap size 趋势
    • 查看是否持续增长不回落
  4. 代码审查重点

    • 全局变量和缓存
    • 事件监听器和定时器清理
    • 闭包使用
    • 第三方库的生命周期管理

快速诊断技巧

// 创建一个诊断函数
function memoryLeakCheck(): void {
const baseline = performance.memory.usedJSHeapSize;

// 执行疑似泄露的操作
for (let i = 0; i < 100; i++) {
suspiciousOperation();
}

// 手动触发 GC(Chrome 需要 --expose-gc)
if ('gc' in window) {
(window as any).gc();
}

// 检查内存增长
const current = performance.memory.usedJSHeapSize;
const growth = current - baseline;

if (growth > 10 * 1024 * 1024) { // 增长超过 10MB
console.warn('Possible memory leak detected:', {
growth: `${Math.round(growth / 1024 / 1024)}MB`,
operation: 'suspiciousOperation'
});
}
}

预防措施

  1. 代码规范:强制清理生命周期(ESLint 规则)
  2. 单元测试:测试组件卸载后内存是否释放
  3. 自动化监控:CI/CD 集成内存泄露检测工具
  4. 定期审计:Review 缓存、监听器、定时器代码

Q5: 常见的内存泄漏场景有哪些?如何排查?

答案

六大常见内存泄漏场景

场景原因解决方案
意外全局变量未声明的变量挂到 window启用 'use strict'、使用 let/const
未清除的定时器setInterval/setTimeout 回调持有引用组件卸载时 clearInterval/clearTimeout
未移除的事件监听addEventListener 后未 removeEventListener保存引用,卸载时移除
闭包引用闭包持有外部大对象只保留必要数据,手动置 null
游离 DOM 引用JS 变量引用已移除的 DOM 节点使用 WeakMap 或同步删除引用
console.log控制台持有打印对象的引用生产环境移除 console.log

排查流程

// 1. 使用 Performance Monitor 观察 JS Heap Size 趋势
// Chrome DevTools → More tools → Performance Monitor
// 如果堆内存持续增长不回落,说明存在泄漏

// 2. 使用 Heap Snapshot 对比法
// 步骤:
// a. 操作前拍摄 Snapshot 1
// b. 执行疑似泄漏的操作(如打开/关闭弹窗多次)
// c. 手动点击 GC 按钮(垃圾桶图标)
// d. 拍摄 Snapshot 2
// e. 选择 "Comparison" 视图对比两次快照

// 3. 编程式检测内存增长
function detectMemoryLeak(
operation: () => void,
iterations: number = 50
): void {
const measurements: number[] = [];

for (let i = 0; i < iterations; i++) {
operation();

// 尝试触发 GC(Chrome 需 --expose-gc 启动)
if (typeof globalThis.gc === 'function') {
globalThis.gc();
}

if (performance.memory) {
measurements.push(performance.memory.usedJSHeapSize);
}
}

// 分析趋势:如果内存持续增长,可能存在泄漏
const first = measurements[0];
const last = measurements[measurements.length - 1];
const growth = last - first;

console.table({
'初始内存': `${(first / 1024 / 1024).toFixed(2)} MB`,
'最终内存': `${(last / 1024 / 1024).toFixed(2)} MB`,
'内存增长': `${(growth / 1024 / 1024).toFixed(2)} MB`,
'疑似泄漏': growth > 5 * 1024 * 1024 ? '是' : '否',
});
}

Heap Snapshot 关键指标

  • Shallow Size:对象自身占用的内存大小
  • Retained Size:对象被 GC 回收后能释放的总内存(包括它引用的其他对象)
  • Retainers:谁持有该对象的引用(泄漏的根本原因)
排查技巧

在 Heap Snapshot 的 "Comparison" 视图中,重点关注 #Delta(新增对象数量)列。如果某类对象持续新增且不减少,就是泄漏的嫌疑对象。通过 Retainers 面板可以追溯到持有它引用的源头。

Q6: WeakRef 和 FinalizationRegistry 是什么?如何用于内存优化?

答案

WeakRefFinalizationRegistry 是 ES2021 引入的两个与垃圾回收相关的 API,提供了对 GC 行为更细粒度的控制。

WeakRef(弱引用)

  • 持有对象的弱引用,不会阻止 GC 回收该对象
  • 通过 .deref() 获取目标对象,如果已被回收则返回 undefined
  • WeakMap/WeakSet 不同,WeakRef 可以直接引用对象并检查其存活状态
// WeakRef 基本用法
let heavyObject: { data: number[] } | undefined = {
data: new Array(1000000).fill(0),
};

const weakRef = new WeakRef(heavyObject);

// 访问对象
const obj = weakRef.deref();
if (obj) {
console.log('对象还活着,数据长度:', obj.data.length);
} else {
console.log('对象已被 GC 回收');
}

// 移除强引用后,对象可以被 GC 回收
heavyObject = undefined;
// 之后某个时刻 weakRef.deref() 会返回 undefined

FinalizationRegistry(终结器注册表)

  • 注册一个回调函数,当目标对象被 GC 回收时自动调用
  • 回调接收注册时提供的 heldValue(不能是被观察的对象本身)
  • 适用于需要在对象被回收后执行清理操作的场景
// FinalizationRegistry 基本用法
const registry = new FinalizationRegistry<string>((heldValue) => {
console.log(`对象 "${heldValue}" 已被 GC 回收,执行清理...`);
});

function createTrackedObject(name: string): object {
const obj = { data: new Array(100000) };
registry.register(obj, name); // 注册对象和持有值
return obj;
}

let tracked: object | null = createTrackedObject('largeData');
tracked = null; // 移除引用后,GC 回收时会触发回调

实际应用:带自动清理的缓存

class WeakCache<K extends object, V> {
private cache = new Map<string, WeakRef<V & object>>();
private keyMap = new Map<string, K>();
private registry: FinalizationRegistry<string>;

constructor() {
// 当缓存的值被 GC 回收时,自动清理 Map 中的条目
this.registry = new FinalizationRegistry<string>((cacheKey) => {
this.cache.delete(cacheKey);
this.keyMap.delete(cacheKey);
console.log(`缓存条目 "${cacheKey}" 已自动清理`);
});
}

set(key: string, value: V & object): void {
// 如果旧值存在,先取消注册
const oldRef = this.cache.get(key);
if (oldRef) {
const oldValue = oldRef.deref();
if (oldValue) {
this.registry.unregister(oldValue);
}
}

const ref = new WeakRef(value);
this.cache.set(key, ref);

// 注册终结器,value 被回收时自动清理 cache 条目
this.registry.register(value, key, value);
}

get(key: string): V | undefined {
const ref = this.cache.get(key);
if (!ref) return undefined;

const value = ref.deref();
if (!value) {
// 值已被回收,清理残留条目
this.cache.delete(key);
return undefined;
}

return value;
}

get size(): number {
return this.cache.size;
}
}

// 使用示例
const imageCache = new WeakCache<object, HTMLImageElement>();

function loadImage(url: string): HTMLImageElement {
let img = imageCache.get(url);
if (img) {
console.log('命中缓存:', url);
return img;
}

img = new Image();
img.src = url;
imageCache.set(url, img);
return img;
}

WeakRef vs WeakMap 对比

特性WeakRefWeakMap
作用弱引用单个对象弱引用作为键的对象
检查存活.deref() 返回对象或 undefined.has() / .get()
可手动解引用否(只能通过键访问)
配合 GC 回调搭配 FinalizationRegistry
典型场景缓存、观察对象生命周期DOM 元素关联数据、私有属性
使用注意
  • GC 的触发时机是不确定的FinalizationRegistry 的回调不保证一定执行
  • 不要依赖 WeakRefFinalizationRegistry 来实现关键业务逻辑
  • 它们主要用于性能优化和资源清理,作为"尽力而为"的辅助手段
  • deref() 在同一个事件循环的同步代码中,对同一个 WeakRef 会返回一致的结果

相关链接