likes
comments
collection
share

Node - 内存篇

作者站长头像
站长
· 阅读数 3

背景

从 2009 年node 诞生至今,涌现出了很多 node 应用与工具,帮助前端拓宽了应用场景、提高了效能。

一方面,node 对于前端工程师非常的友好,由于其基于 v8 打造,v8 本身就是 chrome 中的 javascript 引擎,前端工程师在使用 node 的时候,基本没有什么阻碍,不需要重新去学习关于编程(变量、类型、语法、流程控制等)的基础知识,上手成本相当低。

另一方面,node 由于是服务端编程语言,运行在服务端。由于运行环境的不同,和前端编程相比,里面涉及到的知识点更多,需要编程人员充分去了解,才能保证应用稳定的运行在服务端。

本篇文章属于一个系列,旨在通过对 node 底层原理的解析,帮助大家了解 node 的运行原理。从而在编程中规避一些错误的用法,提升大家 node 应用的稳定性。

首先要介绍的就是内存控制,可能有的同学之前没有怎么接触过,有的同学遇到过类似的情况,比如浏览器 tab 失去了响应,又比如在编译的过程中报出 heap out of memory 的错误。这里面其实都是内存占用满了导致进程退出的情况,那大家有没有考虑过为什么物理内存高达 32gb 的电脑,怎么短短一会儿就 oom 了呢?

Node - 内存篇

v8 的垃圾回收机制

v8 的内存限制

现代编程语言基本不需要开发人员手动来管理内存(这会被当成一种负担),它们中大多都提供了自动的 gc。我们在写 node 的时候,根本不用关心对象的分配和回收,这些都是 v8 帮我们自动完成的。

但是有得就有失,我们在得到便利的同时,也被加上了限制。在 node 中通过 js 使用内存会发现只能使用部分内存,具体来说,默认情况下,v8 在 64 位系统下最多只能使用 1.4GB 的内存,在 32 位系统下最多只能使用 0.7GB 的内存。如果在实际应用中不小心达到了这个限制,就会导致我们的进程退出,出现前文中提到的情况。

  • 这里的限制是指 node v12 以下版本的限制
  • 想查询自己 node 进程堆内存的限制,可以使用**v8.getHeapStatistics()** ****来获取相关信息,截图使用的 node v8.17.0

Node - 内存篇

那么 v8 为什么要设置这个内存限制呢?其实在设计之初,v8 只是作为浏览器端 js 的执行环境,在浏览器端很少遇到处理大内存的情况,所以 v8 的限制值绰绰有余。但这只是表面的原因,更深层次的原因在于垃圾回收的限制。众所周知,v8 是单线程的,gc 的主要工作也是在主线程上完成,如果要维持太多的对象,占用太多的内存,那么当垃圾回收的时候势必会造成卡顿。对于前端这种需要展示动画以及和用户频繁交互的场景来说,卡顿是不能被接受的,基于此,内存限制就不会被放开。

Node - 内存篇

V8 的内存结构

首先让我们来了解下 v8 的内存结构是什么样的,由于 js 是单线程的,v8 也为每一个 js 上下文使用一个进程。一个运行中的 js 应用由 v8 进程分配好一块内存空间,这整个空间被称为 resident size,这个空间又可以划分为不同的区域

暂时无法在飞书文档外展示此内容

我们可以通过process.memoryUsage方法来获取内存信息

Node - 内存篇

这五个属性分别代表不同的含义

rss: 常驻内存大小,是给这个 node 进程分配了多少物理内存,这些物理内存中包含堆,栈和代码片段。

heap total: v8 已经申请到的堆内存

heap used: v8 已经使用的堆内存

external: v8 内部的 C++对象占用的内存

arrayBuffers: 包含在 external 中,指为 ArrayBuffer 和 SharedArrayBuffer 分配的内存

接下来让我们看看不同内存空间的作用

栈内存

v8 是单线程的且只有一个调用栈,里面会存储函数/方法的调用帧以及一些函数中的变量指针。它是一个后进先出的数据结构。在 32 位系统中默认的栈内存为 492KB,在 64 位系统中则是 984KB,在 node 中可以使用--stack-size来调整栈的大小,查看栈内存大小则可以使用如下命令

Node - 内存篇

