Node真的不需要关注内存吗?

1,017 阅读14分钟

前言

Node 是一个构建在 Chrome 的 JavaScript 运行时上的平台,在 JavaScript 执行上直接受益于 V8 引擎,可以随着 V8 的升级就能享受到更好的性能或新的语言特性(如ES6)等,但与此同时也受到 V8 的限制,例如内存限制。

先回答标题的问题,答案是需要的,不然等生产环境出现内存泄漏的时候就准备删库跑路吧嘻嘻

阅读完本文你将学习到:

  1. 什么是内存泄漏
  2. 为什么 Node 需要关注内存
  3. Node 内存与垃圾回收策略
  4. 查看 Node 内存指标的手段
  5. 如何防止内存泄漏
  6. 如何定位内存泄漏

为什么 Node 需要关注内存

前端开发者通常是不需要关注内存的,因为 JavaScript 引擎为我们处理好了一切。并且在浏览器端一个 Tab 一个 JavaScript 引擎的场景下,基本不会出现内存问题。

内存泄露是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

Node 对内存泄露十分敏感,因为一旦我们线上有成千上万的大流量,即使是一个字节的内存泄露也会造成堆积,垃圾回收过程中会耗费很多时间进行对象扫描,导致我们的应用响应缓慢,直到进程内存溢出,整个应用崩溃。

总而言之 Node 运行在大流量的场景之下,一点点内存泄漏都会积少成多,最后导致应用缓慢甚至应用崩溃。

  那么内存泄漏为什么会导致应用缓慢甚至应用崩溃?就需要我们深入了解 Node 内存与垃圾回收策略。

Node 内存与垃圾回收策略

内存策略

Node 主要由通过 V8 进行分配的部分 堆内 和 Node 自行分配的部分 堆外 构成,在 V8 中所有的 JavaScript 对象都是通过堆来分配的。

为了提高垃圾回收的效率,V8 将堆分为新生代和老生代两个部分,其中新生代为存活时间较短的对象(需要经常进行垃圾回收),而老生代为存活时间较长的对象(垃圾回收的频率较低)。

内存分代.jpg

Node 中通过 V8 进行分配的内存是有限制的,因为最初 V8 是设计给浏览器端用的,浏览器端几乎不会遇到大内存的情况,而 V8 进行垃圾回收时又会导致 JavaScript 逻辑暂停,以 1.5GB 的垃圾回收对内存为例,V8 做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至需要1秒以上。浏览器端遇不到大内存场景,V8 索性就限制了内存分配的大小。

Node 面对的是数据提供,逻辑和 I/O,可能会遇到大内存的场景,比如将一个2GB的文件读入内存中进行字符串分析处理,由于有上述的内存限制,即使物理内存有32GB,V8 也无法充分使用。不过我们可以在启动 Node 时传入参数来调整内存限制的大小。

node --max-old-space-size=2000 test.js  // 单位为MB
node --max-new-space-size=2048 test.js  // 单位为KB

值得一提的是 Buffer 是操作二进制数据的对象,不论是字符串还是图片,底层都是二进制数据,因此 Buffer 可以适用于任何类型的文件操作。Buffer 对象本身属于普通对象,保存在堆,由 V8 管理,但是其储存的数据,则是保存在堆外内存,是由 C++ 申请分配的,因此不受 V8 管理,也不需要被 V8 垃圾回收,一定程度上节省了 V8 资源,也不必在意堆内存限制。

垃圾回收策略

基于对象存活时间的区别,Node 内存分为新生代内存和老生代内存,再针对新老内存的特性使用不同的垃圾回收策略,以达到最高的回收效率。

1. 新生代垃圾回收策略

新生代内存特点: 大部分对象存活时间短 ,主要采用通过 Scvenge 算法进行垃圾回收,而 Scavenge 算法的具体实现中,主要采用了 Cheney 算法:

一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为 semispace。在这两个 semispace 空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的 semispace 空间称为 From 空间,处于闲置状态的空间称为 To 空间。当我们分配对象时,先是在 From 空间中进行分配。当开始进行垃圾回收时,会检查 From 空间中的存活对象,这些存活对象将被复制到 To 空间中,而非存活对象占用的空间将会被释放。完成复制后,From 空间和 To 空间的角色发生对换。简而言之就是 将存活对象在两个空间之间复制

新生代内存 (3).jpg 缺点:只能使用一半空间

优点:由于新生代内存大部分都是生命周期比较短的对象,所以需要复制的对象不多,回收效率高。

2. 老生代垃圾回收策略

