V8 的垃圾回收策略是基于分代式垃圾回收机制。在所有垃圾回收的算法中,没有一种能胜任所有的场景。因为在我们的实际应用中,对象的生存周期长短不一,不同的算法只能针对特定的情况具有最好的效果。
因此目前的垃圾回收算法一般是按照对象的存活时间将内存进行分代,然后对不同的内存代采用不同的垃圾回收算法。
V8 的内存分代
在 V8 中主要将内存分为新生代和老生代,新生代中的对象存活时间较短,老生代中的对象存活时间较长或常驻内存,新生代中的对象有机会晋升到老生代。
V8 堆整体的大小就是新生代的内存空间加上老生代的内存空间。在默认情况下,如果一直分配内存,在 64 位操作系统和 32 位操作系统下分别只能使用约 1.4 GB 和 0.7 GB 的大小。
对于新生代而言,在 64 位和 32 位操作系统下内存的最大值为 32MB 和 16MB;对于老生代而言,在 64 位和 32 位操作系统下内存的最大值为 1400MB 和 700MB
V8 的主要垃圾回收算法
根据不同的分代,V8 在新生代中使用 Scavenge 算法进行垃圾回收,而在老生代中使用 Mark-Sweep 和 Mark-Compact 进行垃圾回收。
Scavenge 算法
Scavenge 算法是新生代中的对象进行垃圾回收的算法,其主要采用了 Cheney 算法,算法的核心思想是:
将堆一分为二,每一部分空间称为 semispace,然后采用复制的方式进行垃圾回收。在这两个 semispace 中,只有一个处于使用中,另一个处于闲置状态。处于使用中的空间称为 From 空间,处于闲置中的空间称为 To 空间。当我们在分配对象的时候,首先在 From 空间中进行分配。当进行垃圾回收的时候,检查 From 空间中的存活对象,将存活对象复制到 To 空间,而非存活对象的空间将会被释放。完成复制之后,From 空间变为 To 空间, To 空间变为 From 空间,即进行角色互换。
Scavenge 算法的优点是时间效率较高,缺点是只能利用一半的内存。由于该算法只复制存活的对象,因此对于生存周期较短的场景(新生代),存活的对象较少,非常适合应用该算法进行垃圾回收。
当一个对象在新生代中经过多次复制依然存活,它将被认为是生存周期较长的对象。这些生命周期较长的对象会被移动到老生代中,采用新的算法进行管理。对象从新生代移动到老生代称为晋升。
因此我们在将 From 空间的对象移动到 To 空间之前需要进行检查,在一定条件下需要将存活周期较长的对象移动到老生代中,也就是完成对象晋升。
对象晋升的主要条件有两个:
对象是否经历过 Scavenge 回收
在默认情况下, V8 的对象分配主要集中在 From 空间,对象从 From 复制到 To 空间的时候,会检查它的内存地址来判断该对象是否经历过一次 Scavenge 回收。如果经历过,会将该对象复制到老生代空间中;否则复制到 To 空间。
To 空间的内存占比超过 25%
当要从 From 空间复制一个对象到 To 空间的时候,如果 To 空间已经使用了超过 25%,则这个对象直接晋升到老生代空间中。
Mark-Sweep 和 Mark-Compact
对于老生代中的对象,由于存活对象占比较大,再采用 Scavenge 算法会造成两个问题:
- 存活对象较多,复制存活对象的效率将会很低
- 浪费一半的空间
因此 V8 在老生代中主要采用 Mark-Sweep 和 Mark-Compact 相结合的方法进行垃圾回收。
Mark-Sweep 实际上就是标记清除的意思,它分为标记和清除两个阶段。该算法会遍历堆中的所有对象,并标记存活的对象,在随后的清除过程中,清除未被标记的对象。可以看出 Scavenge 中只复制活着的对象,而 Mark-Sweep 中只清理死亡的对象。活对象在新生代中占较少一部分,死亡对象在老生代中占较少一部分,这是两种回收方式能高效处理的原因。
如图所示,黑色部分标记为死亡的对象。
Mark-Sweep 算法最大的问题是,在进行一次垃圾回收之后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题。例如我们要给一个大对象分配内存的时候,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收机制,而这次回收是没有必要的。
因此,为了 Mark-Sweep 解决内存碎片的问题,Mark-Compact 算法被提出来了。Mark-Compact 是标记整理的意思,是在 Mark-Sweep 的基础上演变而来的。Mark-Compact 在标记对象为死亡之后,在整理的过程中,将活的对象往一端移动,移动完成之后直接清理掉边界外的内存。
由于 Mark-Compact 需要移动对象,因此它的执行效率不可能很快,所以在取舍上, V8 主要采用 Mark-Sweep 算法,在空间不足以给从新生代中晋升过来的对象分配空间的时候才使用 Mark-Compact。
Incremental Marking
为了避免出现 JS 应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的三种基本算法都需要将应用逻辑暂停下来,待执行完回收之后再恢复应用程序的执行,这被称为”全停顿“。
在 V8 的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置较小,且其中存活的对象较少,所以即使它是全停顿也影响不大。但是在老生代中,空间配置较大,存活对象较多,全堆垃圾回收的标记、清理、整理等操作所造成的停顿就会较大,需要设法改善。
为了降低全堆垃圾回收带来的停顿时间,V8 采用了增量标记,也就是将原本需要一口气完成标记的过程拆分为许多小步进行,每做完一小步就让 JS 应用逻辑执行一小会,标记与应用程序交替执行直到标记完成。