在 chrome 和 vscode 里调试时我们都能清晰的看见调用栈的结构,如下面的代码执行的时候

function third() { }

function second() { third() }

function first() { second() }

first();

调用栈的情况变化如下

  1. 首先 first 函数执行,将 first 压入栈底

Node - 内存篇

  1. 然后 second 函数在 first 中执行,将 second 压入栈

Node - 内存篇

  1. 紧接着 third 函数在 second 函数中执行,将 third 压入栈

Node - 内存篇

  1. 在 third 执行完成之后,开始执行出栈,third 首先出栈

Node - 内存篇

  1. second 执行完成,second 出栈

Node - 内存篇

  1. first 执行完成,first 出栈

Node - 内存篇

前文提到过,栈是有大小的,并不能够无限嵌套函数执行,无限入栈。如果我们的代码中存在无限嵌套或者函数嵌套过深(递归场景),或者函数中的变量声明过多,就有可能导致程序运行异常,抛出 maximum call stack size exceeded 的错误

Node - 内存篇

我们可以用以下代码来探测 node 可用调用栈的深度

function computeStackDeep() {
    try {
        return computeStackDeep() + 1;
    } catch (e) {
        return 1;
    }
}

console.log(computeStackDeep())

在 node v16.18.1 得到的结果是 12564,如果我们在函数当中声明一些变量,可用栈的深度将会减少,将上面的代码修改为

function computeStackDeep() {
    try {
        const a = 1;
        const b = 3;
        return computeStackDeep() + 1;
    } catch (e) {
        return 1;
    }
}

console.log(computeStackDeep())

则得到的可用栈深度减少为 10470

从我们开发的层面来看,一般调用栈的栈深是有限的,不会出现超过限制的情况。基本出现问题都是无限嵌套调用的情况,所以栈内存这块我们不需要额外关注

堆内存

