V8 中的垃圾回收

· 1434 words · 8 min

我们知道 JS 中原始类型的数据是存在栈空间中的,引用类型的数据存储在堆空间中,通过这种方式解决了数据的内存分配问题。 不过某些数据在使用过后就不再需要了,这种数据被称为垃圾数据,我们需要对这种垃圾数据进行回收,以释放有限的空间。

不同语言的垃圾回收策略

垃圾回收可分为自动回收和手动回收。例如 C/C++ 就是采用手动回收策略,分配内存和回收内存都是由代码控制的, 如果内存没有被及时回收掉,可能会造成内存泄漏(溢出)。JS、Java、Python 等采用的是自动回收策略,由垃圾回收器来释放内存。

栈内存如何回收

有一个记录当前执行状态的指针(ESP),记录了当前调用栈中正在执行的上下文。当一个函数执行完成之后,JS 就会将 ESP 进行下移操作, 这个下移操作就是来销毁保存在栈中的执行上下文的。当有新的执行上下文需要入栈时,会直接进行覆盖。

堆内存如何回收

堆内存的回收需要引入代际假说(The Generational Hypothesis)的内容。代际假说有以下两个特点:

V8 中会把堆分为新生代和老生代两个区域。新生代(1~8M 大小)存放的是生存时间短的对象,老生代中存放的是生存时间久的对象。 对于两块区域,V8 使用不同的垃圾回收器,以便更高效的回收:

回收流程

不论什么类型的垃圾回收器,执行流程都是相同的。

副垃圾回收器

负责新生代垃圾回收,区域虽然不大,但是垃圾回收会进行的很频繁。新生代采用 Scavenge 算法(清道夫)进行处理, 该算法将新生代空间划分为两个区域,一般是对象区域,一半是空闲区域。

V8 的堆空间

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作:

由于新生代才用的是 Scavenge 策略,每次清理时需要将存活对象复制到空闲区域。如果新生代空间设置的太大, 那么每次清理的时间就会过长,因此为了执行效率,新生代空间区域会被设置的很小。也正是因为空间小,很容易被装满, 为了解决这个问题,V8 采用了对象晋升策略,即两次回收过后仍然存活的对象,会被移入到老生代区域中。

主垃圾回收器

老生区中的对象的特点是:占用空间大、存活时间长。老生区中的垃圾回收采用的是标记-清除(Mark-Sweep)算法。

首先是标记过程阶段,从一组根元素开始,递归遍历这组根元素,遍历过程中,堆地址存在于调用栈中的为活动对象,不存在的为垃圾数据。

标记过程

接下来的就是垃圾的清除过程:

清除过程

连续的多次进行垃圾回收之后,也会产生不连续的内存碎片,于是产生了标记-整理(Mark-Compact)算法, 标记过程是一样的,但是后续步骤不是对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。

整理过程

全停顿

JS 是运行在主线程中的,一旦执行垃圾回收算法,就需要将 JS 的执行停下来,待垃圾回收完成之后 JS 再恢复执行。 这种行为叫做全停顿(Stop-The-World)。

全停顿

V8 的新生代垃圾回收中,空间小,存活对象少,因此全停顿影响不大。但在老生代中,回收过程占用主线时间太久, 若页面此时正在执行 JS 动画,就会造成页面的卡顿。

为了降低老生代的垃圾回收造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JS 执行交替进行, 直到标记阶段完成。这个算法称为增量标记(Incremental Marking)算法。

增量标记算法

有了增量标记之后,垃圾回收可以穿插在 JS 任务中间执行,这样页面就不会感到卡顿了。

From 极客时间