Nodejs内存控制

原创 Lin_Grady 教程 nodejs 219阅读 2018-07-12 09:44:54 举报

完整Demo地址

里面demo都是自己写的,没有依赖可以直接跑。懒得写就去上面搬走看,懒得搬就直接看文章,大部分代码连输出信息都给你们了。
memory-demo

简称术语

垃圾回收(garbage collector),下面统称 GC
xx,代表随意名字或者自定义名字或者无规律程序自动分配名字。

瀏览器内存

如果只接触过前端的人应该都不熟悉这一块内容,因为在浏览器开发很少会遇到内存问题,更加不会用到什么大内存的操作,所以Javascript有自己的一套内存管理制度自动解决问题,我们不需要关心这些,可能瞭解也仅限於大概流程。

我以前在Javascript难点知识运用---递归,闭包,柯里化等(不定时更新)里垃圾收集章节总结过它两种收集方式标记清除引用计数,有兴趣可以去看看。

Nodejs内存

我们都知道 Nodejs 特性是基於异步无阻塞事件驱动型,因此内存消耗低特别擅长处理海量的网络请求。但是在海量请求和长时间运行的前提下,开发者需要考虑一些平时不需要关注的问题,如何可以最大限度高效利用一切资源就成了 Nodejs 服务端性能关键之一了。

还不清楚Nodejs模型的可以参考之前写的一篇文章
Nodejs高性能原理与异步非阻塞事件驱动模型与非异步API浅谈(有需要就更新)

因为 Nodejs 是基於V8引擎构建,所以我们需要先瞭解一下V8是怎么进行内存分配管理的。

V8对象分配

在 Nodejs 中通过Javascript使用的内存有限,64位系统大概1.4GB,32位系统约0.7GB,这就导致Nodejs无法直接操作大内存对象,比如无法将2GB文件读入内存中进行字符串分析处理,即使物理内存有32GB,在单个Nodejs进程下计算机内存资源无法得到充分利用。

之所以有这样限制表层原因是因为V8最初是為瀏览器而设计,并不需要有用到大内存的场景。深层原因是因为V8的GC机制的限制。GC会让Javascript綫程暂时停止执行,官方説法以1.5G内存回收為例,一次小的GC需要50+毫秒,一次大的非增量式回收1+秒,会造成应用性能响应都直綫下降,甚至一不小心触碰界限就会造成进程退出。

V8中所有Javascript对象都是通过堆进行分配的,当我们声明变量并赋值时,所使用对象的内存就分配在堆中,如果已申请的内存不够分配新对象时就继续申请堆内存直到超过V8限制为止。

我们可以打开命令行执行如下命令,process.memoryUsage() 会返回一组数据,单位都是字节。

PS C:\work\project\test> node
process.memoryUsage()
{ rss: 23564288,
heapTotal: 9232384,
heapUsed: 5027992,
external: 8748 }

rss(resident set size):进程常驻内存,包含堆,栈,和代码段
heapTotal:已申请的堆内存
heapUsed:已使用的量
external:V8管理绑定到Javascript的C++对象的内存
进程的内存总共有几部分,一部分是rss ,其余部分在 交换区(swap)或者 文件系统(filesystem)中。

V8也提供了放宽限制的选项,在启动 Nodejs 的时候设置

node --max-old-space-size=xx xx.js//单位MB
node --max-new-space-size=xx xx.js//单位KB

具体用法区别下面会说,现在先记住该设置在V8初始化生效之后不得再动态更改,这就意味著应用运行期间不能自动根据情况自动扩充,内存分配过程超过限制就会引起进程错误。

V8的GC机制

V8的GC策略主要是基于分代式机制,因为实际应用对象的生存周期长短不一,现代的GC算法按对象的存活时间进行不同分代采用不同的算法执行。

内存分代

V8将内存分成存活时间较短的新生代对象和存活时间较长或常驻内存的老生代对象,两者组合起来就是V8堆内存整体大小。上面说的两个设置选项就是这两个对象。

Scavenge算法

Scavenge算法具体采用 Cheney算法 实现,采用复製的方式将堆内存一分为二,每部分空间称为 semispace(半空间)。一个处於使用中称为 From空间,另一个处於闲置中称为 To空间

(直接百度找的,凑合看吧)

当我们分配对象时候会先分配在From空间,进行GC的时候会先检查From空间的存活对象,然后将其复製到To空间,至於非存活对象会被释放内存,完成复制后From空间和To空间角色互换。

Scavenge算法优点是只复製存活对象,缺点是衹能利用一半的堆内存,这是一种由划分空间和复製机制决定的牺牲空间换时间的做法。所以无法大规模应用到所有的GC。