这是 v8 存储对象引用类型的地方,常规情况是所有内容都存储在堆上。这是最大的内存区域块,也是垃圾回收 ( GC 发生的地方。整个堆内存不被垃圾回收,只有 new space 和 old space 空间被垃圾回收管理。堆进一步分为以下区域:

  • new space:新生代是新对象所在的地方,其中大多数对象都是短暂的。这个空间很小,有两个 semi-space。这个空间由 “Scavenger(Minor GC )” 管理,我们稍后再看。可以使用--min-semi-space-size--max-semi-space-size控制新空间的大小。

  • old space:老生代是在“新空间”中存活了两个次要 GC 周期的对象被移动到的地方。这个空间由 Major GC(Mark-Sweep & Mark-Compact) 管理起来” ,我们稍后再看。可以使用--initial-old-space-size--max_old_space_size控制旧空间的大小。这个空间分为两个:

    • 旧指针空间:包含具有指向其他对象的指针的幸存对象。
    • 旧数据空间:包含仅包含数据的对象(没有指向其他对象的指针)。
  • large object space:这是大于其他空间大小限制的对象所在的地方。大对象永远不会被 gc 移动。

  • code space:这是即时 ( JIT 编译器存储已编译代码块的地方。这是唯一具有可执行内存的空间(尽管Codes可能分配在“大对象空间”中,并且那些也是可执行的)。

  • map space:map space 包含 map 对象,它们不会被 gc 移动

  • read only space:存放不会被改变的对象

我们可以通过v8.getHeapSpaceStatistics方法,看到当前堆空间中各区域内存的使用情况

Node - 内存篇

我们在代码中使用变量赋值的时候,所用对象的内存就分配在堆中。如果空闲内存不够分配给新建的对象,那么就会继续申请堆内存,直到堆大小超过 v8 的限制为止。

v8 的垃圾回收(orinoco)

Node - 内存篇

许多现代编程(v8、java、golang)动态管理运行应用程序的内存,因此开发人员无需自己操心。引擎定期传递分配给应用程序的内存,确定不再需要哪些数据,然后将其清除以释放空间。此过程称为垃圾回收。

这里简单讲下 v8 是如何进行垃圾回收的,虽然这一部分对我们来说是黑匣子,但了解其中的原理能帮助我们更好了解 v8 的内存管理及性能优化

v8 的垃圾回收在过去的时间里一直在为了性能而不断优化改进,本文的回收部分参考的 v8 垃圾回收的相关文档,如果有任何不对的地方,请务必联系我,欢迎大家的指正

分代理论

v8 采用的是分代垃圾回收,根据上文的内存结构可以看到,v8 将堆分为了新生代和老生代,不同的区域执行的 gc 算法也不同。

分代垃圾回收基于分代收集理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾回收过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾回收一致的设计原则:

  1. 垃圾回收应该将堆划分出不同的区域,然后将回收对象依据其存活时长分配到不同的区域之中存储。(分代处理)
  2. 如果一个区域中大多数对象都是朝生夕灭,那么每次回收时只须关注如何保留少量存活的对象,就能以较低代价回收到大量的空间;(处理新生代)
  3. 如果一个区域中大多数对象都是难以消亡的,那么便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾回收的时间开销和内存的空间有效利用。(处理老生代)

在堆划分出不同的区域之后:

  • 垃圾回收才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”这样的回收类型的区分
  • 针对不同的区域使用相匹配的垃圾回收算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾回收算法

Major GC

Node - 内存篇

Major GC 指的是从整个堆上进行垃圾回收,一般分为三个阶段

标记

首先要弄清楚哪些对象可以被回收,垃圾回收通过使用对象是否可达来表达对象的活跃度。这意味着任何对象想存活都必须保留自己的可达性,否则对象将会被垃圾回收无情回收。

标记是找到可达对象的过程。GC 从一组已知对象指针开始,包括执行堆栈和全局对象。然后,它跟踪每个指向 JavaScript 对象的指针,并将该对象标记为可访问。GC 跟踪该对象中的每个指针,并递归地继续此过程,直到找到并标记运行时中可到达的每个对象。

清除

这一步主要是将死亡对象清除,然后将死亡对象留下的内存空间释放。

Node - 内存篇

整理

Major gc 还会整理某些内存页,为了解决标记-清除算法的内存碎片问题,引入了标记-整理,其在工作过程中将活着的对象往一端移动,这时内存空间是紧凑的,移动完成之后,直接清理边界之外的内存。

复制存活对象的垃圾回收的一个潜在弱点是,当我们处理大量长期存在的对象时,会付出高昂的成本来复制这些对象。v8 的 gc 选择只整理一些高度碎片化的内存页,而只对其它内存页进行清除,在这些内存页中不会复制存活对象。

Node - 内存篇

Minor GC

V8 中有两种垃圾回收。Major GC(标记-整理)从整个堆中回收垃圾。Minor GC (Scavenger) 在新生代回收垃圾。

在 Scavenger 中,v8 采用了适合新生代的“半空间”设计,这意味着总空间的一半始终是空的, 存活对象总是被复制移动到新的空间,是典型的以空间换时间的做法。在清理过程中,这个最初是空的区域被称为“to space”。我们复制的区域称为“from space”。在最坏的情况下,每个对象都可以在清理中幸存下来,我们需要复制每个对象。

移动步骤将所有存活对象移动到连续的内存块。这样做的优点是可以完成消除死亡对象留下的内存碎片。然后我们切换两个空间,即“to space”变为“from space”,反之亦然。一旦 GC 完成,新的分配就会发生在 from space 中的下一个空闲地址。

仅靠这一策略,我们很快就会耗尽新生代的空间。在第二次 GC 中幸存下来的对象将被疏散到老生代,而不是 to space,这样就能保证新生代有充足的空间且里面的 GC 能足够快的完成。

Scavenger 最后一步是更新已移动的原始对象的指针。每个复制的对象都会留下一个转发地址,用于更新原始指针以指向新位置。

Node - 内存篇

Node - 内存篇

在 Scavenger 中,实际上执行这三个步骤——标记、移动和指针更新。

对比上面的三种 GC 算法,我们得到

算法标记-清除标记-整理scavenge
速度中等最慢最快
空间开销少(有碎片)少(无碎片)双倍空间(无碎片)
是否移动对象

一些额外工作

衡量垃圾回收所用时间的一项重要指标是执行 GC 时主线程暂停的时间。对于传统的全停顿垃圾回收来说,花在 GC 上的时间会以卡顿页面、糟糕的渲染和延迟的形式直接降低用户体验。

orinoco 是 v8 垃圾回收项目的代号,致力于使用最新并且最好的平行 GC、增量 GC、并行 GC 方案来帮助释放主线程,从而提高用户的使用体验。关于这三个额外的概念,本文就不介绍了,感兴趣的同学可以搜索相关的文档了解下,接下来我们看看当前 v8 正在使用的优化 GC 的一些方案

Minor GC

V8 使用并行清理在新生代 GC 期间工作,运用辅助线程进行 GC 将会进一步缩短新生代 GC 中耗费的时间

并行 Scavenger 已将主线程年轻代垃圾收集总时间减少了约 20%–50%

Node - 内存篇

Major GC

V8 中的主要 GC 从并发标记开始。当堆接内存接近限制时,并发标记任务就会启动。当并发标记完成或达到动态分配限制时,主线程将执行快速标记完成步骤。标记完成后主线程会和辅助线程一起开启整理和清理的工作

并发标记和清除将重型 WebGL 游戏的暂停时间减少了 50%

Node - 内存篇

Idle GC

GC 可以发布空闲任务,v8 为嵌入对象提供了一种触发 GC 的机制,像 chrome 就可以在渲染动画的间隙去运行 GC 创建的空闲任务

空闲时 GC 可以在 Gmail 空闲时减少 45%的 JavaScript 堆内存。

Node - 内存篇

小结

根据对垃圾回收进行分析可以得出,V8 对内存的限制是合理的,如果不对内存进行限制,那么在 major gc 的时候对整个堆进行标记-整理花费的时间将会更多,对页面的渲染造成卡顿。对于 Node 编写的服务端来说,内存限制并不会影响正常场景下的使用。

对于 V8 的垃圾回收特点和 js 在单线程上的执行情况,垃圾回收是影响性能的因素之一。如果想要高性能的执行效率,需要让垃圾回收尽量少的进行。

查看垃圾回收日志

查看垃圾回收日志的方式主要是在启动时添加--trace_gc 参数。在进行垃圾回收时,将会从标准输出中打印垃圾回收的日志信息。

node --trace_gc -e "const arr = [];for (let i = 0; i < 1000000; i++) arr.push(Array.from({length: 100}));" > gc.log

Node - 内存篇

查看垃圾回收占用的耗时则可以使用--prof参数,这将得到 v8 执行时的性能分析数据,我们创建一个 test 文件,这个文件,然后执行它 node --prof test.js

for (let i = 0; i < 1000000; i++) {
    const a = [];
    a.push(Array.from({ length: 100 }))
}

这样会在目录下得到 v8.log 文件,该文件不可读,为了理解这个文件,我们需要使用与 Node.js 二进制文件捆绑在一起的处理器。要运行处理器,请使用以下--prof-process标志:

node --prof-process isolate-0xnnnnnnnnnnnn-v8.log > processed.txt

我们可以得到性能文件其中关于 GC 的部分

Node - 内存篇

由于我们不断的分配对象,运行完这段代码,垃圾回收所占的时间为 6.6%。如果运行完代码的时间为 1000 毫秒,那么垃圾回收耗时 6.6 毫秒。

内存指标

查看进程的内存占用

前面提到过process.memoryUsage()可以参看内存使用情况。

我们可以利用这个方法写一个工具函数来帮我们判断进程的内存占用情况,我们可以创造如下代码,其中 showMemory 用来帮我们展示当前内存情况,for 循环则不断新增内存

const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;

const showMemory = () => {
    const memory = process.memoryUsage();
    console.log(`heap total: ${formatSize(memory.heapTotal)} heapUsed: ${formatSize(memory.heapUsed)} rss: ${formatSize(memory.rss)}`);
    console.log('----------------------------');
}

const total = [];

for (let i = 0; i < 100; i++) {
    showMemory();
    total.push(Array.from({ length: 50 * 1024 * 1024 }).map((_, i) => Math.random(i)))
}

在我本机上执行得到了以下的结果,可以看到堆内存在不断的增长,直到超过了限制导致内存溢出

用 v8.getHeapStatistics()可以看到堆内存的限制

Node - 内存篇

内存泄漏

Node 对内存泄漏十分敏感,一旦线上应用有成千上万的流量,哪怕是一个字节的内存泄漏也会造成堆积,垃圾回收过程中将会耗费更多时间进行对象扫描,应用响应缓慢,直到进程内存溢出,应用崩溃。

在 V8 的垃圾回收机制下,在通常的代码编写中,很少会出现内存泄漏的情况。但是内存泄漏通常产生于无意间,较难排查。尽管内存泄漏的情况不尽相同,但其实质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。

通常,造成内存泄漏的原因有如下几个。

  1. 全局变量

未声明的变量或挂在全局 global 下的变量不会自动回收,将会常驻内存直到进程退出才会被释放,除非通过 delete 或 重新赋值为 undefined/null 解除之间的引用关系,才会被回收

可以使用 --expose-gc选项使得我们能手动进行 gc,试着执行以下代码,观察输出

const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;

const showMemory = () => {
    const memory = process.memoryUsage();
    console.log(`heap total: ${formatSize(memory.heapTotal)} heapUsed: ${formatSize(memory.heapUsed)} rss: ${formatSize(memory.rss)}`);
    console.log('----------------------------');
}

const a = [];
showMemory();
let b = [];
b.push(Array.from({ length: 1000 * 1024 }).map((_, i) => Math.random(i)))
showMemory();
b = undefined;
global.gc();
showMemory();

可以观察到,随着我们解除 b 的引用并且手动调用 gc 之后,堆内存使用明显下降

Node - 内存篇

  1. 闭包

闭包是一个常见的内存泄漏情况,闭包会引用父函数中的变量,如果闭包得不到释放,闭包引用的父级变量也不会释放从而导致内存泄漏。

一个真实的案例 — The Meteor Case-Study,2013 年,Meteor 的创建者宣布了他们遇到的内存泄漏的调查结果。有问题的代码段如下

var theThing = null
var replaceThing = function () {
  var originalThing = theThing
  var unused = function () {
    if (originalThing)
      console.log("hi")
  }
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log('some message')
    }
  };
};
setInterval(replaceThing, 1000)

