js与java一样,由垃圾回收机制来进行自动内存管理,这使得开发者不需要像C/C++程序员那样在编写代码的过程中时刻关注内存的分配和释放问题。
V8的内存限制
在一般的后端开发语言中,在基本的内存使用上没有什么限制,然而在Node中通过js使用内存时就会发现只能使用部分内存(64位系统下约为1.4 GB,32位系统下约为0.7GB)。在这样的限制下,将会导致Node无法直接操作大内存对象,比如无法将一个2GB的文件读入内存中进行字符串分析处理。
造成这个问题的主要原因在于Node基于V8构建,所以在Node中使用的js对象基本上都是通过V8自己的方式来进行分配和管理的。
V8的对象分配
在V8中,所有的js对象都是通过堆来进行分配的。Node提供了V8中内存使用量的查看方式:
其中,heapTotal和heapUsed是V8的堆内存使用情况,heapTotal是已申请到的堆内存, heapUsed是当前使用的量。
下图为V8的堆示意图:
当我们在代码中声明变量并赋值时,所使用对象的内存就分配在堆中,如果已申请的堆空闲内存不够分配新的对象,将继续申请堆内存,直到堆的大小超过V8的限制为止。
至于V8为何要限制堆的大小,深层原因是V8的垃圾回收机制的限制。按官方的说法,以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起js线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。因此,权衡利弊直接限制堆内存是一个好的选择。
当然,V8依然提供了选项来让我们使用更多的内存,Node在启动时可以传递--max-old-space-size或--max-new-space-size来调整内存限制的大小
node --max-old-space-size=1700 xx.js // 单位为MB,调整老生代这部分的内存
// 或者
node --max-new-space-size=1024 xx.js // 单位为kB,调整新生代这部分的内存
V8的垃圾回收机制
在V8中,主要将内存分为新生代和老生代两代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。
下图为V8的分代示意图:
V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。--max-old-space-size命令行参数可以用于设置老生代内存空间的最大值, --max-new-space-size命令行参数则用于设置新生代内存空间的大小的。
对于新生代内存,内存最大值在64位系统和32位系统上分别为32MB和16MB。新生代中的对象主要通过Scavenge算法进行垃圾回收。主要采用了Cheney算法,这是一个采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分称为semispace,在这两个空间中,只有一个处于使用中(称为From空间),另一个处于闲置状态(称为To空间)。当我们分配对象时,先是在From空间中进行分配,在开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生兑换。
Scavenge的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的,但Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。
Scavenge是典型的牺牲空间换时间的算法,所以非常适合应用在新生代中,因为新生代中对象的生命周期较短。
当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象,这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代移动到老生代中的过程称为晋升。
在单纯的Scavenge过程中,From空间中的存活对象会被复҃制到To空间中去,然后对From空间和To空间进行角色对换(又称翻转)。但在分代式垃圾回收的前提下,From空间中的存活对象在在复҃到To空间之前需要进行检查。在一定条件下,需要将存活周期长的对象移动到老生代中, 也就是完成对象晋升。
对象晋升情况:
- 对象是否经历过Scavenge回收;
- To空间的内存占用比超过25%
默认情况下,V8的对象分配主˞要集中在From空间中。对象从From空间中复҃到To空间时, 会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间复҃到老生代空间中,如果没有,则复҃到To空间中。如下图:
V8在老生代中主˞要采用了Mark-Sweep֖(标记清除)和Mark-Compact(标记整理)相结合的方式进行垃圾回收。
Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除对象中,只清除没有被标记的对象。可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象。
如图:黑色部分标记为死亡的对象。
这种方法最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
完成移动后,就可以直接清除最右边的存活对象后面的内存区۪域完成回收。
由于Mark-Compact需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8˞要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact。
增量标记(Incremental Marking)
由于JS的单线程机制,V8在执行垃圾回收时都会将应用逻辑暂停下来,如果V8老生代配置较大,且存活对象较多,全堆垃圾回收(full垃圾回收)的标记、清理、整理等动作造成的影响就会很大,为了降低回收垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小的部分,每做完一部分就让JavaScript应用逻辑执行一小会, 垃圾回收与应用逻辑交替执行直到标记阶段完成。
如下图所示:
V8在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。
V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。
参考资料: