跳到主要内容

垃圾回收机制

在 JavaScript 中,我们创建变量、对象、数组等数据时,引擎会自动为其分配内存。但当这些数据不再被需要时,如果不及时释放占用的内存,程序就会越来越"臃肿",最终可能导致页面卡顿甚至崩溃——这就是 内存泄漏

为了解决这个问题,JavaScript 引擎内置了 垃圾回收(Garbage Collection, GC) 机制,它会自动追踪内存的使用情况,找出那些"不再被使用"的数据,并回收它们所占的内存空间。理解垃圾回收的工作原理,不仅能帮助我们写出性能更好的代码,还能在遇到内存泄漏问题时快速定位和修复。

本文将从内存的生命周期出发,深入讲解主流的垃圾回收算法,重点剖析 V8 引擎(Chrome / Node.js 使用的 JS 引擎)的分代回收策略,并结合实际场景分析常见的内存泄漏问题和排查手段。


内存的生命周期

无论使用哪种编程语言,内存的生命周期都遵循相同的三个阶段:

阶段说明JavaScript 中的体现
分配为变量、对象等申请内存空间let obj = { name: 'Tom' } 时自动分配
使用读写已分配的内存console.log(obj.name) 读取,obj.age = 18 写入
释放不再需要时归还内存由垃圾回收器 自动完成,开发者无需手动释放

在 C/C++ 中,开发者需要手动调用 malloc/free 来管理内存。而 JavaScript 采用 自动垃圾回收,引擎会在后台默默帮你完成"释放"这一步。

栈内存与堆内存

JavaScript 中的数据存储在两个地方:

  • 栈内存:大小固定,由操作系统自动管理,函数执行完毕后自动回收(出栈即释放)。
  • 堆内存:大小不固定,生命周期不确定,需要垃圾回收器来判断何时释放。

因此,垃圾回收主要针对的是堆内存中的数据


垃圾回收的核心思想

垃圾回收要解决的核心问题是:如何判断一块内存"不再被需要"?

这个问题在计算机科学中是不可判定的(无法用算法精确解决),因此垃圾回收器采用的是 近似算法——通过"可达性"来判断。

什么是"可达性"

如果一个对象能通过某条引用链从 根对象(Root) 访问到,那它就是"可达的",不应该被回收;反之就是"不可达的",可以被安全回收。

上图中,对象 A、B、C、D 从根对象可达,不会被回收。对象 E 和 F 虽然互相引用,但从根对象不可达,所以会被回收。