老生代内存特点: 大部分对象存活时间长 ,主要采用 Mark-Sweep 和 Mark-Compact 相结合的方式进行垃圾回收

  1. Mark-Sweep算法,即标记清除。它分为标记和清除两个阶段,在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。由下图可以看到 Mark-Sweep 存在一个问题就是在进行一次标记清除回收后,内存空间会出现不连续的状态,而如果此时又需要分配一个大对象,就会触发垃圾回收,而这次垃圾回收其实是不必要的。

标记清除.jpg

  1. Mark-Compact,即标记整理。他出现的目的就是解决 Mark-Sweep 导致内存不连续的问题。它和 Mark-Sweep 的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

标记整理 (2).jpg

3. 新生代晋升到老生代内存策略

新生代晋升到老生代主要考虑如下两个条件:

  1. 对象是否经历过 Scavenge 回收。对象从 From 空间中复制到 To 空间时,会检查它的内存地址来判断这个对象是否已经经历过一次 Scavenge 回收,如果已经经历过了,则将该对象从 From 空间中复制到老生代空间中。
  2. To 空间的内存占比超过25%限制。当对象从 From 空间复制到 To 空间时,如果 To 空间已经使用超过 25%,则这个对象直接复制到老生代中。这么做的原因在于这次 Scavenge 回收完成后,这个 To 空间会变成 From 空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

4. 增量标记策略

为了降低全堆垃圾回收带来的停顿时间,V8 先从标记阶段入手,将原本要一口气停顿完成的动作改成增量标记(Incremental Marking),也就是拆分为许多小“步进”,每做完一“步进”就让 JavaScript 应用逻辑执行一小会儿,垃圾回收和应用逻辑交替执行直到标记阶段完成。

增量标记允许堆的标记发生在几次5-10毫秒(移动设备)的小停顿中。增量标记在堆的大小达到一定的阈值时启用,启用之后每当一定量的内存分配后,脚本的执行就会停顿并进行一次增量标记。就像普通的标记一样,增量标记也是一个深度优先搜索,并同样采用白灰黑机制来分类对象。

查看Node内存指标的手段

主要依赖于 Node 给我们提供的3个 Api 接口。

  1. process.memoryUsage()

image.png rss 是 resident set size 的缩写,即进程的常驻内存部分。进程的内存总共有几部分,一部分是 rss,其余部分在交换区(swap)或者文件系统(filesystem)中。
heapTotal 是 V8 堆中总共申请的内存量。
heapUsed 表示 V8 堆中使用中的内存量。
external 指的是绑定到 V8 管理的 JavaScript 对象的 C++ 对象的内存使用量。 arrayBuffers 是指为 ArrayBuffer 和 SharedArrayBuffer 分配的内存。

  1. os.totalmem() 以整数形式返回系统内存总量(以字节为单位)。

image.png 3. os.freemem()以整数形式返回空闲的系统内存量(以字节为单位)。

image.png

如何防止内存泄漏

在内存策略部分已经讲过,Node 中 V8 使用的内存是有限制的,如果内存中对象不断增多并且得不到释放,那最后就会导致内存被占满,进而导致进程缓慢甚至崩溃,这时候开发同学也就跟着崩溃了。

为了防止开发同学崩溃,我们必须注意以下 Node 常见的内存泄漏场景,防患于未然。

  1. 缓存使用不当,请看如下代码
const cahe = {};
const get = (key) => {
    if(cache[key]) {
      return cache[key];
    } else {
      // do something
    }
}
const set = (key, value) => {
  cache[key] = value;
} 

// 当任何一个请求进来,我们都会执行这样一个缓存逻辑
cache.set(someKey, someValue);

这段代码在浏览器通常不会有什么问题,因为每个 Tab 都有独立的 V8 线程,但是在 Node 服务端,是所有用户都共享这个 Node 服务内存的,如果每个请求进来都缓存某些数据到 cahe 这个对象,cahe 就会不断变大,最终导致内存泄漏。
解决思路很简单:给这个缓存对象增加一个最大值,比如说 cache 对象最多保存 200 个 key 的数据,当要保存第 201 个 key 时就要使用策略去删除一些已有的缓存,比如 LRU 或者 FIFO。
当然也可以使用进程外缓存,在避免内存泄漏的同时,也能解决不同线程缓存共享的问题,比如使用 Redis 作缓存。

  1. 队列消费不及时在JavaScript中可以通过队列(数组对象)来完成许多特殊的需求,队列在消费者-生产者模型中经常充当中间产物,当生产者大于消费者时,队列就会不断堆积,最终导致内存泄漏。 举个例子:应用收集日志的时候。如果欠缺考虑,也许会采用数据库来记录日志。日志通常会是海量的,数据库构建在文件系统之上,写入效率远远低于文件直接写入,于是会形成数据库写入操作的堆积,而 JavaScript 中相关的作用域也不会得到释放,内存占用不会回落,从而出现内存泄漏。
    解决思路:监控队列的长度,一旦堆积,应当通过监控系统产生报警并通知相关人员。另一个解决方案是任意异步调用都应该包含超时机制,一旦在限定的时间内未完成响应,通过回调函数传递超时异常,使得任意异步调用的回调都具备可控的响应时间,给消费速度一个下限值。

  2. 作用域未释放这个就是我们闭包经常遇到的场景,V8 中如果一个对象一直被引用,那它就不会被垃圾回收。

