V8是如何进行垃圾回收的

作为一个前端开发者,垃圾回收看起来好像和我们没啥关系,JavaScript是一门自动垃圾回收的语言,不需要开发者去手动管理内存的分配和回收。其实不是这样的,了解垃圾回收机制对我们的开发工作有着很大的帮助,能增强我们在写代码过程中更加合理的去编写代码,避免内存泄漏的情况,让你写出更加健壮和对V8引擎更加友好的代码。

内存的生命周期

在说到具体的回收机制之前,我们先来了解一下程序语言的生命周期可以简单的分为以下三个阶段:

  1. 内存分配:按需分配内存。
  2. 内存的食用:读写已分配的内存。
  3. 内存释放:释放不再需要的内存。。

在JavaScript的内存分配是根据变量的数据类型来进行分配的,内存分为两种类型:

  • 栈内存:因为使用了栈的结构,所以叫栈内存,作为一种简单储存,适合存放生命周期短,占用空间小而且固定的数据,由系统直接管理,进行内存分配和自动释放,所以基本数据类型数据分配在栈存中。

  • 堆内存:可以理解成可以储存任何数据类型的,很大的一个存储空间。堆内存按需进行内存空间的申请,动态分配且不连续,值大小不固定,访问速度比栈内存要慢,无用数据需要JS引擎程序主动去回收。引用类型的数据会同时分配在栈内存和堆内存,其地址存在栈内存,其具体内容存在堆内存中。

不过需要注意的是,全局变量和闭包都是储存在堆内存中。

  • 全局作用域下的声明的所有变量(使用var声明的变量),会成为window对象的属性,所以及时是全局的简单类型的数据,都是存储在堆内存中的。

  • 当一个局部变量被当前函数之外的函数引用时,此时这个变量就不能随着当前的函数的执行完毕而被回收,所以这个变量也就存储在了堆内存中。

V8的垃圾回收算法

在JavaScript中,根据对象存活的周期分为两种类型:

  • 生存时间较短的对象:对象经过一次垃圾回收之后,不在需要被使用的对象,就被释放回收。

  • 生存时间较长的对象:对象经过多次垃圾回收之后,还继续存活。

对于不同存活时间的对象,V8使用分代回收的方法来处理,V8将堆分为两个部分,新生代和老生代,新生代一般有1-8M的容量,老生代容量要大得多了。

old&new.png 对于这两块区域,V8分别使用两个不同的垃圾回收器,达到更高效地进行垃圾回收的目的。

  • 副垃圾回收器,主要负责新生代的垃圾回收。
  • 主垃圾回收器,主要负责老生代的垃圾回收。

新生代

新生代主要存储存活时间较短的对象,新生代将会先被放置在新生代中,而因为大部分对象在内存中存活的周期很短,所以需要一个效率非常高的算法。在新生代中,副垃圾回收器主要使用Scavenge算法进行垃圾回收,Scavenge算法是一种典型的使用空间换时间的算法,在占用空间不大的场景上非常适用该算法将新生代空间的堆内存分为2块同样大小的空间,称为Semispace,我们将处于使用状态的区域叫作From空间,闲置的区域叫作To空间:

new.png

Scavenge算法在每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。

Scavenge算法工作方式比较简单,将From空间中存活的活动对象复制到To空间中,并将这些对象的内存有序的排列起来,然后将From空间中的非活动对象的内存进行释放,完成之后,将From空间和To空间进行互换,这样可以使得新生代中的这两块区域可以重复利用。垃圾回收的过程简述如下:

  • 标记过程阶段,标记活动对象和非活动对象

标记阶段.png

该过程从初始的根对象(window或者global,根对象永远是活对象)的指针开始,向下搜索子节点,子节点被搜索到了,说明该子节点的引用对象可达,并为其进行标记,然后接着递归搜索,直到所有子节点被遍历结束。那么没有被遍历到节点,假设A对象没有被引用,也就没有被标记,也就会被当成没有被任何地方引用,就可以证明这是一个需要被释放内存的对象,可以被垃圾回收器回收。

  • 复制From空间的激活对象到To空间中并进行排序。

a被清除.png

当JS引擎进入垃圾回收时,发现A对象没有被引用时,则表示可以对A进行回收,对象B和C此时处于被引用的激活状态,则要对两者复制到To空间中进行保存。

  • 清除From空间中的未激活对象。

保存.png

对对象A从From空间里进行回收掉。

  • 将From空间和To空间进行角色互换,以便下一次的Scavenge算法垃圾回收。

空间互换.png

此时From未被激活的对象已经被全部清除。

在新生代中,还进一步进行了细分,分为nursery子代和intermediate子代两个区域,一个对象第一次分配内存时会被分配到新生代中的nursery子代,如果进过下一次垃圾回收这个对象还存在新生代中,这时候我们移动到 intermediate 子代,再经过下一次垃圾回收,如果这个对象还在新生代中,副垃圾回收器会将该对象移动到老生代中,这个移动的过程被称为晋升。