根对象通常包括:

  • 全局对象(浏览器中的 window,Node.js 中的 global
  • 当前正在执行的函数的局部变量和参数
  • 调用栈上所有函数的局部变量
  • setTimeoutsetInterval 等注册的回调引用的对象

经典垃圾回收算法

1. 引用计数(Reference Counting)

核心思想:为每个对象维护一个"被引用次数"的计数器。每当有新引用指向该对象时,计数 +1;引用被移除时,计数 -1。当计数变为 0 时,说明没有任何引用指向它,可以立即回收。

// 引用计数的工作过程示意
let obj = { name: 'Tom' }; // { name: 'Tom' } 引用计数 = 1
let copy = obj; // 引用计数 = 2
obj = null; // 引用计数 = 1(obj 不再指向它)
copy = null; // 引用计数 = 0 → 可以回收!

优点

  • 可以立即回收垃圾(计数为 0 时马上释放)
  • 不需要暂停程序执行

致命缺陷——循环引用

function createCycle() {
let a = {};
let b = {};
a.ref = b; // a 引用 b
b.ref = a; // b 引用 a
// 函数执行完毕,a 和 b 离开作用域
// 但它们互相引用,引用计数都不为 0,永远无法回收!
}

createCycle(); // 内存泄漏!

早期的 IE6/IE7 中,DOM 对象使用的就是引用计数机制,因此在 JS 与 DOM 之间产生循环引用时会导致内存泄漏。这是那个年代前端开发的一个经典"坑"。

2. 标记-清除(Mark-Sweep)

标记-清除 是现代 JavaScript 引擎采用的基础算法,也是目前最主流的方案。

核心思想:从根对象出发,递归遍历所有可达对象并打上"标记"。遍历完成后,没有被标记的对象就是垃圾,直接清除释放内存。

工作流程

  1. 标记阶段:从根对象开始,深度优先遍历所有引用链,将可达对象标记为"存活"
  2. 清除阶段:遍历整个堆内存,回收所有未标记的对象

优点

  • 解决了引用计数的循环引用问题(互相引用但从根不可达的对象会被正确回收)
  • 实现相对简单

缺点

  • 内存碎片化:被清除的对象散落在内存各处,留下大小不一的空闲块,可能导致后续分配大对象时找不到连续空间
  • 全停顿(Stop-The-World):GC 执行时需要暂停 JS 主线程,可能导致页面卡顿

3. 标记-整理(Mark-Compact)

核心思想:在标记-清除的基础上增加"整理"步骤——将所有存活对象移动到内存的一端,然后直接清理边界以外的内存。

优点

  • 消除了内存碎片化问题
  • 大对象分配更高效

缺点

  • 需要移动对象并更新所有引用指针,开销比标记-清除更大
  • 执行时间更长

V8 引擎的垃圾回收策略

Chrome 和 Node.js 使用的 V8 引擎采用了 分代回收(Generational Collection) 策略,这是目前最高效的垃圾回收方案。

分代假说

分代回收基于一个经验性的统计规律——分代假说(Generational Hypothesis)

大多数对象的生命周期很短,只有少数对象会存活很长时间。

例如:函数中的临时变量、循环中的迭代对象等,通常用完就丢。而全局配置、缓存数据、组件实例等则可能贯穿整个应用生命周期。

基于此,V8 将堆内存划分为两个区域,针对不同"年龄"的对象采用不同的回收策略:

新生代回收:Scavenge 算法(副垃圾回收器)

新生代使用的是 Scavenge 算法,核心是一种叫做 Cheney 算法 的半空间复制策略。

工作原理

新生代内存被等分为两个半空间:

  • From 空间(活动空间):当前正在使用的空间,新对象分配在这里
  • To 空间(空闲空间):空闲备用空间

详细步骤

  1. 新对象被分配到 From 空间
  2. 当 From 空间快满时,触发 Scavenge GC
  3. 从根对象开始,遍历并标记 From 空间中所有可达对象
  4. 将存活对象 复制 到 To 空间(复制过程中对象会被紧凑排列,无碎片)
  5. 清空 From 空间(非存活对象直接被丢弃)
  6. 交换 From 和 To 的角色,继续分配新对象

晋升条件

并非所有新生代对象都会一直留在新生代。满足以下任一条件的对象会被 晋升(Promotion) 到老生代:

  1. 经历过一次 Scavenge 回收仍然存活——说明它不是"朝生夕灭"的短命对象
  2. To 空间的使用率超过 25%——为了保证后续分配有足够空间
// 典型的新生代对象(短命)
function processData(data) {
const temp = data.map(item => item * 2); // temp 是临时对象
const result = temp.reduce((a, b) => a + b, 0);
return result;
// temp 在函数返回后就不可达了,下次 Scavenge 就会被回收
}

// 典型的会晋升到老生代的对象(长寿)
const cache = new Map(); // 全局缓存,持续存活
function getData(key) {
if (!cache.has(key)) {
cache.set(key, fetchFromServer(key));
}
return cache.get(key);
}

Scavenge 的优缺点

优点缺点
速度非常快(只处理少量存活对象)牺牲了一半的内存空间
复制过程天然解决碎片问题不适合存活对象比例高的场景
非常适合"多数对象短命"的新生代内存利用率只有 50%

老生代回收:Mark-Sweep + Mark-Compact(主垃圾回收器)

老生代中的对象存活率高、数量大,使用 Scavenge 的复制策略效率太低(复制大量存活对象开销很大)。因此,老生代采用 标记-清除标记-整理 的组合策略。

策略选择

  • 常规情况 使用标记-清除(速度快),允许少量碎片存在
  • 碎片过多时 使用标记-整理(慢但彻底),消除碎片以保证大对象分配

全停顿问题与优化策略

垃圾回收执行时,需要暂停 JavaScript 主线程(因为 GC 过程中对象的引用关系不能改变)。这种暂停被称为 全停顿(Stop-The-World)

对于新生代,由于空间小、对象少,停顿时间很短(通常 < 1ms),用户几乎感知不到。但老生代的 GC 可能涉及数百 MB 的数据遍历,如果一次性完成可能导致明显卡顿(几十甚至上百毫秒)。

为此,V8 引入了多种优化策略:

1. 增量标记(Incremental Marking)

将一次长时间的标记任务拆分为多个小步骤,穿插在 JavaScript 执行之间,避免长时间停顿。

增量标记使用三色标记法来记录标记进度:

颜色含义
白色尚未被访问,GC 结束后仍为白色的对象会被回收
灰色已被访问,但其引用的子对象尚未全部处理
黑色已被访问,且其所有引用的子对象都已处理完毕

三色标记法保证了即使标记过程被中断,也能从上次停下的位置(灰色对象)继续,无需重新开始。

2. 并发回收(Concurrent GC)

GC 的部分工作(如标记、清除)交由 辅助线程 在后台执行,不阻塞主线程。这是 V8 目前最重要的优化手段。

3. 并行回收(Parallel GC)

在 GC 暂停期间,主线程和多个辅助线程 同时 进行标记/清除工作,缩短暂停时间。

4. 惰性清理(Lazy Sweeping)

标记完成后,不立即清除所有垃圾对象,而是按需逐步清理——只在需要分配新内存时才清理一部分,减少单次停顿时间。

V8 GC 全景总结


常见的内存泄漏场景

虽然 JavaScript 有自动垃圾回收,但如果代码中无意间保持了不必要的引用,GC 就无法回收这些对象,导致 内存泄漏。以下是最常见的几种场景:

1. 意外的全局变量

// ❌ 错误:忘记使用 let/const/var 声明
function handler() {
leakedData = new Array(1000000); // 变成了 window.leakedData,永远不会被回收!
}

// ✅ 正确:使用严格模式 + 显式声明
'use strict';
function handler() {
const data = new Array(1000000); // 函数结束后即可被回收
}

启用 'use strict' 严格模式后,未声明的变量赋值会直接报错,从源头杜绝这类问题。

2. 被遗忘的定时器

// ❌ 错误:组件销毁后定时器仍在运行
function startPolling() {
setInterval(() => {
const data = fetchData(); // data 和闭包中的引用都无法被回收
updateUI(data);
}, 1000);
}

// ✅ 正确:保存定时器 ID,在适当时机清除
function startPolling() {
const timerId = setInterval(() => {
const data = fetchData();
updateUI(data);
}, 1000);

// 在不需要时清除
return () => clearInterval(timerId);
}

React 组件中的典型写法:

function PollingComponent() {
useEffect(() => {
const timerId = setInterval(() => {
console.log('polling...');
}, 1000);

// ✅ 清理函数:组件卸载时清除定时器
return () => clearInterval(timerId);
}, []);

return <div>Polling...</div>;
}

3. 被遗忘的事件监听器

// ❌ 错误:添加了事件监听但从不移除
function setupListener() {
const element = document.getElementById('btn');
const handler = () => {
// 这个闭包会引用外部作用域中的变量,导致它们也无法被回收
console.log('clicked');
};
element.addEventListener('click', handler);
// element 被移除 DOM 后,如果 handler 仍持有引用,element 和 handler 都无法被回收
}

// ✅ 正确:在适当时机移除事件监听
function setupListener() {
const element = document.getElementById('btn');
const handler = () => console.log('clicked');
element.addEventListener('click', handler);

// 在不需要时移除
return () => element.removeEventListener('click', handler);
}

4. 闭包引用

// ❌ 错误:闭包意外持有大对象
function createHandler() {
const hugeData = new Array(1000000).fill('*'); // 100 万个元素

return function handler() {
// 即使 handler 只用了 hugeData.length,
// 整个 hugeData 数组都不会被回收,因为闭包持有对它的引用
console.log(hugeData.length);
};
}

const fn = createHandler(); // hugeData 被闭包持有,无法回收

// ✅ 正确:只保存需要的数据
function createHandler() {
const hugeData = new Array(1000000).fill('*');
const length = hugeData.length; // 只提取需要的值

return function handler() {
console.log(length); // hugeData 可以被正常回收
};
}

5. 脱离 DOM 的引用

// ❌ 错误:JS 中仍引用着已从 DOM 树移除的元素
const elements = {
button: document.getElementById('myButton'),
image: document.getElementById('myImage'),
};

function removeButton() {
document.body.removeChild(elements.button);
// 虽然按钮从 DOM 中移除了,但 elements.button 仍引用着它
// 这个 DOM 节点和它的所有子节点都无法被 GC 回收
}

// ✅ 正确:移除 DOM 后同时解除 JS 引用
function removeButton() {
document.body.removeChild(elements.button);
elements.button = null; // 解除引用,让 GC 可以回收
}

6. 未清理的 Map / Set 缓存

// ❌ 错误:缓存无限增长
const cache = new Map();

function processUser(user) {
if (!cache.has(user.id)) {
cache.set(user.id, expensiveCompute(user));
}
return cache.get(user.id);
}
// 随着 user 越来越多,cache 永远不会缩小

// ✅ 方案一:使用 WeakMap(key 被回收时自动清除对应条目)
const cache = new WeakMap();

function processUser(user) {
if (!cache.has(user)) {
cache.set(user, expensiveCompute(user)); // user 对象被回收时,条目自动消失
}
return cache.get(user);
}

// ✅ 方案二:实现 LRU 缓存,限制缓存大小
class LRUCache {
constructor(maxSize = 100) {
this.maxSize = maxSize;
this.cache = new Map();
}

get(key) {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value); // 移到末尾(最近使用)
return value;
}

set(key, value) {
if (this.cache.has(key)) this.cache.delete(key);
this.cache.set(key, value);
if (this.cache.size > this.maxSize) {
// 删除最老的条目(Map 迭代顺序即插入顺序)
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
}
}

WeakRef 与 FinalizationRegistry

ES2021 引入了两个与垃圾回收密切相关的新特性,让开发者在特定场景下能更精细地与 GC 交互。

WeakRef:弱引用

WeakRef 创建对一个对象的"弱引用"——这种引用 不会阻止 垃圾回收器回收该对象。

let target = { name: 'important data' };
const weakRef = new WeakRef(target);

// 通过 .deref() 获取原始对象
console.log(weakRef.deref()); // { name: 'important data' }

// 当 target 没有其他强引用时,GC 可以回收它
target = null;

// 之后某个时刻(GC 发生后)
console.log(weakRef.deref()); // undefined(对象已被回收)

使用场景:缓存系统——允许 GC 在内存不足时自动清除缓存。

class WeakCache {
#cache = new Map();

get(key) {
const ref = this.#cache.get(key);
if (!ref) return undefined;

const value = ref.deref();
if (!value) {
this.#cache.delete(key); // 对象已被 GC 回收,清理无效条目
return undefined;
}
return value;
}

set(key, value) {
this.#cache.set(key, new WeakRef(value));
}
}

FinalizationRegistry:垃圾回收回调

FinalizationRegistry 允许你注册一个回调,在某个对象 被垃圾回收后 执行。

const registry = new FinalizationRegistry((heldValue) => {
console.log(`对象被回收了,关联值: ${heldValue}`);
// 可以在这里执行清理工作,如关闭文件句柄、取消网络请求等
});

let obj = { data: 'some data' };
registry.register(obj, 'my-object-id'); // 注册 obj,关联值为 'my-object-id'

obj = null; // 移除强引用,GC 回收后会触发回调
// 某个时刻控制台输出:对象被回收了,关联值: my-object-id

⚠️ 注意WeakRefFinalizationRegistry 的回调时机是不确定的(取决于 GC 何时运行),因此不能依赖它们来执行关键的业务逻辑。它们主要用于优化和辅助性的资源清理。


如何检测和排查内存泄漏

使用 Chrome DevTools

Chrome DevTools 提供了强大的内存分析工具:

1. Performance Monitor(性能监视器)

快速判断是否存在内存泄漏:

  1. 打开 DevTools → 按 Ctrl+Shift+P(Mac: Cmd+Shift+P
  2. 输入 "Performance Monitor" 并选择
  3. 观察 JS Heap Size 指标,如果随着操作不断增长且不下降,很可能存在内存泄漏

2. Memory 面板 - 堆快照(Heap Snapshot)

精确定位泄漏对象:

  1. 打开 DevTools → Memory 面板
  2. 选择 Heap snapshot → 点击 Take snapshot
  3. 执行一系列操作(如打开/关闭弹窗、切换路由)
  4. 再次拍摄快照
  5. 选择第二个快照,将视图切换为 Comparison,对比两次快照的差异
  6. 关注 Delta 列(增量)中数值为正的对象类型

3. Memory 面板 - 分配时间线(Allocation Timeline)

实时观察内存分配情况:

  1. 选择 Allocation instrumentation on timeline
  2. 开始录制 → 执行操作 → 停止录制
  3. 蓝色柱状条表示分配了但仍存活的内存,灰色表示已被回收
  4. 如果蓝色柱状条只增不减,说明存在泄漏

代码层面的检测

// 使用 performance.memory API(Chrome 非标准 API)
function checkMemory() {
if (performance.memory) {
console.log('已使用堆大小:', (performance.memory.usedJSHeapSize / 1048576).toFixed(2), 'MB');
console.log('堆大小限制:', (performance.memory.jsHeapSizeLimit / 1048576).toFixed(2), 'MB');
}
}

// 定期监控内存变化
setInterval(checkMemory, 5000);
// Node.js 中使用 process.memoryUsage()
function checkNodeMemory() {
const usage = process.memoryUsage();
console.log({
rss: `${(usage.rss / 1048576).toFixed(2)} MB`, // 常驻内存
heapTotal: `${(usage.heapTotal / 1048576).toFixed(2)} MB`, // 堆总大小
heapUsed: `${(usage.heapUsed / 1048576).toFixed(2)} MB`, // 堆已使用
external: `${(usage.external / 1048576).toFixed(2)} MB`, // C++ 对象内存
});
}

编写 GC 友好代码的实践建议

1. 及时解除引用

function process() {
let bigData = loadHugeDataset();
const result = analyze(bigData);
bigData = null; // ✅ 不再需要时主动解除引用,帮助 GC 尽早回收
return result;
}

2. 善用 WeakMap 和 WeakSet

当需要为对象关联额外数据,且不想阻止对象被回收时:

// ✅ 使用 WeakMap 存储 DOM 元素的关联数据
const elementData = new WeakMap();

function bindData(element, data) {
elementData.set(element, data);
// 当 element 从 DOM 中移除并且没有其他引用时,
// WeakMap 中对应的条目也会被自动清理
}

3. 避免在热路径中创建不必要的对象

// ❌ 每次调用都创建新对象
function getConfig() {
return { theme: 'dark', lang: 'zh' }; // 每次都会在堆上分配新对象
}

// ✅ 复用不变的对象
const CONFIG = Object.freeze({ theme: 'dark', lang: 'zh' });
function getConfig() {
return CONFIG; // 始终返回同一个对象,无需分配新内存
}

4. 使用对象池复用对象

在高频创建/销毁对象的场景(如游戏、动画)中,对象池可以减轻 GC 压力:

class ObjectPool {
#pool = [];
#factory;

constructor(factory, initialSize = 10) {
this.#factory = factory;
for (let i = 0; i < initialSize; i++) {
this.#pool.push(factory());
}
}

acquire() {
return this.#pool.length > 0 ? this.#pool.pop() : this.#factory();
}

release(obj) {
this.#pool.push(obj); // 归还而非销毁
}
}

// 使用示例:粒子系统
const particlePool = new ObjectPool(() => ({ x: 0, y: 0, vx: 0, vy: 0, life: 0 }), 200);

function emitParticle() {
const p = particlePool.acquire(); // 从池中获取
p.x = Math.random() * 800;
p.y = Math.random() * 600;
p.life = 100;
return p;
}

function recycleParticle(p) {
p.x = p.y = p.vx = p.vy = p.life = 0; // 重置状态
particlePool.release(p); // 归还到池中
}

5. 注意闭包陷阱

// ❌ 不必要的闭包导致变量无法释放
function setup() {
const hugeArray = new Array(1e6);
const name = 'test';

// 这个闭包只用了 name,但 V8 可能保留整个闭包作用域(包含 hugeArray)
return () => console.log(name);
}

// ✅ 将大数据放在闭包作用域之外,或提取需要的值
function setup() {
const name = extractName(); // 只保存需要的数据
return () => console.log(name);
}

面试高频问答

Q1:JavaScript 的垃圾回收机制是什么?说说你的理解。

:JavaScript 采用 自动垃圾回收机制,开发者不需要手动分配和释放内存。引擎会在后台自动追踪内存使用情况,找出不再被引用(不可达)的对象并回收其占用的内存。现代 JS 引擎(如 V8)采用 分代回收 策略——将堆内存分为新生代和老生代。新生代使用 Scavenge(半空间复制)算法,适合频繁创建和销毁的短命对象;老生代使用标记-清除和标记-整理的组合算法,适合存活时间长的对象。同时配合增量标记、并发回收等优化策略来减少 GC 停顿对页面流畅度的影响。

Q2:引用计数和标记-清除有什么区别?为什么现代引擎不用引用计数?

引用计数为每个对象维护引用数,引用数为 0 时立即回收;标记-清除从根对象遍历,标记所有可达对象,未被标记的统一回收。引用计数的致命缺陷是无法处理循环引用——两个对象互相引用时即使已经不可达,引用计数也不为 0,导致永远无法回收。标记-清除从根对象出发判断可达性,能正确回收循环引用的对象。因此现代引擎都以标记-清除为基础算法。

Q3:V8 引擎的新生代和老生代分别使用什么回收算法?为什么要这样设计?

新生代使用 Scavenge 算法(Cheney 半空间复制),将内存分为 From/To 两个半空间,GC 时将存活对象从 From 复制到 To 然后交换。老生代使用标记-清除 + 标记-整理的组合。设计理由基于分代假说——大多数对象生命周期很短。新生代对象少、存活率低,复制少量存活对象非常快;老生代对象多、存活率高,如果用复制算法会复制大量对象效率很低,因此改用标记-清除,必要时才用标记-整理消除碎片。

Q4:什么是增量标记?三色标记法是怎么工作的?

增量标记是 V8 为减少 GC 停顿时间的优化策略,将一次完整的标记任务拆分成多个小步骤,穿插在 JS 代码执行之间。使用三色标记法来记录标记进度:白色表示未访问(GC 结束后仍为白色的对象会被回收),灰色表示已访问但子引用未处理完,黑色表示已访问且子引用全部处理完。每次增量步骤从灰色对象开始处理,处理完变为黑色,其子对象变为灰色。这样即使标记被中断,下次也能从灰色对象继续,不必从头开始。

Q5:说说你知道的常见内存泄漏场景,以及如何避免?

:常见场景包括:①意外全局变量——未用 let/const 声明的变量成为全局属性,应启用严格模式并始终显式声明;②遗忘的定时器和回调——组件销毁后 setInterval/setTimeout 仍在运行,应保存 ID 并在清理时 clearInterval;③闭包持有大对象——闭包中只用了一个小值,但整个大对象无法被回收,应只提取需要的值;④脱离 DOM 的引用——DOM 元素从页面移除后 JS 变量仍引用着它,应同时将引用置为 null;⑤无限增长的 Map/Set 缓存——应使用 WeakMap 或实现 LRU 缓存策略。

Q6:如何排查页面中的内存泄漏?

:主要使用 Chrome DevTools 的 Memory 面板:①先用 Performance Monitor 观察 JS Heap Size 是否持续增长不下降;②用 Heap Snapshot(堆快照) 对比操作前后的内存差异,通过 Comparison 视图找到 Delta 值为正的对象类型;③用 Allocation Timeline(分配时间线) 实时观察内存分配,如果蓝色柱状条(未回收的分配)持续增长说明存在泄漏。定位到泄漏对象后,通过 Retainers(保持者)面板查看是谁引用了它,从而找到泄漏的根因。

Q7:WeakMap、WeakSet 和普通 Map、Set 有什么区别?与垃圾回收有什么关系?

:核心区别在于引用类型:普通 Map/Set 对键(或值)持有强引用,只要 Map/Set 存在,其中的对象就不会被 GC 回收;而 WeakMap/WeakSet 对键持有弱引用,不会阻止 GC 回收。当 WeakMap 的键对象在外部没有其他引用时,GC 可以回收该对象,对应的条目也会自动从 WeakMap 中消失。因此 WeakMap/WeakSet 不可枚举、没有 size 属性、不支持遍历。它们非常适合用来为对象关联附加数据(如 DOM 元素的元数据、组件的私有数据),而不会造成内存泄漏。

Q8:什么是 WeakRef?它和 WeakMap 有什么区别?

WeakRef 是 ES2021 引入的 API,用于创建对对象的弱引用。与 WeakMap 的区别在于用途不同:WeakMap 是一种数据结构,用键值对的方式存储关联数据,键必须是对象;WeakRef 则是直接对某个对象创建弱引用,通过 .deref() 方法获取原始对象(如果已被回收则返回 undefined)。WeakRef 常与 FinalizationRegistry 配合使用,后者可以注册对象被回收后的回调。WeakRef 的典型场景是可被 GC 自动清理的缓存系统。但需要注意 GC 时机不确定,不应依赖 WeakRef 实现关键业务逻辑。

Q9:为什么全停顿(Stop-The-World)会影响页面性能?V8 做了哪些优化?

:JavaScript 是单线程运行的,GC 执行时必须暂停 JS 主线程(否则 GC 过程中对象引用关系可能被改变)。如果 GC 耗时过长(比如老生代标记数百 MB 数据),就会造成明显的页面卡顿,用户可感知到动画掉帧或操作无响应。V8 的优化策略包括:①增量标记——将标记拆分为小步骤穿插执行;②并发回收——标记和清除工作在辅助线程后台执行,不阻塞主线程;③并行回收——暂停期间多个线程同时工作以缩短暂停时间;④惰性清理——标记完成后不立即全部清理,而是按需逐步清除。这些策略的组合使 V8 的 GC 停顿时间通常控制在几毫秒以内。