以上代码运行时每次执行 replaceThing 方法都会生成一个新的对象,但是之前的对象没有释放导致内存泄漏。这块涉及到一个闭包的概念 “同一个作用域生成的闭包对象是被该作用域中所有下一级作用域共同持有的” 因为定义的 unused 使用了作用域的 originalThing 变量,因此 replaceThing 这一级的函数作用域中的闭包(someMethod)对象也持有了 originalThing 变量(重点:someMethod 的闭包作用域和 unused 的作用域是共享的),之间的引用关系就是 theThing 引用了 longStr 和 someMethodsomeMethod 引用了 originalThingoriginalThing 又引用了上次的 theThing,因此形成了链式引用。

  1. 缓存

缓存在应用中的作用举足轻重,可以十分有效地节省资源。因为它的访问效率要比 I/O 的效率高,一旦命中缓存,就可以节省一次 I/O 的时间。

但是在 Node 中,缓存并非物美价廉。一旦一个对象被当做缓存来使用,那就意味着它将会常驻在老生代中。缓存中存储的键越多,长期存活的对象也就越多,这将导致垃圾回收在进行扫描和整理时,对这些对象做无用功。

另一个问题在于,js 开发者通常喜欢用对象的键值对来缓存东西,但这与严格意义上的缓存又有着区别,严格意义的缓存有着完善的过期策略,而普通对象的键值对并没有。

