Skip to content

前言

在生产运行时,有个功能会定时解析 sourcemap 文件,因为有些文件的大小会有十几 M,导致程序运行了一段时间后,解析库会报错“memory access bound”,初步估计是内存不够导致,所以特意总结下 NodeJS 的内存管理。

NodeJS 的内存控制

NodeJS 内存组成部分

NodeJS 是在 V8 引擎运行的,而 V8 引擎就是现在 Chrome 运行 JavaScript 那套,只不过现在是把 V8 搬到服务器运行

V8 内存的组成 - Resident Set(RSS):

shell
--------------------------
|    Code Segment        |
--------------------------
|    Stack(局部变量)       |
--------------------------
|    Heap(对象、闭包)      |
--------------------------
|    External            |
--------------------------

heap 占大部分,而 external 看程序获取的量

V8 引擎默认是有一个内存限制的:

shell
--------+-------------------------------------------------
| 新生代 |                     老生代                      |
|  32MB |                      1.4G                      |
--------+-------------------------------------------------

根据不同的位数,新生代和老生代分配到的内存不一致,上面的是 64 位系统的

为什么内存要限制 1.4 G?

V8 一开始是用于浏览器的,后面才搬到服务器,所以最开始的设计是只关注于网页,而网页应用并不像服务应用,要常驻进程,网页刷新后会一切重来,这使得网页应用大部分都不需要很大的内存

而另一点就是因为垃圾回收的原因,垃圾回收会造成“全卡顿”现象,为了保证垃圾回收时对象引用不变,会暂停其他的代码执行,垃圾回收会占用 JS 的线程。而在 1.5G 的一次“非增量”垃圾回收,要使用 1s 以上的时间,当你的内存越大,垃圾回收要遍历的对象就越多,需要的时间越长,这会大大影响程序的运行

当然,这个可以用过配置,调整程序的新生代和老生代的内存分配:

shell
--max-old-space-size=2  --> 配置老生代的内存,单位是 MB
--max-new-space-size=1024 --> 配置新生代的内存,单位是 KB

当内存一旦设置好,运行期间是无法改变

代码运行时,如何查看当前使用情况?

通过 process.memoryUsage() 方法,可以查看当前程序运行时的内存使用情况

javascript
process.memoryUsage()
->
{
  rss   -->  常驻内存大小
  heapTotal  -->  堆分配的内存大小
  heapUsed  -->  堆使用的内存大小
  external  -->  外部引用的内存大小
}

使用 os. 的方法也可以查看操作系统的内存情况

javascript
os.totalmem()  -->  返回操作系统的总内存
os.freemem()  -->  返回操作系统的闲置内存

垃圾回收机制

V8 使用多套垃圾回收算法,而新生代内存和老生代内存会用不同的算法

新生代的 GC - Scavenge 算法

新生代内存主要用的是 Scavenge 算法进行垃圾回收,而 scavenge 算法主要采用 Chency 算法

Cheney 的算法把可用空间切割两个均等空间,变量只存在一边 “From” 空间里面,当触发 GC 时,筛选活跃变量到 “To” 空间,然后清空 “From” 空间,再把 “To” 空间对象移到 “From” 空间

这是一个典型的牺牲空间换取时间的算法,适合用于空间不大的 GC

而 Scavenge 算法还有另外一个机制:晋升。当对象曾今触发过 Scavenge 回收或者 To 空间使用超过 25%,就会被转到 “老生代” 空间

为什么是 25%?如果占比过高,会影响后续的内存分配(我也不懂为啥是 25,书是这样写的,为啥不能是 50 ?)

老生代的 GC - Mark-Sweep & Mark-Compact

老生代 GC 是两种算法配合使用,首先进行 Mark-Sweep

Mark-Sweep 主要两个操作:标注和清除,它会先将活跃对象(有被引用的对象)进行标记,标记完成后在清除阶段,清除没被标记的对象

Mark-Compact 在清除对象时的整理过程,将活的对象往一边统一移动,最后直接清理这端边界外的内存

Mark-Compact 是基于 Mark-Sweep 演变而来,总体上的流程就是:

  1. 标记活跃对象
  2. 把它们整理到一端
  3. 直接清除另一端的内存

为什么要这样做?

当使用 Mark-Sweep 清理后,空白的区间是闲置的内存,如果突然要分配给一个大的对象时,这些小块的内存空间是不够位置的,这就造成总体上来看内存是很足,但实际上不能分配给大的对象

所以使用了 Mark-compact 后,闲置的内存块会被排成一个连续的内存空间