而儅一个对象经过多次复製依然存活的情况下会被认为是生命周期较长的对象,於是会被移动到老生带中采用新的算法进行管理,这过程称为“晋升”。而“晋升”需要符合两种条件之一:

1,对象是否经歷过Scavenge回收(前提得经过多次复製依然存活);
2,To空间内存占比是否超过25%限制;


(直接百度找的,凑合看吧)

在分代式GC的前提下,From空间对象复製之前会检查它的内存地址判断是否经歷过一次scavenge算法回收和To空间是否使用超过25%,之所以限制在25%是因为回收完成之后两个semispace空间角色互换,如果当前To空间内存占比过高会影响后续内存分配。


(直接百度找的,凑合看吧)

在分代的基础上生命周期短暂的新生代对象非常适合通过这种算法进行GC,而老生代对象存活比重大,并不适合Scavenge算法。

Mark-Sweep && Mark-Compact

Mark-Sweep 会在标记阶段遍歷堆中所有对象并标记活著的对象,没有被标记的对象会在清除阶段被释放,这种既不需要复製也不需要牺牲空间的方式最大的问题在於回收之后内存空间处於一种不连续的状态。内存碎片化会影响后续的内存分配让碎片内存得不到应用,儅无法完成分配的时候会提前触发不必要的GC。


(直接百度找的,凑合看吧)

Mark-Compact 是为了解决碎片问题从Mark-Sweep基础上演变出来,差别在於它会在标记过程中移动存活对象,然后直接清理边界外内存,因为中间需要移动对象所以效率会低於 Mark-Sweep。

V8主要使用Mark-Sweep,直到空间不足于分配“晋升”对象时才使用Mark-Compact。

Incremental Marking

为了避免Javascript应用逻辑和GC器情况不一致,上面算法都会在回收期间暂停执行应用逻辑,这种行为叫“全停顿”(stop-the-world)

因为V8老生代通常配置较大,存活对象多,标记,整理,清除步骤较久,所以在标记阶段改用增量标记(Incremental Marking),把连续动作拆分多个小动作,每个动作间隙中执行一会Javascript逻辑。即GC和应用逻辑交替执行直到标记阶段完成,最大停顿时间可以减少到原有1/6左右。

基本对比

回收算法ScavengeMark-SweepMark-Sweep
速度最快中等最慢
空间开销双倍空间(无碎片)少(有碎片)少(无碎片)
是否移动对象

其餘

V8后续还引入了延迟清理(lazy sweeping)增量式整理(incremental compaction)并行标记与并行清理等,进一步利用多核性能降低每次停顿时间。

小结

从V8对内存进行限制的设计角度来说:
1,瀏览器每个选项卡页面使用一个V8实例的内存是绰绰有餘的;
2,新生代对象相对内存佔用少,存活佔比小,影响不大;
3,老生代对象内存过大对於GC没有特别意义;

Nodejs 服务端正常场景下使用没有问题,只是需要注意Javascript单线程的执行情况和GC特点对性能的影响,特别是老生代对象过大会造成内存紧张,清理过程费时停顿。

查看GC内存

启动的时候我们可以在命令加上

--trace_gc参数:在进行GC时,将会从标準输出中打印GC信息。
\> xx.log参数:在当前目录下将标準输出写入/生成指定的log文件。

我们先创建 lesson1.js 文件遍历插入一百万条数据。

(完整代码可以执行memory-demo的 lesson1 查看效果)
然后当前目录启动终端输入 node --trace_gc lesson1 > gc.log,执行完之后会看到目录下生成一个 gc.log 文本,里面大概有这些信息

