前言
在生产运行时,有个功能会定时解析 sourcemap 文件,因为有些文件的大小会有十几 M,导致程序运行了一段时间后,解析库会报错“memory access bound”,初步估计是内存不够导致,所以特意总结下 NodeJS 的内存管理。
NodeJS 的内存控制
NodeJS 内存组成部分
NodeJS 是在 V8 引擎运行的,而 V8 引擎就是现在 Chrome 运行 JavaScript 那套,只不过现在是把 V8 搬到服务器运行
V8 内存的组成 - Resident Set(RSS):
--------------------------
| Code Segment |
--------------------------
| Stack(局部变量) |
--------------------------
| Heap(对象、闭包) |
--------------------------
| External |
--------------------------
heap 占大部分,而 external 看程序获取的量
V8 引擎默认是有一个内存限制的:
--------+-------------------------------------------------
| 新生代 | 老生代 |
| 32MB | 1.4G |
--------+-------------------------------------------------
根据不同的位数,新生代和老生代分配到的内存不一致,上面的是 64 位系统的
为什么内存要限制 1.4 G?
V8 一开始是用于浏览器的,后面才搬到服务器,所以最开始的设计是只关注于网页,而网页应用并不像服务应用,要常驻进程,网页刷新后会一切重来,这使得网页应用大部分都不需要很大的内存
而另一点就是因为垃圾回收的原因,垃圾回收会造成“全卡顿”现象,为了保证垃圾回收时对象引用不变,会暂停其他的代码执行,垃圾回收会占用 JS 的线程。而在 1.5G 的一次“非增量”垃圾回收,要使用 1s 以上的时间,当你的内存越大,垃圾回收要遍历的对象就越多,需要的时间越长,这会大大影响程序的运行
当然,这个可以用过配置,调整程序的新生代和老生代的内存分配:
--max-old-space-size=2 --> 配置老生代的内存,单位是 MB
--max-new-space-size=1024 --> 配置新生代的内存,单位是 KB
当内存一旦设置好,运行期间是无法改变
代码运行时,如何查看当前使用情况?
通过 process.memoryUsage()
方法,可以查看当前程序运行时的内存使用情况
process.memoryUsage()
->
{
rss --> 常驻内存大小
heapTotal --> 堆分配的内存大小
heapUsed --> 堆使用的内存大小
external --> 外部引用的内存大小
}
使用 os.
的方法也可以查看操作系统的内存情况
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 演变而来,总体上的流程就是:
- 标记活跃对象
- 把它们整理到一端
- 直接清除另一端的内存
为什么要这样做?
当使用 Mark-Sweep 清理后,空白的区间是闲置的内存,如果突然要分配给一个大的对象时,这些小块的内存空间是不够位置的,这就造成总体上来看内存是很足,但实际上不能分配给大的对象
所以使用了 Mark-compact 后,闲置的内存块会被排成一个连续的内存空间
来自《深入浅出Node.js》
后续优化
上面的算法都会造成“全停顿”(stop-the-world),后面新版本有不同的更新,比如增量标记(incremental marking)、延迟清理(lazy sweeping)和增量式整理(incremental compaction),书中没提,就暂时不理了
如何对内存进行调试
node-heapdump
使用这个库,可以看到程序的堆使用情况
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,但也没对应的解决方法
错误日志:
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)。
buffer.kMaxLength
2147483647
最大的限制是 2.1G 左右,所以解释了上面的问题为什么会在 2G 左右的时候报错
文档:
参考资源
- 《深入浅出Node.js》
- 简书 - 深入理解Node.js垃圾回收与内存管理
- buffer 文档