~/ ?.log $
返回文章列表
10 min read
更新于 2026年3月4日

我不知道的 V8(17)— 垃圾回收:Scavenge、Mark-Sweep-Compact 与增量优化

JavaScript 的内存管理是自动的——代码里没有 malloc 和 free。但"自动"不意味着"没有代价"。V8 的垃圾回收(GC)在清理内存时会占用主线程,产生停顿。一次严重的 GC 停顿可以让页面卡顿 100ms 以上。理解 V8 的 GC 策略,不只是知识储备,直…

JavaScript 的内存管理是自动的——代码里没有 mallocfree。但”自动”不意味着”没有代价”。V8 的垃圾回收(GC)在清理内存时会占用主线程,产生停顿。一次严重的 GC 停顿可以让页面卡顿 100ms 以上。理解 V8 的 GC 策略,不只是知识储备,直接影响写出来的代码在内存上的行为。

一、两代分治:新生代与老生代

V8 把堆内存分成两个区域,针对不同的对象生命周期采用不同的回收策略:

新生代(Young Generation):

  • 大小:1-8MB(视 V8 版本和平台而定)
  • 特征:存放刚创建的对象,大多数对象在这里就”死”了(变成不可达)
  • 策略:Scavenge 算法(快速复制)

老生代(Old Generation):

  • 大小:通常几十 MB 到几百 MB
  • 特征:存放”长寿”对象——从新生代晋升过来的对象,或直接分配的大对象
  • 策略:Mark-Sweep + Mark-Compact(标记清除 + 标记整理)

这种分代思路基于一个统计规律:大多数对象生命周期很短(函数执行完毕后,局部变量就可以回收)。对短命对象用快速算法,对长寿对象用彻底算法,整体效率最高。

二、新生代回收:Scavenge(复制算法)

Scavenge 的核心思路是将新生代空间一分为二(From 和 To),存活对象从 From 复制到 To,然后翻转两个空间的角色:

初始状态:
  From 空间: [A, B, C, D, E](A、C、E 是存活对象,B、D 已死亡)
  To 空间:   [空]

Scavenge 执行:
  1. 从根对象开始扫描,标记存活对象
  2. 把存活的 A、C、E 复制到 To 空间(连续排列,消除碎片)
  3. From 空间整体清空
  4. 翻转:原 To 变 From,原 From 变新的 To

结果:
  From 空间: [A, C, E](连续排列)
  To 空间:   [空](等待下次使用)

晋升机制:存活对象不能永远待在新生代。晋升条件:

  • 对象经历了两次(或更多次)Scavenge 仍然存活
  • 或者 To 空间的使用率超过 25%(为了避免 To 空间很快被填满)
// 这个对象很快就会被 GC
function processRequest() {
  const temp = { id: Math.random() }; // 函数返回后 temp 不可达,新生代 GC
  return processData(temp);
}

// 这个对象会晋升到老生代
const cache = {}; // 全局变量,长期存活 → 晋升到老生代

三、老生代回收:Mark-Sweep-Compact

老生代不适合 Scavenge——老生代大(几十 MB),复制开销太大。V8 使用两种互补的算法:

标记-清除(Mark-Sweep)

标记阶段: 从根对象(全局变量、调用栈里的引用)出发,深度优先遍历所有可达对象,在 V8 维护的位图(Bitmap)里标记它们为存活。

清除阶段: 遍历老生代空间,释放没有被标记的对象占用的内存,标记为”空闲”。

标记-清除的问题:清除后内存不连续,产生碎片。如果后续需要分配一块较大的连续内存,可能找不到足够大的连续空闲空间,即使总空闲内存够用。

标记-整理(Mark-Compact)

标记阶段同上,但清除时多做一步:把存活对象移动到内存的一端,使其连续排列,然后统一释放另一端的内存。

标记-清除后(有碎片):
  [A, 空, C, 空, E, 空, 空]

标记-整理后(无碎片):
  [A, C, E, 空, 空, 空, 空]

标记-整理消除了碎片,但移动对象需要更新所有指向它们的引用(Relocation),开销比标记-清除大。V8 不会每次都触发标记-整理,只在碎片过多、内存紧张时才触发。

四、增量标记:消除长停顿

传统的全量 GC(一次完成所有标记和清除)会造成”Stop-the-World”——主线程暂停,页面卡顿。在老生代大的情况下,这个停顿可能长达几百毫秒。