[25164:000002B6E5994800] 40 ms: Scavenge 2.6 (3.8) -> 2.4 (4.8) MB, 1.7 / 0.0 ms allocation failure
[25164:000002B6E5994800] 50 ms: Scavenge 2.8 (4.8) -> 2.7 (5.8) MB, 1.2 / 0.0 ms allocation failure
[25164:000002B6E5994800] 77 ms: Scavenge 4.0 (5.8) -> 3.9 (8.8) MB, 0.8 / 0.0 ms allocation failure
[25164:000002B6E5994800] 80 ms: Scavenge 5.1 (8.8) -> 5.1 (8.8) MB, 1.7 / 0.0 ms allocation failure
[25164:000002B6E5994800] 82 ms: Scavenge 5.8 (8.8) -> 5.8 (14.3) MB, 1.2 / 0.0 ms allocation failure
[25164:000002B6E5994800] 85 ms: Scavenge 9.0 (14.3) -> 8.9 (15.3) MB, 1.5 / 0.0 ms allocation failure
[25164:000002B6E5994800] 87 ms: Scavenge 9.6 (15.3) -> 9.5 (26.3) MB, 1.8 / 0.0 ms allocation failure
[25164:000002B6E5994800] 97 ms: Scavenge 17.1 (27.8) -> 17.2 (28.3) MB, 4.1 / 0.0 ms allocation failure
[25164:000002B6E5994800] 102 ms: Scavenge 17.7 (28.3) -> 17.4 (51.8) MB, 4.8 / 0.0 ms allocation failure
[25164:000002B6E5994800] 116 ms: Scavenge 32.6 (51.8) -> 32.8 (52.3) MB, 5.6 / 0.0 ms allocation failure
[25164:000002B6E5994800] 122 ms: Scavenge 33.1 (52.3) -> 32.3 (67.8) MB, 5.6 / 0.0 ms allocation failure
[25164:000002B6E5994800] 233 ms: Mark-sweep 145.2 (178.7) -> 142.7 (177.2) MB, 1.8 / 0.0 ms (+ 2.5 ms in 4 steps since start of marking, biggest step 1.4 ms, walltime since start of marking 31 ms) finalize incremental marking via stack guard GC in old space requested
[25164:000002B6E5994800] 371 ms: Mark-sweep 274.5 (311.2) -> 271.1 (310.3) MB, 2.3 / 0.0 ms (+ 3.5 ms in 56 steps since start of marking, biggest step 0.8 ms, walltime since start of marking 47 ms) finalize incremental marking via stack guard GC in old space requested
[25164:000002B6E5994800] 627 ms: Mark-sweep 543.6 (587.4) -> 536.2 (580.0) MB, 2.5 / 0.0 ms (+ 14.9 ms in 318 steps since start of marking, biggest step 0.7 ms, walltime since start of marking 104 ms) finalize incremental marking via stack guard GC in old space requested

以类似信息第一条为例
[25164:000002B6E5994800] 40 ms: Scavenge 2.6 (3.8) -> 2.4 (4.8) MB, 1.7 / 0.0 ms allocation failure
执行40ms后使用Scavenge算法对新生代对象进行GC,使用内存从2.6->2.4,常驻内存从3.8->4.8.里面的“allocation failure”并不是真正的失败,只是代表着分配了这么多内存是时候该做个 GC 去看看能不能回收一些内存。

[25164:000002B6E5994800] 233 ms: Mark-sweep 145.2 (178.7) -> 142.7 (177.2) MB, 1.8 / 0.0 ms (+ 2.5 ms in 4 steps since start of marking, biggest step 1.4 ms, walltime since start of marking 31 ms) finalize incremental marking via stack guard GC in old space requested
执行233ms后使用Mark-sweep算法对老生代对象进行GC,使用内存从145.2->142.7,常驻内存从178.7->177.2.

如果启动时候加上--prof参数可以得到V8执行时的性能分析数据,其中也包括了GC执行时占用时间比。

当前目录启动终端输入 node --prof lesson1,执行完之后会看到目录下生成一个 xx-v8.log 文本,该文本不具有可读性,里面有巨量的信息没办法直接贴出来,反正我是看不出什么。

V8提供了 windows/linux-tick-processor工具 用于统计日志信息,就在 Nodejs 源码的 deps/v8/tools 目录下,可以将它配置到环境变量 PATH 路径直接调用

windows/linux-tick-processor xx-v8.log

我嫌麻烦没去试,你们可以试试。

高效使用内存

其实就是编写代码的时候注意全局变量,作用域销毁,作用域链查找深度,无用变量回收,创建闭包等占用内存的问题,都是些基础常用知识,有兴趣可以参考我以前写的一篇科普文章。
Javascript难点知识运用---递归,闭包,柯里化等(不定时更新)

内存指标

查看内存情况

除了我们上面说过的 process.memoryUsage() 以外,os模块 中的 taotalmem()freemem() 方法也可以查看。

查看进程的内存占用

process.memoryUsage()是返回进程的内存占用,为了更好观察效果,我们用上面的代码改装一下,首先更加直观地格式化输出效果。

再不断遍历循环占用内存,并且每次遍历都输出内存结果,因为windows系统限制大概在1.4G,我们设置1.5G长度使用让它溢出。

(完整代码可以执行memory-demo的 lesson2 查看效果)
输出结果大概如下

最后一次循环停在
Process: heapTotal 1289.90 MB heapUsed 1283.65 MB rss 1301.21 MB
后面进行几次GC,最终宣告内存溢出
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory

查看系统的内存占用

totalmem()freemem() 返回的是系统内存使用情况,因为 os模块 是 Nodejs 的核心模块,所以不需要额外引入,直接在 Nodejs 终端执行。