老生代

在新生代空间中的对象长期存活之后,如果再继续使用Scavenge算法的话会造成空间资源的浪费,并且重复的复制和交换对象使得效率低下,所以在老生代中,主垃圾回收器采用了Mark-Sweep(标记-清除)和Mark-Compact(标记-整理)算法来进行垃圾回收。

Mark-Sweep

首先是标记过程阶段,标记阶段就是递归遍历堆中的所有对象,递归遍历这组根元素(遍历调用栈),在这个遍历过程中,标记存活对象和未激活的对象,标记完成后就进行清除过程。它和副垃圾回收器的垃圾清除过程不同,这个的清除过程是将不可访问的对象留下的内存空间,添加到空闲链表的过程。未来为新对象分配内存时,可以从空闲链表中进行再分配。而未激活对象在老生代中占用的比例很小,所以效率较高。

Mark-Compact

清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了标记-整理(Mark-Compact)算法,这个标记过程仍然与标记-清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,从而让存活对象占用连续的内存块。主要解决标记清除阶段后,内存空间出现较多内存碎片时,可能导致无法分配大对象,而提前触发垃圾回收的问题。 标记-清除-整理的流程如图所示:

mark.png

垃圾回收引起的性能问题

JavaScript是运行在主线程之上的,为了避免出现JavaScript应用逻辑与垃圾回收操作产生不一致的冲突,垃圾回收正在执行时,会占用JavaScript引擎,正在执行的JavaScript脚本就会暂停,等到垃圾回收结束之后,JavaScript脚本才会继续执行,这个过程称作全停顿(stop-the-world)

在V8的分代式垃圾回收中,由于新生代默认配置的较小,且其中活动对象通常较少,所以即便它是全停顿,影响也不大。但在老生代通常配置较大,且存活对象较多,全堆垃圾回收的标记、清理、整理等动作造成的停顿就会比较严重。

V8垃圾回收的优化策略

为优化停顿带来的影响,V8团队进行多年的改进,向垃圾回收器中增加了其他垃圾回收优化技术:

  • 增量标记(Incremental marking):在老生代中,存活对象多,垃圾回收时间长,全停顿造成的页面停顿或者无响应可能会较多,产生的影响可能会较大,为了减少全停顿的时间,V8对标记进行了优化,垃圾达到一定数量时,将一次停顿进行的标记过程,分成了很多小步。每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成标记,降低每次停顿的时间。

增量回收.png

  • 延迟清除(Lazy sweeping):增量标记是在标记阶段的优化手段,延迟清除是在清理阶段。在增量标记之后,要进行非激活对象的清除,这个时候其实不清理,垃圾回收器剩余的内存也能让JS代码跑起来,所以就延迟了清除或只清理部分垃圾。这就是延迟清除。通过增量标记大大的改善了全停顿的问题,但是新的问题随之而来。标记和代码执行的穿插可能让对象的引用和标记发生错误。应用程序必须通知垃圾收集器关于改变对象图的所有操作。V8使用Dijkstra风格的写屏障(write-barrier)机制来实现通知。形如object.field=value的写操作之后,V8会插入写屏障代码。写屏障机制强制不变黑的对象指向白色对象。这也被称为强三色不变性,保证应用程序不能在垃圾收集器中隐藏活动对象,因此标记结束时的所有白色对象对于应用程序来说都是不可达的,可以安全释放。

  • 并行(Parallel):是指主线程和辅助线程同时执行大致相等的工作量,这也是一种全停顿的一种方法,但是总的停顿时间因为辅助线程的参与而得到减少,因为辅助线程没有运行JavaScript,所以每个辅助线程只需确保它同步对其他线程可能也要访问的任何对象即可。

并行.png

  • 增量(Incremental):增量就是主线程断断续续做一部分GC的工作,而不是不是做整个GC流程中的所有工作,这个实现逻辑比较困难,就是在增量GC工作之间,JavaScript是会执行的,在执行的过程会导致堆的状态发生变化,这有可能导致之前的GC是无效的。增量并不能减少垃圾回收的总时间把垃圾回收的时间给分散了,能够让主线程间断的执行JS交互变得流畅。

  • 并发(Concurrent):并发式GC是指主线程不间断的执行,辅助线程在后台进行GC工作,这种技术的难点是JavaScript堆的状态时刻发生者变化,使GC的工作无效。这种技术的好处是主线程能够自由的执行程序,可以让辅助线程来分担主线程的GC工作,虽然会有和辅助线程同步的一些开销。

并发.png

总结

本文从JS的数据存储、内存分代、垃圾回收算法、垃圾回收带来的性能问题及优化策略等几个方面来讲述了V8是如何进行垃圾回收的,V8垃圾回收机制细节非常复杂,中间还有很多细节有遗漏或是疏忽,感兴趣的小伙伴自行深入。另外,文中要是有错误的地方,期待你在评论区批评指正。

Guess you like

Origin juejin.im/post/7068157060038262791
v8