V8垃圾回收详解

40 阅读7分钟
  1. Node用的V8引擎有内存上限

    • 64位系统默认老生代最大约1.4GB(新生代几十MB)。
    • 超过这个限就会抛 OOM 错误,进程崩溃。
    • 可以用 --max-old-space-size=4096 把上限调到4GB(甚至更高),但不是无限。
  2. 为什么有上限?

    • V8最初是为浏览器设计的,一个网页吃太多内存会卡死整个Chrome。
    • Node继承了这个限制(虽然可以用参数调大)。
  3. V8怎么回收垃圾(GC)?

    • 分成新生代(小而快)和老生代(大而慢)。
    • 新生代:新对象先放这里,大多数对象很快死掉,用“复制”算法快速清理(几毫秒)。
    • 老生代:活得久的对象晋升过来,用“标记-清除/整理”算法清理(可能几十到几百毫秒,会短暂卡顿JS)。
  4. 实际影响

    • Node很适合I/O密集(等网络、读文件),因为内存占用稳定。
    • 不适合内存密集(一次性加载几GB数据、巨型数组、图片批量处理),容易爆内存或频繁GC卡顿。

平时你可能没注意过,是因为大多数Web/API服务内存都在几百MB以内晃悠,很安全。但一旦做爬虫、日志处理、大文件上传下载、WebSocket推送大数据等,就容易踩坑。

V8 垃圾回收的整体策略:分代回收

V8 把所有对象分成两代:

  1. 新生代(Young Generation)

    • 空间很小(64位系统默认 32MB 左右,分成两半各16MB)。
    • 大多数对象出生在这里,也很快死在这里(“朝生夕死”)。
    • 回收算法:Scavenge(复制算法),速度极快,通常几毫秒。
  2. 老生代(Old Generation)

    • 空间大(默认 ~1.4GB)。
    • 放活得久的对象(从新生代晋升过来,或直接大对象)。
    • 回收算法:标记-清除(Mark-Sweep) + 标记-整理(Mark-Compact)

下面我们重点讲新生代(最常用、最快),因为 90% 的对象都在这里回收。

新生代 Scavenge 算法详细过程(举个栗子)

新生代空间分成两个等大的半区:From 区(当前使用)和 To 区(空闲)。

我们用一个简单例子来说明整个回收过程。

function createPerson(name) {
  let person = {          // 新对象分配在 From 区
    name: name,
    age: 18
  };
  return person;
}

let p1 = createPerson('小明');  // p1 在 From 区
let p2 = createPerson('小红');  // p2 在 From 区

p1 = null;                      // p1 不再被引用,变成垃圾
// p2 还活着

// 触发新生代 GC(假设现在内存满了)

GC 发生时,Scavenge 算法一步步这样做:

  1. 标记存活对象
    从根(全局对象、当前栈上的变量)开始遍历,能到达的就是存活的。
    在这个例子中:

    • 根能到达 p2 → p2 存活
    • p1 没人引用 → p1 是垃圾
  2. 复制存活对象到 To 区
    把 p2 从 From 区复制到 To 区(顺序摆放,避免碎片)。

  3. 翻转 From 和 To 角色

    • 清空原来的 From 区(里面的垃圾直接丢弃)。
    • 把 To 区变成新的 From 区,原来的 From 区变成新的 To 区(空闲)。

结果:

  • 垃圾 p1 被直接回收(From 区清空)。
  • 存活的 p2 现在在新 From 区,继续使用。

整个过程只需要复制少量存活对象(通常 <10%),速度极快!

如果对象活得久会怎样?

  • 每次 Scavenge 存活的对象会有一个“年龄”计数。
  • 活过几次(默认2次)后,会晋升到老生代。
  • 老生代用标记-清除(快速回收但产生碎片)+ 偶尔标记-整理(消除碎片但慢)。

再来一个更直观的例子(你可以用 Node 跑)

let arr = [];  // 全局变量,根对象

function foo() {
  let temp1 = new Array(1000).fill('temp');  // 大临时数组,只在函数内用
  let temp2 = { data: '临时对象' };
  
  arr.push({ keep: '我活得久' });  // 这个对象被全局 arr 引用,会存活
}

