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 之间断了链路 → 垃圾,可以回收。
- 全局对象(
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。
三步走:标记 → 复制搬家 → 角色互换
- 标记:从 GC Roots 出发遍历 From Space,找出所有可达对象(A、C、E)
- 复制搬家:边标记边把存活对象紧挨着复制到 To Space(自动整理,没碎片)
- 角色互换:From Space 整片直接当垃圾扔掉(不用一个个清理!),原来的 To 变 From、原来的 From 变 To,下一轮继续
为什么新生代用「复制搬家」?
关键洞察是「弱分代假说」:新生代里 90%+ 的对象都活不过一次 GC(短命的临时变量、函数参数、循环里的中间值……)。
既然垃圾占绝大多数、存活的只是少数派,那就不要去一个个清理垃圾,而是只搬走少数派、把剩下的整片扔掉:
| 维度 | 复制算法的好处 |
|---|---|
| 回收速度 | 时间复杂度只跟「存活对象数量」相关,跟垃圾数量无关。10000 个对象里只活 100 个,那就只搬 100 个 |
| 没有碎片 | 复制时按顺序紧挨着放,自动就是连续内存,新对象分配只要把指针往后挪一格(bump pointer),极快 |
| 实现简单 | "整片清空"比"挨个标记空闲块"快得多 |
代价就是「永远有一半内存闲着」,但新生代本来就只有几 MB,浪费 4MB 换来极致的回收速度,完全值得。
新生代是**「少数派搬家」**——存活的少,那就把少的搬走,剩下的整片清空。
对象晋升(Promotion):什么时候搬到老生代?
不是所有对象都活该被反复在 From/To 之间搬来搬去——能活过一次 GC 的对象,大概率会活很久(典型的就是缓存、单例、长期持有的 DOM 引用)。把它们搬到老生代,不再参与新生代的频繁回收。
满足以下任一条件的对象会被**晋升(Promotion)**到老生代:
- 经历过一次 Scavenge 还活着——已经证明了「不是短命鬼」
- 本次复制后 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,会出大问题:
- 空间浪费扛不住:新生代浪费 4MB 没事,老生代浪费几百 MB 谁也受不了
- 复制成本爆炸:复制算法的耗时正比于「存活对象数」,老生代存活率 90%+,搬一次的代价远超清理一次
- 大对象搬不动:一个几 MB 的 Buffer 反复复制,性能直接崩盘
所以老生代换成 Mark-Sweep(标记清除)+ Mark-Compact(标记整理) 组合。核心思路是「对象别动,原地把垃圾清掉」。
主力算法:Mark-Sweep(标记 + 原地清除)
- 标记:跟新生代一样,从 GC Roots 开始遍历,标记所有可达对象
- 原地清除:把没标记的对象所占的内存块加入「空闲列表(Free List)」,下次分配新对象时从这个列表里找空位
优点:存活对象不动(指针不用更新、CPU 缓存不失效),单次回收快。 缺点:空闲块大小不一,散落在内存各处,时间长了就成了「奶酪状」的内存碎片——明明剩 100MB,却找不到一块连续的 10MB 来放新对象。
兜底算法:Mark-Compact(标记 + 整理碎片)
当碎片严重到分配不出大对象时,V8 切换到 Mark-Compact:
Mark-Sweep 之后(碎片化):
[A][空][C][空空][E][空][G][空空空]
↑ 大对象想申请连续 5 格,找不到
Mark-Compact 整理后:
[A][C][E][G][空空空空空空空空]
↑ 连续 8 格可用,分配丝滑
- 标记:同上
- 整理:把存活对象全部往内存一端挤,更新所有引用它们的指针
- 清除:边界后面整片当空闲
代价:要移动对象、还要修正所有指针指向,比 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
使用步骤
- 打开 Chrome DevTools → Memory 标签
- 选择 Heap snapshot(堆快照)
- 执行可能泄露的操作
- 再次拍摄快照
- 对比两次快照,找出增长的对象
关键指标:
- 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');
}
最佳实践总结
- 及时清理:定时器、事件监听器、DOM 引用
- 使用 WeakMap/WeakSet:存储对象引用(允许自动回收)
- 避免全局变量:尽量使用局部变量、启用严格模式
- 优化闭包:只保留必要的外部变量
- 定期监控:使用性能分析工具检测内存增长
- 生产环境移除日志:避免 console.log 持有大对象
- 组件销毁时清理:React/Vue 组件卸载时清理资源
常见面试问题
Q1: V8 为什么要分代回收?
答案:
基于弱分代假说(Weak Generational Hypothesis):
- 大部分对象生命周期很短(临时变量、函数参数等)
- 少部分对象生命周期很长(全局对象、缓存等)
分代回收的优势:
- 新生代:使用快速的 Scavenge 算法,频繁回收短周期对象
- 老生代:使用完整的标记算法,较少回收长周期对象
- 性能优化:针对不同特征的对象使用不同策略,整体效率更高
对比表格:
| 策略 | 新生代 | 老生代 |
|---|---|---|
| 对象特征 | 生命周期短(90% 以上) | 生命周期长 |
| 回收频率 | 高(毫秒级) | 低(秒级) |
| 算法 | Scavenge(复制) | Mark-Sweep/Compact |
| 单次耗时 | 极短(<10ms) | 较长(100ms+) |
Q2: 什么是 Stop-The-World?V8 如何优化?
答案:
Stop-The-World (STW) 是指垃圾回收时暂停所有 JavaScript 执行,会导致:
- 页面卡顿
- 动画掉帧
- 接口响应延迟
V8 的优化策略:
-
增量标记(Incremental Marking)
- 将标记工作拆分成多个小步骤
- 与 JavaScript 执行交替进行
- 减少单次停顿时间
-
并发标记(Concurrent Marking)
- 在后台线程执行标记
- 主线程继续运行 JavaScript
- 通过写屏障跟踪变化
-
并发清除(Concurrent Sweeping)
- 在后台线程清除未标记对象
- 不阻塞主线程
-
惰性清除(Lazy Sweeping)
- 按需清除内存页
- 分散清除工作
对比表格:
| 技术 | 停顿时间 | 实现复杂度 | V8 版本 |
|---|---|---|---|
| 传统 GC | 100-200ms | 低 | - |
| 增量标记 | 每次 5-10ms | 中 | V8 v4.0+ |
| 并发标记 | <5ms | 高 | V8 v6.6+ |
| 并发清除 | <1ms | 高 | V8 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 不会阻止
对比表格:
| 特性 | Map | WeakMap |
|---|---|---|
| 键类型 | 任意类型 | 只能是对象 |
| 引用类型 | 强引用 | 弱引用(键) |
| 可枚举 | ✅ 可遍历 | ❌ 不可遍历 |
| 垃圾回收 | 手动清理 | 自动回收 |
| 使用场景 | 通用缓存 | 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: 如何排查生产环境的内存泄露?
答案:
排查步骤:
-
监控内存趋势
// 定期上报内存使用情况
setInterval(() => {
const mem = performance.memory;
if (mem) {
reportMetrics({
usedJSHeapSize: mem.usedJSHeapSize,
totalJSHeapSize: mem.totalJSHeapSize,
jsHeapSizeLimit: mem.jsHeapSizeLimit
});
}
}, 60000); // 每分钟上报 -
使用 Chrome DevTools
- 录制 Heap Snapshot(堆快照)
- 对比多个时间点的快照
- 查看 Retainers(持有者)找到泄露源
-
Performance Monitor
- 监控 JS heap size 趋势
- 查看是否持续增长不回落
-
代码审查重点
- 全局变量和缓存
- 事件监听器和定时器清理
- 闭包使用
- 第三方库的生命周期管理
快速诊断技巧:
// 创建一个诊断函数
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'
});
}
}
预防措施:
- 代码规范:强制清理生命周期(ESLint 规则)
- 单元测试:测试组件卸载后内存是否释放
- 自动化监控:CI/CD 集成内存泄露检测工具
- 定期审计: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 是什么?如何用于内存优化?
答案:
WeakRef 和 FinalizationRegistry 是 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 对比:
| 特性 | WeakRef | WeakMap |
|---|---|---|
| 作用 | 弱引用单个对象 | 弱引用作为键的对象 |
| 检查存活 | .deref() 返回对象或 undefined | .has() / .get() |
| 可手动解引用 | 是 | 否(只能通过键访问) |
| 配合 GC 回调 | 搭配 FinalizationRegistry | 无 |
| 典型场景 | 缓存、观察对象生命周期 | DOM 元素关联数据、私有属性 |
- GC 的触发时机是不确定的,
FinalizationRegistry的回调不保证一定执行 - 不要依赖
WeakRef和FinalizationRegistry来实现关键业务逻辑 - 它们主要用于性能优化和资源清理,作为"尽力而为"的辅助手段
deref()在同一个事件循环的同步代码中,对同一个WeakRef会返回一致的结果