V8的GC机制


V8的GC机制

V8对JS使用内存的限制

V8引擎在执行JS的过程中限制了JS可以使用内存的大小。通常在32位系统下,JS可以使用的内存大小约为0.7GB而64位系统下约为1.4GB。当我们一直申请内存,JS所使用内存超过这个限制时就会报错。造成这个问题的主要原因在于JavaScript对象基本上都是通过V8自己的方式来进行分配和管理的。

在V8中,所有的JavaScript对象都是通过堆来进行分配的。 当我们在代码中声明变量并赋值时,所使用对象的内存就分配在堆中。至于V8为何要限制堆的大小,表层原因为V8最初为浏览器而设计,不太可能遇到用大量内存的场景。对于网页来说,V8的限制值已经绰绰有余。

深层原因是V8的垃圾回收机制的限制。**按官方的说法,以1.5 GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。这是垃圾回收中引起JavaScript线程暂停执行的时间,在这样的时间花销下,应用的性能和响应能力都会直线下降。 **这样的情况不仅仅后端服务无法接受,前端浏览器也无法接受。因此,在当时的考虑下直接限制堆内存大小是一个好的选择。

当然,这个限制也不是不能打开,V8依然提供了选项让我们使用更多的内存。Node在启动时可以传递--max-old-space-size--max-new-space-size来调整内存限制的大小,示例如下:

node --max-old-space-size=1700 test.js # 单位为MB
node --max-new-space-size=1024 test.js # 单位为KB上述参数在V8初始化时生效,一旦生效就不能再动态改变。

如果遇到Node无法分配足够内存给JavaScript对象的情况,可以用这个办法来放宽V8默认的内存限制,避免在执行过程中稍微多用了一些内存就轻易崩溃。

GC策略

**V8的垃圾回收策略主要基于分代式垃圾回收机制。**在自动垃圾回收的演变过程中,人们发现没有一种垃圾回收算法能够胜任所有的场景。因为在实际的应用中,对象的生存周期长短不一,不同的算法只能针对特定情况具有最好的效果。为此,统计学在垃圾回收算法的发展中产生了较大的作用,现代的垃圾回收算法中按对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同分代的内存施以更高效的算法。

分代式垃圾回收

在V8中JavaScript对象被分为新生代和老生代。新生代通常是一些生命周期较短的对象,而老生代则通常是新生代经过一轮GC后仍然存活的对象晋升而来。

前面我们提及的--max-old-space-size命令行参数可以用于设置老生代内存空间的最大值,--max-new-space-size命令行参数则用于设置新生代内存空间的大小的。

V8中GC管理的内存

新生代GC

在分代的基础上,V8新生代中的对象主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用了Cheney算法,该算法由C. J. Cheney于1970年首次发表在ACM论文上。

Scavange算法将新生代堆分为两部分,分别叫from-space和to-space,工作方式也很简单,就是将from-space中存活的活动对象复制到to-space中,并将这些对象的内存有序的排列起来,然后将from-space中的非活动对象的内存进行释放,完成之后,将from space 和to space进行互换,这样可以使得新生代中的这两块区域可以重复利用。

新生代内存空间

Scavenge的缺点是只能使用堆内存中的一半,这是由划分空间和复制机制所决定的。但Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的表现。它是典型的以空间换时间的算法。

当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。对象从新生代中移动到老生代中的过程称为晋升。

对象晋升的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过限制(25%)。

在默认情况下,V8的对象分配主要集中在From空间中。对象从From空间复制到To空间时,会检查它的内存地址。以判断这个对象是否已经经历了一次Scavenge回收。若已经历过了,则将该对象晋升至老生代内存空间。

uplevel

设置25%这个限制值的原因是当这次Scavenge回收完成后,这个To空间将变成From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

对象晋升后,将会在老生代的内存空间中存活,由老生代的GC算法进行处理。

由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模地应用到所有的垃圾回收中。但可以发现,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。

老生代GC

对象晋升后,将会在老生代空间中作为存活周期较长的对象来对待,接受新的回收算法处理。对于老生代中的对象,由于存活对象占较大比重,再采用Scavenge的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。这两个问题导致应对生命周期较长的对象时Scavenge会显得捉襟见肘。为此,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。

Mark-Sweep

Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。与Scavenge复制活着的对象不同,Mark-Sweep在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge中只复制活着的对象,而Mark-Sweep只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。

Mark-Sweep算法最大的问题就是在进行一次标记清理回收后,会产生内存碎片,内存空间会出现不连续的状态。这种内存碎片会对后续内存分配造成影响。例如要分配一个100MB大小的对象,此时总体空闲的内存空间大小为150MB但是并没有一个连续的100MB大小的内存空间,这就会导致内存分配失败。这种情况会导致提前触发GC是程序停顿,而此次的GC是不必要的。为了解决Mark-Sweep的问题,所以Mark-Compact被提出。

Mark-Compact

Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。这样就可以减少内存碎片。总体上的思路就是将小碎片移动、合并成一个大块的内存空间。

这里将Mark-Sweep和Mark-Compact结合着介绍不仅仅是因为两种策略是递进关系,在V8的回收策略中两者是结合使用的。

GC停顿处理

为了避免出现JavaScript应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。

在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但V8的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善。

为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”就让JavaScript应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。

V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。