V8 的**增量标记(Incremental Marking)**把标记过程分成小片段执行:

传统全量标记(一次完成):
  主线程: [JS执行] [========= GC标记 =========] [JS执行]
                     ↑ 停顿 100ms+

增量标记(分片执行):
  主线程: [JS执行] [GC 5ms] [JS执行] [GC 5ms] [JS执行] [GC 5ms] [JS执行]
                    ↑                   ↑                  ↑
                 每片最多 5ms,几乎感知不到停顿

增量标记的挑战:JS 在标记间隙里可能修改对象引用(新建对象、改变引用关系),导致之前的标记结果过时。

**写屏障(Write Barrier)**解决这个问题:每次修改对象引用时,V8 会额外记录这次修改(Dijkstra Write Barrier),确保新建或修改后的引用不会被漏标。

五、并行 GC:利用多核 CPU

增量标记把 GC 分散到主线程的空隙里,但仍然占用主线程时间。V8 还支持并行 GC——用工作线程(Worker Threads)分担 GC 工作:

  • ParallelScavenge:新生代 GC 使用多个工作线程并行扫描和复制
  • ParallelSweep:老生代清除阶段使用多线程并行处理不同内存页

在多核 CPU 上,这可以显著缩短 GC 的墙钟时间(wall-clock time)。

六、空闲时 GC

V8 还有延迟 GC(Idle-time GC):当主线程空闲时(如用户没有输入,也没有 JS 任务要执行),利用这个空闲时间做增量 GC,把 GC 压力分摊到不影响用户体验的时段。

Chrome 的 scheduler 会向 V8 传递”空闲时间”信号,V8 据此安排增量 GC 的执行。

七、写出 GC 友好的代码

减少短命的大对象

// Bad Case:每次调用都创建大数组
function processData(items) {
  const sorted = [...items]; // 创建副本,处理完就丢弃
  sorted.sort((a, b) => a - b);
  return sorted[0];
}

// Better:如果原数组可以修改,直接排序
function processData(items) {
  items.sort((a, b) => a - b);
  return items[0];
}

// 或者:对象池模式(复用大对象)
const pool = [];
function getBuffer() {
  return pool.pop() || new Array(1000);
}
function releaseBuffer(buf) {
  pool.push(buf);
}

避免意外的老生代引用

// 全局数组不断增长 → 对象被晋升到老生代,且永不回收
const globalLog = [];
function logEvent(event) {
  globalLog.push(event); // 内存只增不减
}

// 修复:限制大小或使用 WeakRef
const MAX_LOG = 1000;
const globalLog = [];
function logEvent(event) {
  globalLog.push(event);
  if (globalLog.length > MAX_LOG) {
    globalLog.shift(); // 超出限制就移除最旧的
  }
}

用 WeakMap/WeakRef 持有临时引用

// WeakMap 的键是弱引用,键对象无其他引用时,GC 可以回收它
const cache = new WeakMap();

function getComputedValue(obj) {
  if (cache.has(obj)) return cache.get(obj);
  const value = computeExpensive(obj);
  cache.set(obj, value); // 当 obj 被 GC,cache 里的这条记录也自动清除
  return value;
}

诊断工具

# 查看 GC 事件和停顿时间
node --trace-gc your-script.js

# 查看堆内存统计
node --expose-gc -e "gc(); console.log(process.memoryUsage())"

Chrome DevTools 的 Memory 面板提供堆快照(Heap Snapshot)和分配时间线(Allocation Timeline),用于分析内存泄漏和大对象的来源。

八、总结

V8 的 GC 用分代策略应对 JavaScript 对象的生命周期特征:新生代用 Scavenge(快速复制)处理短命对象,老生代用 Mark-Sweep-Compact 处理长寿对象。

三个关键优化消除了全量 GC 的停顿问题:

  • 增量标记:把标记分成小时间片,穿插在 JS 执行之间
  • 并行 GC:多线程分担扫描和清除工作
  • 空闲时 GC:利用主线程空闲时间消化 GC 压力

写出 GC 友好的代码,核心是:不创建不必要的短命大对象、不让长寿引用意外持有大量临时数据、需要临时关联数据时用 WeakMap/WeakRef


本系列其他文章:

share.ts

// 觉得这篇文章有帮助?

// 欢迎分享给更多人

export const  subscribe  =  "/rss.xml" ;