如下代码虽然利用 JavaScript 对象十分容易创建一个缓存对象,但是受垃圾回收机制的影响,

只能小量使用:

const cache = {};  
const getCache = (key) => {  
 if (cache[key]) {  
     return cache[key];  
 } else {  
     // get from otherwise  
 }  
};  
conste setCache = (key, value) => {  
 cache[key] = value;  
};  

cache 对象会随着缓存增加而持续增长,以上代码还有一个问题,当你启动多个进程或部署在多台机器会造成每个进程都会保存一份,显然是资源的浪费,还需要考虑缓存的一致性,这种情况最好是通过 Redis 做共享。

当然,如果是轻量缓存场景,那么我们可以考虑使用 LRU 算法来规避内存增长的问题,LRU 只会保存最近使用最多的对象,当达到缓存数量限制的时候,会将最不常用的对象移除,以下是截取的第三方库关于 LRU 的实现,最核心的逻辑就在于缓存数量达到限制时,调用 evict 方法移除对象

LRU.prototype.set = function (key, value) {
  if (typeof key !== 'string') key = '' + key

  var element

  if (this.cache.hasOwnProperty(key)) {
    element = this.cache[key]
    element.value = value
    if (this.maxAge) element.modified = Date.now()

    // If it's already the head, there's nothing more to do:
    if (key === this.head) return value
    this._unlink(key, element.prev, element.next)
  } else {
    element = {value: value, modified: 0, next: null, prev: null}
    if (this.maxAge) element.modified = Date.now()
    this.cache[key] = element

    // Eviction is only possible if the key didn't already exist:
    if (this.length === this.max) this.evict()
  }

  this.length++
  element.next = null
  element.prev = this.head

  if (this.head) this.cache[this.head].next = key
  this.head = key

  if (!this.tail) this.tail = key
  return value
}
  1. 事件监听

    • 事件监听的回调函数会一直保持引用, 继而使用回调函数所使用的闭包数据不能得到释放,尝试运行以下代码
    const { EventEmitter } = require('events');
    
    const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;
    
    const showMemory = () => {
        const memory = process.memoryUsage();
        console.log(`heap total: ${formatSize(memory.heapTotal)} heapUsed: ${formatSize(memory.heapUsed)} rss: ${formatSize(memory.rss)}`);
        console.log('----------------------------');
    }
    
    const event = new EventEmitter();
    
    for (let i = 0; i < 1000; i++) {
        const a = [];
        event.on('event', () => {
            a.push(Array.from({ length: 10 * 1024 * 1024 }).map((_, i) => i))
        });
    
        showMemory();
        event.emit('event')
    }
    
    • 事件重复监听

      在 Node.js 中对一个事件重复监听则会报如下错误,实际上使用的 EventEmitter 类,该类包含一个 listeners 数组,默认为 10 个监听器超出这个数则会报警如下所示,用于发现内存泄漏,也可以通过 emitter.setMaxListeners() 方法为指定的 EventEmitter 实例修改限制。

    (node:23992) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 connect listeners added. Use emitter.setMaxListeners() to increase limit
    

      之前 Node.js HTTP 模块 Keep-Alive 因为事件重复监听产生的内存泄漏,参考 Github Node Issues #714

  1. 队列消费不及时

