做 Node 开发的兄弟,下面这个报错十有八九都碰到过:
text复制<--- Last few GCs --->
[10308:0000022A0A80B4A0] 11024 ms: Mark-sweep 1393.1 -> 1393.1 (1441.2) MB, 402.1 / 0.0 ms (average mu = 0.127, current mu = 0.000) allocation failure
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
一个本来运行得好好的 Node.js 程序,一旦数据量上来,就直接进程崩溃。很多人第一反应是“代码有 bug”“内存泄漏了”,然后在业务代码里翻半天,结果啥也没揪出来。其实这类问题有相当一部分不是业务逻辑的锅,而是 V8 引擎给 JavaScript 堆空间画了一条默认的上限,你的进程数据量已经碰线了。
换句话说,Node.js 不是不能用大内存,而是默认没让你用那么多。这篇文章就把 V8 内存管理、Nodejs 环境下的堆大小限制、以及怎么正确调整 V8 堆大小这件事讲透,顺便把我实际排查中踩过的坑也一并列出来。
1. 先搞清楚 Node.js 的“内存限制”到底限在哪
1.1 V8 的堆并不是一块完整的大空地
很多人以为 Node.js 能用的内存就是操作系统物理内存,其实不是。Node.js 跑 JavaScript 的引擎是 V8,V8 在启动时会给 JavaScript 对象分配一个“堆”,这个堆并不是把所有内存都包进来,而是被拆分成了若干个带不同用途的空间。
简单用一个生活例子来理解:你把 V8 的堆想象成一个老式仓库,仓库里不是一个大通铺,而是分了几个隔间:
- 新生代(new space):新创建的小对象先进这里,空间小、回收频率高,里面的对象绝大多数活不过几轮垃圾回收。
- 老生代(old space):熬过新生代多次回收的对象会被晋升到这里,生命周期长,是堆里最主要的大块头。
- 大对象空间(large object space):超过一定体积的大数组、大字符串、大 Buffer 会直接从这里的独立区域分配,避免在大空间里搬来搬去。
- 代码空间、Map 空间等:专给 JIT 编译后的机器码、隐藏类之类的元数据用的。
当 V8 需要清理垃圾时,就得去做垃圾回收。老生代的回收通常采用“标记-清除”和“标记-整理”,这个过程有一个特点:回收期间 JavaScript 线程会暂停,也就是业界常说的 Stop The World。堆越大,标记和清除涉及的对象越多,单次停顿时间就越长。V8 之所以默认不把堆的上限直接设成“物理内存有多大就吃多大”,一个重要原因就是既要保证程序有足够空间,又要控制 GC 停顿不至于让线上服务出现肉眼可见的卡顿。
1.2 默认上限到底是多少
V8 的默认堆上限和 Node.js 运行时的位数强相关:
| 运行环境 | 默认老生代堆上限 | 大致总堆上限 |
|---|---|---|
| 64 位 Node.js | 约 1.4 GB | 约 1.5 ~ 2 GB 之间 |
| 32 位 Node.js | 约 0.7 GB | 约 1 GB 以下 |
这里的核心限制来自老生代。平时我们说“调大内存”,本质上就是调大老生代的上限。为什么 32 位平台默认值会低那么多?因为 32 位进程的虚拟地址空间总共就 4GB 左右,留给单个进程的内存天然有限,V8 不可能像在 64 位系统上那样放开手脚。
如果你用的是当下主流 64 位 Node.js,默认堆上限就是 1.4GB 左右。这意味着什么?假如你写了一段脚本,把 2GB 的文件往内存里一读,再转成对象数组做聚合分析,大概率会在某个瞬间直接撞上 V8 堆上限,进程“啪”一下没了。
1.3 记住:Node.js 的开销不只有 V8 堆
这是新手最容易忽略的一点:Node.js 进程占用的总内存远远不等于 V8 堆里的内存。一个 Node 进程的内存大头通常包含三块:
- V8 堆里的 JavaScript 对象、数组、闭包变量等。
- Buffer 和底层网络模块使用的原生内存,这部分属于堆外内存,不受 V8 堆限制管。
- 模块加载、JIT
