-
Node用的V8引擎有内存上限
- 64位系统默认老生代最大约1.4GB(新生代几十MB)。
- 超过这个限就会抛 OOM 错误,进程崩溃。
- 可以用
--max-old-space-size=4096把上限调到4GB(甚至更高),但不是无限。
-
为什么有上限?
- V8最初是为浏览器设计的,一个网页吃太多内存会卡死整个Chrome。
- Node继承了这个限制(虽然可以用参数调大)。
-
V8怎么回收垃圾(GC)?
- 分成新生代(小而快)和老生代(大而慢)。
- 新生代:新对象先放这里,大多数对象很快死掉,用“复制”算法快速清理(几毫秒)。
- 老生代:活得久的对象晋升过来,用“标记-清除/整理”算法清理(可能几十到几百毫秒,会短暂卡顿JS)。
-
实际影响
- Node很适合I/O密集(等网络、读文件),因为内存占用稳定。
- 不适合内存密集(一次性加载几GB数据、巨型数组、图片批量处理),容易爆内存或频繁GC卡顿。
平时你可能没注意过,是因为大多数Web/API服务内存都在几百MB以内晃悠,很安全。但一旦做爬虫、日志处理、大文件上传下载、WebSocket推送大数据等,就容易踩坑。
V8 垃圾回收的整体策略:分代回收
V8 把所有对象分成两代:
-
新生代(Young Generation)
- 空间很小(64位系统默认 32MB 左右,分成两半各16MB)。
- 大多数对象出生在这里,也很快死在这里(“朝生夕死”)。
- 回收算法:Scavenge(复制算法),速度极快,通常几毫秒。
-
老生代(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 算法一步步这样做:
-
标记存活对象
从根(全局对象、当前栈上的变量)开始遍历,能到达的就是存活的。
在这个例子中:- 根能到达 p2 → p2 存活
- p1 没人引用 → p1 是垃圾
-
复制存活对象到 To 区
把 p2 从 From 区复制到 To 区(顺序摆放,避免碎片)。 -
翻转 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),几乎不卡顿。
- 老生代大 + 标记-清除 → 回收效率高,适合长寿对象。
下面是几张经典图,帮助你永久记住这个过程:
- 新生代 From/To 区结构
- Scavenge 复制过程(一步步动画式)
- 对象晋升到老生代
- 老生代标记-清除与整理
“翻转 From 和 To 角色”这一步(也叫 Semi-Space Flip 或 角色翻转)是 V8 新生代 Scavenge 垃圾回收算法 的核心设计,它的作用主要有三个:
-
快速回收垃圾对象
- 在回收前,From 区是“活跃区”(放当前所有新对象),To 区是空闲的。
- GC 时,只把 From 区里存活的对象复制到 To 区。
- 翻转后,原来的 From 区变成空闲(里面的垃圾直接被“丢弃”,无需一个个标记删除),直接整块清空回收。
- 这比“标记-清除”算法快得多,因为不需要遍历整个空间清理碎片。
-
自动消除内存碎片
- 复制存活对象到 To 区时,是顺序紧凑摆放的(没有空洞)。
- 翻转后,新活跃区(原来的 To 区)内存是连续的,避免了碎片化问题(标记-清除算法会留下很多小空洞,导致后续分配慢)。
-
准备下一次 GC
- 翻转后,To 区变成新的 From 区(活跃),原来的 From 区变成新的 To 区(空闲)。
- 新对象继续分配到新的 From 区,下次 GC 时重复这个过程——永远只需一个空闲区作为“目标”。
简单说:这一步让新生代 GC 既快(只需复制少量存活对象 + 整块清空垃圾),又干净(无碎片),暂停时间极短(几毫秒),适合“朝生夕死”的短命对象。
Mark-Sweep 是干什么的?
Mark-Sweep(标记-清除)是 V8 老生代(Old Generation)的主要垃圾回收算法。它分为两个阶段:
-
Mark(标记阶段)
- 从根对象(全局变量、当前执行栈等)开始遍历所有可达对象。
- 把可达(存活)的对象标记为“活着”。
- 未被标记的就是垃圾。
-
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 是老生代的“主力清道夫”,大对象直接“跳级”到老生代,避免新生代负担。