在解决了缓存带来的内存泄漏问题后,另一个不经意产生的内存泄漏则是队列。

队列在消费者-生产者模式中经常充当中间产物。这是一个容易忽略的情况,因为在大多数应用场景下,消费的速度远远大于生产的速度,内存泄漏不易产生。但是一旦消费速度低于生产速度,将会形成堆积。

举个实际的例子,有的应用会收集日志。如果欠缺考虑,也许会采用数据库来记录日志。日志通常会是海量的,数据库构建在文件系统之上,写入效率远远低于文件直接写入,于是会形成数据库写入操作的堆积,而 js 中相关的作用域也不会得到释放,内存占用不会回落,从而出现内存泄漏。

如何避免内存泄漏

了解了内存泄漏的 case 之后,我们在编程的过程中较难时时刻刻注意着这些风险,或者在 review 的时候能时时刻刻提防内存泄漏。但是只要我们知道内存泄漏的原理以及可能存在的内存泄漏的 case,那么我们就可以借助工具来帮助我们规避内存泄漏,这里最常见的工具就是使用 linter 来完成代码的检查拦截。

举个例子,针对已经全局对象不释放导致内存泄漏的 case,我们可以借助已有的 eslint rule 来规避,利用如下规则,达到禁止全局变量声明的效果

{
    rules: {
        // 禁止全局变量声明,也禁用了缓存,禁止全局变量内存泄漏漏洞
        "no-implicit-globals": 'warn',
    }
}

关于闭包、事件监听以及其它泄漏的 case,目前只能通过自定义 eslint rule 的方式来实现,这里列举一个针对读文件的提示,如果使用 fs.readFileSync 会将文件内容放到内存中,也有可能导致内存泄漏,这里我们可以在这个方法后面添加 warn 提示

'use strict';
/**
 * @type {import('eslint').Rule.RuleModule}
 */
const { hasParent, isAsyncSafeFn } = require('../utils/hasParent');

