理解Node.js中的内存
请思考一个问题:我们的应用到底需要多大的内存?
对于Node.js应用而言,我们需要关心它的内存使用情况。因为它可能在某些时候出现内存不够用,或者内存泄露的问题。
本文介绍如何对Node应用中的内存使用情况进行查看和调优。
V8引擎
Node.js是建立在谷歌的V8 JavaScript引擎上的,内存管理主要由V8 引擎负责。
我们来看一张图:

V8 中包含了 Heap 和 Stack 内存,而 garbage collector(垃圾回收器)会对 Heap(堆内存)进行管理。
堆内存
JavaScript中的 objects, arrays, and functions 等对象会被放入 Heap(堆内存)中。堆内存的大小不是固定的,会根据实际需要由V8引擎动态分配(增加或减少)。一旦超出它的最大可用空间,就会导致“out-of-memory”错误,整个应用也就随之崩溃了。
堆内存里面包括两个部分:
- New Space:当应用创建了一个新对象,会先将这个对象放入到这里。这些对象会被认为很快就会“死亡”,垃圾回收器(GC)会频繁地对这里的内存进行回收,以便
尽快地清理不再使用的“垃圾对象”,让它们归还占有的内存供后续新对象使用。在这里,执行一次回收的代价相对较低,通常可以在1ms内完成。可以使用--max-semi-space-size来指定这个空间的最大容量。 - Old Space:经过几次垃圾回收后,依旧幸存的对象会被放入到这里。这里的对象通常被认为是“寿命较长”的,所以,垃圾回收器不会那么频繁地对这里的内存进行回收。在这里,执行一次回收的代价比较高,所需时间也会更长,通常需要几个ms甚至更长时间。可以使用
--max-old-space-size来指定这个空间的最大容量。
那如何知道当前堆内存容量的上限呢?可以通过如下代码获取:
import v8 from 'node:v8'
// check the current heap size limit
const { heap_size_limit } = v8.getHeapStatistics()
const heapSizeInGB = heap_size_limit / (1024 * 1024 * 1024)
console.log(`${heapSizeInGB} GB`)这个值不是固定的,会根据系统的可用内存来决定它的默认值。在2G物理内存的服务器上,大约是0.8G;如果服务器的物理内存是16G,大约是4G。
栈内存
除了堆内存,V8还使用了栈,用它来存放本地变量和函数调用等信息。不像被垃圾回收器管理的堆内存,栈内存中遵循着后进先出(LIFO)的原则。每当调用一个函数时,都会将一个新的帧压入栈中。当这个函数返回时,它在栈中的那个帧又会被弹出来。栈的大小比堆小很多,但它在内存分配和释放方面会更快。
栈也有容量限制,超出它的容量,会导致“stack overflow”错误。比如进行深度嵌套的函数调用时。
查询内存使用情况
在进行内存调优前,我们需要思考一个问题:如何知道当前Node进程使用的内存大小呢?
process.memoryUsage() 会返回当前你的应用正在使用多少内存:
process.memoryUsage()它会返回一个类似如下的对象:
{
rss: 30154752,
heapTotal: 4825088,
heapUsed: 4029208,
external: 267863,
arrayBuffers: 11851
}各个属性的含义如下:
- rss (Resident Set Size): The total memory allocated to your process, including heap and other areas.
- heapTotal: The total memory allocated for the heap.
- heapUsed: The memory currently in use within the heap.
- external: Memory used by external resources like bindings to C++ libraries.
- arrayBuffers: Memory allocated to various Buffer-like objects.
如果你的应用在上线后,heapUsed一直在增加,而没有减少,则可能表明应用程序中存在内存泄漏的问题。另外,这个 rss 表示的是应用程序实际占用的物理内存大小。如果这个rss接近服务器的物理内存,那就说明你需要加内存了。
Garbage Collector
首先,需要知道的是,当GC进行回收时,你的应用程序运行会被暂停。
由于堆内存包含两个部分(New Space 和 Old Space),因此,垃圾回收器也会有两个行为:
- Scavenge:在New Space中执行垃圾回收的算法叫Scavenge。
- Mark-sweep:用于在Old Space中执行垃圾回收。它包含两个阶段。首先,会标记(Mark)那些依旧需要使用的对象;接着,进行清理(sweep)工作,把那些未被标记的对象进行回收。
为了直观地观察垃圾回收的过程,我们用下面的代码做一个实验:
import os from 'node:os'
let len = 1_000_000 // 100万
const entries = new Set()
function addEntry() {
const entry = {
timestamp: Date.now(),
memory: os.freemem(),
totalMemory: os.totalmem(),
uptime: os.uptime()
}
entries.add(entry)
}
;(() => {
while (len > 0) {
addEntry()
process.stdout.write(`~~> ${len} entries to record\r`)
len--
}
console.log(`Total: ${entries.size} entries`)
})()我们往内存中写入了100万个entry对象。将上面代码保存到gc.mjs文件中,运行:
node --trace-gc gc.mjs在控制台中发现,大部分都是Scavenge类型,只有少量Mark-sweep, 程序运行一切正常。但这个程序中,我们知道会出现内存泄露的问题,因为Set中的对象不断增多,并一直占据在内存中没有被释放。那为什么测试结果中没有出现内存泄露错误呢?
不急,我们再运行一次,这次我们设置 --max-old-space-size=50,再次运行:
node --trace-gc --max-old-space-size=50 gc.mjs在控制台中,我们发现Mark-sweep类型明显变多,并且,最后出现了out of memory 错误:
<--- Last few GCs --->
at[25144:0x7fecf5316000] 4535 ms: Mark-sweep (reduce) 50.3 (51.6) -> 49.2 (51.6) MB, 3.6 / 0.0 ms (+ 2.4 ms in 15 steps since start of marking, biggest step 0.4 ms, walltime since start of marking 39 ms) (average mu = 0.810, current mu = 0.871) allocat
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory这次运行,我们指定了old space 中的大小为50M,一旦超出这个容量,后面需要存放到old space中的entry对象就没地方了,因此报错。
如果不指定,这个容量会根据系统可用内存进行分配,所以第一次运行时,堆内存的容量大约达到了150M,程序也没有提示 out of memory 错误。
那如何修复这个内存泄露的程序呢?可以考虑将这些值存入到文件或者数据库中。