防止内存泄漏核心思想是我们在写 Node 代码的时候一定要考虑到海量请求的情况,Node 内存泄漏大部分都是海量请求导致的,这就是写服务和写浏览器端思路上的区别。

如何定位内存泄漏

如果确实已经在生产环境出现了内存泄漏,那我们真的只能删库跑路了吗?那倒未必,这里有几个常用的工具帮助我们去定位内存泄漏的问题,如果这几个工具都用了也救不活,那再跑路也不迟。

  1. node-heapdump它允许对V8堆内存抓取快照,用于事后分析,下面用个例子介绍一下定位内存泄漏的思路
    1. 安装heapdump
    npm install heapdump --save
    
    1. 在代码中引入headump,并模拟缓存泄漏的场景,不断给缓存对象增加数据
     const heapdump = require('heapdump');
     console.log(process.pid);
    
     const cache = {};
     let index = 0;
     const cacheValue = new Array(2000);
    
     setInterval(() => {
       cache[`testHeapKey---${index}`] = cacheValue;
       index++;
     }, 0);
    
    1. 有两种方法来主动生成快照,本例使用方法2

      1. 主动调用方法 writeSnapshot([filename], [callback])
      2. 通过给进程发送信号 kill -USR2 pid
    2. 给进程发送信号之后默认就会在项目根目录产生快照文件,当然这个文件不是给人看的,我们需要使用chrome的开发者工具去解析 image.png

    3. 打开cheome开发开发者工具,进入Memory工具,点击Load按钮加载我们刚才生成的内存快照文件 image.png

    4. 我们定位内存泄漏最好使用两个不同时间点的内存快照去进行对比,有了对比我们才能更好的发现问题,比如真实情况下,当我们怀疑 Node 服务有内存泄漏,那周一导出一份内存快照,周五再导出一份新的快照,然后用这两份快照去进行对比,就很容易对比出是哪个部分的内存在不断增长。如下图我已经获取了两个时间点的快照并导入到 Chrome 内存分析工具,从图可以看到第一个快照总内存 9.2MB,而第二个已经增长到了42.9MB,由此我们可以初步判断有内存泄漏 image.png

    5. 使用内存工具的对比模式,可以很清晰的看到,是哪种类型的对象在不断增长,由下图可以清晰地看到,第二份快照的String类型相对第一份快照增长了358586,由此可以进一步推断有String类型的对象再不断增加。 image.png

    6. 点开String对象,进一步观察是增加了哪些字符串,可以看到正是我模拟内存泄漏的代码添加的字符串,这样就已经定位到了是哪里出现了内存泄漏,再改代码进行优化和修复即可。

image.png

image.png

  1. node-memwatch 提供了一些监听的方法,当可能出现内存泄漏时,就会执行我们监听的方法,具体用法参照 node-memwatch的npm包文档即可,这里不再展开讨论
  2. memwatch-next监控内存使用情况最好的方法是在V8的GC后统计内存使用信息, memwatch就是这样做的.当V8执行垃圾回收的时候, memwatch会触发一个事件, 我们可以通过监听该事件来收集内存使用情况。 用法参照memwatch-next的npm包文档
  3. 想使用现成的完整监控方案也可以用阿里做的node性能监控平台
  4. PM2 有最大内存重启功能,可以在我们的 Node 进程因为内存泄漏崩溃之后自动重启,进一步对我们的 Node 进程做保护。

其实市面上还有很多工具可以定位内存泄漏,但不管哪种工具,其核心思路都是一样的 拿到 V8 内存快照,进行内存分析。有了内存快照,就像医生有了病人的彩超,知道哪里有问题,才能对症下药。

当然我们也可以通过脚本去采集进程的内存快照,有了快照就可以做任何你想做的事,比如触发监控告警,画出内存状态曲线图等等。

总结

Node 是需要关注内存的,虽然 V8 为我们处理了内存管理的工作,我们不用直接操作内存,但是也需要了解 Node 的内存以及垃圾回收机制,知道常见的内存泄漏问题,以及定位解决问题的手段,这样才能在生产环境出现问题时处理的得心应手。

参考资料

《深入浅出Node.js》第五章