Node与V8
Node选择了V8引擎,基于事件驱动、非阻塞I/O模型。
V8的内存限制
64位系统约为1.4GB,32位系统约为0.7GB,在这样限制下,将会导致Node无法直接操作大内存对象,比如无法将一个2GB的文件读入内存中进行字符串分析处理,即使物理内存有32GB,这样在单个Node进程的情况下,计算机的内存资源无法得到充足的使用。要知晓V8为何限制了内存的用量,则需要回归到V8在内存使用上的策略。
V8的对象分配
Node提供V8内存使用量查看方式:
$ node
$ process.memoryUsage();
{
rss: 18702336,
heapTotal: 10295296,
heapUsed: 5409936
}复制代码
heapTotal:已申请到的堆内存
heapUsed:当前使用的量
JS声明变量并赋值时,所使用对象的内存就分配在堆中。如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到对的大小超过V8的限制为止。
至于V8为何要限制堆的大小,表层原因:V8最初为浏览器而设计,不太可能遇到用大量内存的场景。深层原因:V8的垃圾回收机制的限制。官方说法,以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JS线程暂停执行的时间,在这样时间花销下,应用的性能和响应能力都会直线下降。V8提供选择来调整内存大小的配置,需要在初始化时候配置生效,遇到Node无法分配足够内存给JS对象的情况,可以用如下办法来放宽V8默认内存限制。避免执行过程内存用的过多导致崩溃。
node --max-old-space-size=1700 index.js
node --max-new-space-size=1024 index.js
复制代码
V8的垃圾回收机制
V8垃圾回收策略主要基于分代式垃圾回收机制。
V8的内存分代
Scavenge算法
Cheney算法是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生兑换。简而言之,在垃圾回收过程中,就是通过将存活对象在两个semispace空间之间进行复制。
Scavenge的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。但Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。
V8堆内存示意图:
实际使用的堆内存是新生代的两个semispace空间大小和老生代所用内存大小之和。当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代中移动到老生代中的过程称为晋升。
Mark-Sweep & Mark-Compact
Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。与Scavenge相比,Mark-Sweep并不将内存空间划分为两半,所以不存在浪费一半空间的行为。与Scavenge复制活着的对象不同,Mark-Sweep在标记阶段遍历堆中所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。
Mark-Sweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
为了解决Mark-Sweep的内存碎片问题,Mark-Compact被提出来。Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。下图为Mark-Compact完成标记并移动存活对象后的示意图,白色格子为存活对象,深色格子为死亡对象,浅色格子为存活对象移动后留下的空洞。
Mark-Sweep、Mark-Compact、Scavenge三种主要垃圾回收算法的简单对比
回收算法 |
Mark-Sweep |
Mark-Compact |
Scavenge |
速度 |
中等 |
最慢 |
最快 |
空间开销 |
少(有碎片) |
少(无碎片) |
双倍空间(无碎片) |
是否移动对象 |
否 |
是 |
是 |
Incremental Marking
为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”就让js应用逻辑执行一小会,垃圾回收与应用逻辑交替执行直到标记阶段完成。
V8在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。
V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。
内存泄漏
Node对内存泄漏十分敏感,一旦线上应用流量千万级别,哪怕一个字节的内存泄漏也会造成堆积,垃圾回收过程中将会耗费更多时间进行对象描述,应用响应缓慢,直到进程内存溢出,应用崩溃。
在V8的垃圾回收机制下,在通常的代码编写中,很少会出现内存泄漏的情况。但是内存泄漏通常产生于无意间,较难排查。尽管内存泄漏的情况不尽相同,但其实质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。通常,造成内存泄漏的原因有如下几个。
- 缓存:无限制增长的数组无限制设置属性和值;
- 队列消费不及时;
- 作用域未释放:任何模块内的私有变量和方法均是永驻内存的。
总结
以Web服务器中的会话实现为例,一般通过内存来存储,但在访问量大的时候会导致老生代中的存活对象骤增,不仅造成清理/整理过程费时,还会造成内存紧张,甚至溢出。