module.exports = {
  meta: {
    type: 'suggestion', // `problem`, `suggestion`, or `layout`
    docs: {
      description: 'promise-test',
      category: 'Fill me in',
      recommended: false,
      url: null, // URL to the documentation page for this rule
    },
  },

  create(context) {
    return {
      Identifier: node => {
        if (node.name === 'readFileSync') {
          context.report({
            node,
            message: '请注意内存泄漏问题',
          });
        }
      },
    };
  },
};

如何排查内存泄漏

上面列举了常见的一些内存泄漏的 case,那么当我们线上的应用真的遇到了内存泄漏之后我们该如何去排查呢?

v8 自带工具

我们可以通过 Node.js 内置的 dump 方式,即 --heap* 的启动 flag 来执行 dump。比如:

  --heap-prof                    Start the V8 heap profiler on start up, and
                                 write the heap profile to disk before exit.
                                 If --heap-prof-dir is not specified, write
                                 the profile to the current working
                                 directory.
  --heap-prof-dir=...            Directory where the V8 heap profiles
                                 generated by --heap-prof will be placed.
  --heap-prof-interval=...       specified sampling interval in bytes for the
                                 V8 heap profile generated with --heap-prof.
                                 (default: 512 * 1024)
  --heap-prof-name=...           specified file name of the V8 CPU profile
                                 generated with --heap-prof
  --heapsnapshot-signal=...      Generate heap snapshot on specified signal
  --heapsnapshot-near-heap-limit=...
                              Generate heap snapshots whenever V8 is
                              approaching the heap limit. No more
                              than the specified number of heap

这种 V8 提供的工具,基本都是通过某种方式触发 dump 文件的生成,然后回到 Chrome DevTools 上去分析。基本步骤:

  1. 通过--heapsnapshot-signal flag 或者 --heapsnapshot-near-heap-limit 来开启功能。如 node --heapsnapshot-signal=SIGINT test.js。
const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;

const showMemory = () => {
    const memory = process.memoryUsage();
    console.log(`heap total: ${formatSize(memory.heapTotal)} heapUsed: ${formatSize(memory.heapUsed)} rss: ${formatSize(memory.rss)}`);
    console.log('----------------------------');
}

process.on('SIGINT', () => {
    process.exit();
})

const a = [];

setInterval(() => {
    a.push(Array.from({ length: 10 * 1024 * 1024 }).map((_, i) => i))

    showMemory();
}, 1000)
  1. 在程序运行过后,检查当前的工作目录 (通常是你启动程序的目录)。基于你的设置(如 interval、或者设置了信号然后出发了信号)就可以 dump 出一个 heap 文件,默认来说文件名类似 Heap*. heapsnapshot
  2. 打开 Chrome DevTools → 找到 Memory tab
  3. 在最下面,点击 Load → 选择 Heap*. heapsnapshot
  4. 现在我们就可以看到内存里面的情况:

例如下方 case:我们可以通过检视内存中特别大的对象来排查分析,是哪些可能存在的原因/逻辑导致了内存 crash。

其中 Shadow Size 表示对象本身的内存大小,Retained Size 表示对象释放后可以释放的内存大小,所以通常会先按照 Retainer Size 从大到小排序,然后找到内存占比比较高的项进行分析。

然后看 Distance,Distance 代表当前对象到根对象的最短距离,如果 Distance 一致很有可能表示它们来自同一处代码

Node - 内存篇

三方库

如果觉得 Node.js 内置的 dump 方式不太友好,可以找一找市面上的第三方库。比如 heapdump 这个 npm。

const { EventEmitter } = require('events');
const heapdump = require('heapdump');

const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(2)} MB`;

const showMemory = () => {
    const memory = process.memoryUsage();
    console.log(`heap total: ${formatSize(memory.heapTotal)} heapUsed: ${formatSize(memory.heapUsed)} rss: ${formatSize(memory.rss)}`);
    console.log('----------------------------');
}

process.on('SIGINT', () => {
    process.exit();
})

const event = new EventEmitter();

setInterval(() => {
    const a = [];
    event.on('event', () => {
        a.push(Array.from({ length: 10 * 1024 * 1024 }).map((_, i) => i))
    });
    heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot');
    showMemory();
    event.emit('event')
}, 1000)