可以得到我电脑的总内存和限制内存如上。

堆外内存

从 process.momoryUsage() 输出结果可以看到,堆内存总是少于进程的常驻内存,这是因为 Nodejs 中的内存不全是V8分配的,非V8分配的内存被称为堆外内存
我们将上面的方法修改一下,用 Buffer 替代 Array,然后扩大使用内存看看效果。

(完整代码可以执行memory-demo的 lesson3 查看效果)
输出结果如下

可以看到这次尽管数据量扩大十倍,但是没有发生内存溢出,而且 heapTotalheapUsed 基本持平,唯有rss不断扩充甚至远远超过V8限制值了。这是因为Buffer对象不经过V8内存分配,所以可以无视堆内存的大小限制。

(本来开始是打算写关于Buffer对象的,然后因为它需要涉及到内存的知识点,所以先学习这一章,终端最后输出用法废弃先不管,下一章再去研究Buffer对象。)

内存泄露

Nodejs 擅长高并发场景,同样也对内存泄露特别敏感,而且通常无意间造成的问题难以排查,但基本原因都是因为本该回收的对象没有回收以至于变成常驻老生代对象,可能原因有几个:

  • 缓存
  • 队列消费不及时
  • 作用域未释放

缓存

缓存是一种十分有效节省资源的手段,缓存也分多种,有严格意义上的有完善过期策略缓存,也有开发者代码常用的普通对象键值对缓存等,可能如下。

(完整代码可以执行memory-demo的 lesson4 查看效果)
如果没做基本限制使用条件,在浏览器这种短时应用场景问题不大,但在 Nodejs 这种执行量大和参数多样性的使用环境会造成占用过多内存不释放,所以务必需要非常谨慎。

1,缓存限制策略
为了解决缓存对象永远无法释放的问题,需要加入一种策略限制缓存的无限增长。懒得写了,直接搬朴灵写的limitablemap模块实现写法。

基本原理就是设置一个阈值,每当达到阈值的时候就移除头部缓存。

还有其他如模块机制的缓存是常驻老生代的对象,上一章节讲过 Nodejs 会将编译执行过后的模块缓存起来,然后可以通过 exports 变量访问里面的私有变量,导致私有变量作用域因为模块缓存原因不会被释放,而且外部也能够让私有变量不断增加占用内存。

一般解决方案就是同时导出一个清理缓存的方法。

如果不熟悉模块知识的话可以参考我之前写的一篇文章Nodejs模块加载与ES6模块加载实现

缓存解决方案

因为进程之间无法共享内存,所以进程间不可避免有些重复缓存,目前比较好的方案是采用进程外的缓存。

  • 缓存转移到外部减少常驻内存对象数量,提高GC效率
  • 进程可以共享外部缓存

广为人知的有 RedisMemcached,以后有机会再单独写一篇。

队列状态

Javascript中我们也经常使用队列来控制事件,如果消费速度远低于生产速度就会形成堆积最后形成内存泄漏。
例如有些应用会采用数据库记录海量日志,而数据库又构建在文件系统之上而写入效率远低于直接写入文件系统,于是写入操作慢慢堆积导致相关作用域得不到释放占用大量内存最后出现内存泄漏,最简单解决方法就是换用文件写入日志的方式。
这是提高消费速度的情况,自然也会有需要抑制生产速度的情况出现。
深度解决方案有

  • 监控队列长度,达到阈值通过监控系统报警并通知相关人员
  • 超时机制,保证限定时间内未完成响应调用相关操作终止
  • 拒绝模式,达到阈值之后不再接收新的生产事件

内存泄漏排查

  • node-heapdump 这是Node 核心贡献者之一Ben Noordhuis编写的模块,它允许对V8对堆内存抓取快照,用于事后分析。
  • node-memwatch 来自Mozilla的Lloyd Hilaiel贡献的模块,采用WTFPL许可发布。

(试了一下安装很麻烦,总是报错,好像需要安装些其他依赖环境,编译插件,暂时放弃了,以后研究到再补充进来。)

大内存应用

Nodejs 提供了 Stream模块 用于处理大文件,它继承自 EventEmitter ,所以具备基本的自定义事件功能,同时抽象出标准的事件和方法, Nodejs 大多数模块都会用到 Stream模块,得益于它实现方式不受V8内存限制,能够有效提高代码健壮性。

(因为稍后也准备写一篇关于Stream模块的东西,这里就不再说了,如果内容不多的话就搭配Buffer模块一起了。)

参考资料

nodejs 深入浅出

评论 ( 0 )
最新评论
暂无评论

赶紧努力消灭 0 回复