foo();  // 执行一次

// temp1 和 temp2 在函数结束时没人引用,成为垃圾
// 下次新生代 GC 时,它们会被 Scavenge 直接回收
// arr 中的对象存活,会被复制到 To 区,继续活下去

运行后,你几乎感觉不到 GC,因为新生代回收太快了。

为什么这么设计好?

  • 新生代小 + 复制算法 → GC 暂停时间极短(几ms),几乎不卡顿。
  • 老生代大 + 标记-清除 → 回收效率高,适合长寿对象。

下面是几张经典图,帮助你永久记住这个过程:

  1. 新生代 From/To 区结构
  2. Scavenge 复制过程(一步步动画式)
  3. 对象晋升到老生代
  4. 老生代标记-清除与整理

“翻转 From 和 To 角色”这一步(也叫 Semi-Space Flip角色翻转)是 V8 新生代 Scavenge 垃圾回收算法 的核心设计,它的作用主要有三个:

  1. 快速回收垃圾对象

    • 在回收前,From 区是“活跃区”(放当前所有新对象),To 区是空闲的。
    • GC 时,只把 From 区里存活的对象复制到 To 区。
    • 翻转后,原来的 From 区变成空闲(里面的垃圾直接被“丢弃”,无需一个个标记删除),直接整块清空回收。
    • 这比“标记-清除”算法快得多,因为不需要遍历整个空间清理碎片。
  2. 自动消除内存碎片

    • 复制存活对象到 To 区时,是顺序紧凑摆放的(没有空洞)。
    • 翻转后,新活跃区(原来的 To 区)内存是连续的,避免了碎片化问题(标记-清除算法会留下很多小空洞,导致后续分配慢)。
  3. 准备下一次 GC

    • 翻转后,To 区变成新的 From 区(活跃),原来的 From 区变成新的 To 区(空闲)。
    • 新对象继续分配到新的 From 区,下次 GC 时重复这个过程——永远只需一个空闲区作为“目标”。

简单说:这一步让新生代 GC 既(只需复制少量存活对象 + 整块清空垃圾),又干净(无碎片),暂停时间极短(几毫秒),适合“朝生夕死”的短命对象。

Mark-Sweep 是干什么的?

Mark-Sweep(标记-清除)是 V8 老生代(Old Generation)的主要垃圾回收算法。它分为两个阶段:

  1. Mark(标记阶段)

    • 从根对象(全局变量、当前执行栈等)开始遍历所有可达对象。
    • 把可达(存活)的对象标记为“活着”。
    • 未被标记的就是垃圾。
  2. Sweep(清除阶段)

    • 遍历整个老生代堆,把未标记的对象回收(释放内存)。
    • 标记清除后,内存空间被回收,但会留下碎片(空洞)。

优点:简单高效,适合长寿对象(存活对象多,复制成本高)。
缺点:会产生内存碎片(后续分配大对象可能失败,即使总空闲够)。
为了解决碎片,V8 还会偶尔结合 Mark-Compact(标记-整理):把存活对象往一端移动,消除碎片(但更慢)。

如果第一次创建的对象很大,超过新生代内存大小,会发生什么?

不会直接报错,而是直接分配到老生代(Old Generation)。

原因:

  • 新生代空间很小(默认32MB左右,分成两个半区)。
  • V8 有优化:如果对象大小超过新生代半区(Semi-Space)的一定比例(通常几KB到几十KB以上,具体阈值取决于版本),会判断它不适合新生代的快速复制算法。
  • 直接在老生代分配(Large Object Space 或直接老生代),从一开始就用 Mark-Sweep 处理。

这样设计的好处:

  • 避免大对象在新生代频繁复制(复制算法成本与存活对象大小成正比,大对象复制慢)。
  • 大对象通常长寿,直接放老生代更合适。

示例代码(创建一个大数组):

let bigArray = new Array(10 * 1024 * 1024).fill('big');  // 约80MB大数组
// 这个对象直接分配到老生代,不会进新生代

现在这两个点清楚了吗?
Mark-Sweep 是老生代的“主力清道夫”,大对象直接“跳级”到老生代,避免新生代负担。