来自《深入浅出Node.js》

后续优化

上面的算法都会造成“全停顿”(stop-the-world),后面新版本有不同的更新,比如增量标记(incremental marking)、延迟清理(lazy sweeping)和增量式整理(incremental compaction),书中没提,就暂时不理了

如何对内存进行调试

node-heapdump

使用这个库,可以看到程序的堆使用情况

javascript
const http = require('http')
require('heapdump')

var leakArray = []; 
var leak = function () { 
 leakArray.push("leak" + Math.random()); 
}; 
http.createServer(function (req, res) { 
 leak();
 res.writeHead(200, {'Content-Type': 'text/plain'}); 
 res.end('Hello World\n'); 
}).listen(1337); 
console.log('Server running at http://127.0.0.1:1337/', process.pid);

想停止的时候,调用:kill -USR2 {pid}

会在项目根目录生成一份报告,在 chrome 的 memory 栏,导入报告,就可以看到详细的堆情况

这里有个问题,运行的程序,通常都会有非常多不同的对象,不像上面那个 demo 可以轻易找到泄露的对象,这有什么方法快速找到泄露内存的对象呢?

书中介绍的另外一个库:node-memwatch,我的 mac 安装不了,去 github 看也有相关 issue,这个库也很久没更新了,所以不再研究了

出现的问题

解析 sourcemap 导致报错“memory access out of bounds”

我这里用了 source-map 库,对文件进行解析,当执行多次后,发现会触发:RuntimeError: memory access out of bounds 错误。很明显是关于内存使用的问题,我在 github 搜索了相关的 issues,但也没对应的解决方法

错误日志:

javascript
RuntimeError: memory access out of bounds
    at wasm-function[18]:0xf76
    at wasm-function[28]:0x3494
    at BasicSourceMapConsumer._parseMappings (/Users/apple/Downloads/analySourcemap/node_modules/source-map/lib/source-map-consumer.js:353:46)
    at BasicSourceMapConsumer._getMappingsPtr (/Users/apple/Downloads/analySourcemap/node_modules/source-map/lib/source-map-consumer.js:326:12)
    at /Users/apple/Downloads/analySourcemap/node_modules/source-map/lib/source-map-consumer.js:535:14
    at Object.withMappingCallback (/Users/apple/Downloads/analySourcemap/node_modules/source-map/lib/wasm.js:95:11)
    at BasicSourceMapConsumer.originalPositionFor (/Users/apple/Downloads/analySourcemap/node_modules/source-map/lib/source-map-consumer.js:533:16)
    at analyz (/Users/apple/Downloads/analySourcemap/analyz.js:40:42)
    at async Promise.all (index 19)
    at async update (/Users/apple/Downloads/analySourcemap/main.js:35:20)
    at async task (/Users/apple/Downloads/analySourcemap/main.js:51:18)

定位到对应的源文件和对应行

发现是执行 parse_mappings 时触发的错误,我在该库全局搜索,没搜到相关的错误信息,然后再搜了下这错误信息,发现这个是 c/c++ 报出的错误

找不到相关解决的方法,我就看自己的代码,看看是不是自己哪里的对象没释放,导致内存泄露

然后我先用了 heapdump 查询对应

看完堆信息后,发现是这块的数据没释放,然而我的业务代码是没用到 Buffer,那肯定是 source-map 库的问题

然后我使用了 process.memoryUsage() 方法,实时查看当时的内存状况,如图下,y 轴是内存大小 MB,x 轴是调用次数

外部内存升高最明显,达到了 2.09G 了,后面就是报错无法继续运行。接着我只能看源码,是不是哪里的的数组没释放,然后发现了一个 destroy 方法。。。。。。原来是自己的问题,没看好文档,用完没做销毁!!!

添加了该方法后,内存使用情况如下图:

外部内存峰值也就到 500+ MB,解决了问题

为什么当外部内存到 2G 后就不能执行下去?

source-map 使用的是 buffer 进行解析操作,而 buffer 有个最大字节长度限制

在 32 位的架构上,该值当前是 $$2^(30) - 1$$ (~1GB)。 在 64 位的架构上,该值当前是 $$2^(31) - 1$$ (~2GB)。

javascript
buffer.kMaxLength
2147483647

最大的限制是 2.1G 左右,所以解释了上面的问题为什么会在 2G 左右的时候报错

文档:

参考资源

  1. 《深入浅出Node.js》
  2. 简书 - 深入理解Node.js垃圾回收与内存管理
  3. buffer 文档