Node 内存控制
✨文章摘要(AI生成)
在重新阅读《深入浅出 NodeJS》的过程中,我深入了解了 Node.js 的内存控制机制。Node.js 基于 V8 引擎,其内存使用受到限制,64 位系统约为 1.4GB,32 位系统约为 0.7GB,尽管物理内存可能更大。这是由于 V8 的垃圾回收机制设计,旨在优化性能,避免长时间的停顿。
我学习了 V8 的内存管理是通过分代垃圾回收机制进行的,包括新生代和老生代两部分。新生代使用 Scavenge 算法,快速回收短生命周期对象;老生代则结合 Mark-Sweep 和 Mark-Compact 算法,清理长生命周期对象。全停顿问题促使了增量标记等优化策略的出现,进一步提高了应用的响应能力。此外,内存泄漏的常见原因包括缓存未过期、队列消费不及时及作用域未释放,理解这些有助于我在实际开发中更好地监控和管理内存。
这是重新阅读《深入浅出 NodeJS》的相关笔记,这次阅读发现自己依旧收获很多,而第一次阅读的东西也差不多忘记完了,所以想着这次过一遍脑子,用自己的理解输出一下,方便记忆以及以后回忆...
基本介绍
说到node
对于内存的控制,可能最先想到的就是node
是基于 V8 构建,因此在node
中通过JavaScript
使用内存时就会发现只能使用部分内存(64 位系统约为 1.4GB,32 位系统约为 0.7GB)。在这样的限制下,将会导致node
无法直接操作大内存对象,即使你本机的物理内存有 32GB 也不行,这与我们传统上的认知形成了一定的差别,接下来先解释一下为什么有这样的差别:
注:64 位系统约为 1.4GB,32 位系统约为 0.7GB 为默认,也可以用户自定义
--max-old-space-size
和--max-new-space-size
来调整,不过只能在启动时指定,node
无法运行中自动扩充
首先,V8 的设计就是在浏览器的应用场景下完成的,这套内存管理机制在浏览器下使用是绰绰有余的,只是在node
中使用有些限制,当然,也有其他方式来解决,只是不能让开发者随心所欲地使用大内存了。
其实深层原因是 V8 的垃圾回收机制的限制,每次垃圾回收都必须让JavaScript
线程暂停,如果垃圾回收时间过长会导致应用的性能和响应能力都会直线下降,所以 V8 当时的考虑直接限制堆内存是一个好的选择;这中停顿叫做“全停顿”,V8 为此也还做了许多优化,这个后续章节会讲到
回到这一部分,介绍一个比较常见的命令:我们在node
中输入process.memoryUsage()
,就可以很方便地查看node
内存使用量的相关信息:
简单解释一下其中的含义,详细参考
注:V8 中的所有
JavaScript
对象都是通过堆来进行分配的
rss
:Resident Set Size,是该进程所占用的总空间量,包含所有C++
和JavaScript
对象和代码heapTotal
:已经申请到的堆内存heapUsed
:当前使用的堆内存external
:指绑定到 V8 管理的 JavaScript 对象的 C++ 对象的内存使用情况arrayBuffers
:包含的Buffer
对象,该值包含在external
中
值得注意的是:V8 在上述变量中只负责了堆内存的分配,external
包含的内存并不是通过 V8 管理的,所以我们在external
中操作的东西可以不受 V8 的内存限
垃圾回收机制
总的来说,V8 的垃圾回收策略是基于分代式垃圾回收机制,分为新生代和老生代。其中新生代的对象为存活时间较短的对象,老生代的对象为存活时间较长的对象,这里先介绍结论,具体原因后续讲到。
新生代
新生代中主要通过 Scavenge 算法进行垃圾回收,这是典型的空间换时间的算法,会牺牲一半的存储空间,但速度较快,正好与新生代中对象的特点相对应
该算法主要采取复制的方式进行的垃圾回收,如下图所示:
之前提到过这个算法与新生代的特点向符合,解释一下,新生代里面对象的特点就是生命周期较短,所以在下一次复制过程中存活的对象一般来说是比较少的,而这个算法是只复制存活的对象,所以时间效率上有优异的表现,同时这中将存活中的对象直接在另一半内存空间中依次排列,不会产生老生代那种算法的内存碎片问题(后续会讲到)
晋升
其实一开始对象声明的时候,V8 也不知道这个对象是否生命周期较短,那它是如何判断从而将对象区分到新生代区域和老生代区域之中的呢?
答案就是本小节的标题,“晋升”:当一个对象经过多次复制依然存活时或者该对象 To 空间的占用比超出限制(一般 25%),它将会被执行晋升操作,放入到老生代区域之中(注意:只要这两个条件满足一个,就会执行晋升操作,这是个“或”条件)
老生代
结合老生代的特点,V8 在老生代中主要采取了Mark-Sweep
和Mark-Compact
相结合的方式进行垃圾回收,就是标记清除和标记整理
标记清除:
- 标记阶段:标记活着的对象
- 清除阶段:清除没被标记的对象
老生代的特点就是存活时间长,即失活对象的占比一般来说是比较小的,所以这里是清除死亡的对象是合理的。而不是与新生代算法一致,复制活着的对象,并且由于老生代对象占用内存较大,所以分出一半空间来说也是不合理的
标记整理:在标记清除的基础上提出来的,在对象标记为死亡后,整理的过程中,会将活着的对象往一边移动,移动完成后,直接清理掉边界外的内存。
注意:V8 并不是直接采取标记整理的方式来管理老生代,而是通过标记清除和标记整理相结合的方式进行处理,因为它们在处理效率上有较大差别,毕竟标记清理多做了移动的操作。
回收算法 | Mark-Sweep | Mark-compact | Scavenge |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(有碎片) | 少(无碎片) | 双倍空间(无碎片) |
是否移动对象 | 否 | 是 | 是 |
这种分层级处理方式在计算机中非常常见,最容易想到的就是比如速度上寄存器 > 内存 > 外存
,而价格上寄存器 < 内存 < 外存
,所以计算机的存储结构就采取了三级分层策略来平衡速度与价格;就像 V8 中于内存的处理是在时间和空间以及内存碎片等维度上进行平衡的。
所以在取舍中,V8 主要使用标记清除算法,在空间不足以对新生代晋升过来的对象进行分配的时候才使用标记整理算法(如下图)
全停顿问题
上述中的三种基本垃圾回收算法都需要将应用逻辑(JavaScript
执行线程)暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这样做是为了避免JavaScript
应用逻辑与垃圾回收器看到的不一致的情况。这种行为就是文章前面提到的“全停顿”;
而且老生代通常配置得较大,且活动对象较多,全堆垃圾回收得标记、清理、整理等动作造成得停顿就会比较可怕,需要优化:
这就是“增量标记”出现的原因,具体过程就是将原本要以口气停顿完成的动作改为增量标记的方式,也就是拆分为寻多个小“步进”,每做完一个“步进”就让JavaScript
执行一小会儿
同时还有延迟清理与增量式整理,让清理和整理的动作也变成增量式等一系列优化操作,这里不深入研究。
内存泄漏
通常造成内存泄漏的原因有这些:
- 缓存:把内存作缓存,却没有过期策略清除,导致越来越多
- 队列消费不及时:生产速度远远大于消费速度,队列长度没做限制的话就会无限变大,导致内存泄漏
- 作用域未释放
内存监控及内存泄漏解决方案
TODO,功力不够,后续来补,主要是还没实践经验,这里先挖个坑。。。