# 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
命令行参数则用于设置新生代内存空间的大小的。
# 新生代 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 回收。若已经历过了,则将该对象晋升至老生代内存空间。
设置 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),让清理与整理动作也变成增量式的。