发展历史
- 采用 stop-the-world并引入Incremental增量(2011年) ,在JS运行过程中某个空闲时间阻塞JS线程(时间暂停)来执行垃圾回收。
- 之后引入Parallel 并行 + Concurrent并发(2018年) 来减少JS线程的阻塞时间。
- 为什么对于一个宏观的垃圾回收任务,引入了三种工作方式?
- 原因是V8引擎中的垃圾回收任务被划分为了多个子任务,不同的任务根据其特性采用不同的工作方式!
回收算法
垃圾回收这个宏观任务需要做到以下三点:
-
识别活/死对象
-
回收/重用死对象所占内存
-
碎片整理
V8回收机制为了能够实现这三点做出了如下设计
-
将堆内存划分为了两部分,新生代和老生代,两块内存
-
新生代内存存储声明短暂的对象:执行上下文等
-
老生代内存存储生命漫长的对象:函数声明等
-
-
针对不同内存采用不同的垃圾回收机制:新生代---Mark-compact 老生代---cheney GC
-
Mark-compact(标记整理)算法
-
cheney GC 复制算法
-
前提:根搜索算法(Tracing Collector)
垃圾回收的第一步就是我们要能够识别出活/死对象
markdown
* 活对象:能够访问到(被引用)的对象
* 死对象:与活对象反之
传统方法是引用计数法,而引用计数法存在一个致命问题就是无法对循环引用进行正确的处理,会导致内存溢出问题,而根搜索算法并不会。
根搜索算法中将会维护一个根集合(GC Roots Set),每一个根集中都对应着一系列引用,从根集出发可以找到所有引用,这个根集合在无循环引用时就是树形,有循环引用就是图形。
根节点一般为:
- 所有正在运行的栈上的引用变量
- 所有全局对象/变量
- 所有内置对象。
因此我们可以通过遍历GC Roots Set,并对于每一个树/图继续遍历,就能找到所有可访问的对象了。
老生代回收机制 (Mark-compact 算法)
老生代中存储着生命漫长的变量,而真正的环境中,大多是生命短暂的变量,这些变量的处理才更为复杂(对应新生代回收机制)。在V8中有这样一个假设,就是大多的生死都在新生代中发生。
因为以上假设,采用了Mark-compact算法,实际上这个算法包括两个部分:
-
标记:通过根搜索区分活死/对象,并进行标记
-
整理:将活对象在堆内存中紧凑安置
-
将存活对象移动
-
更新指向他们的引用
-
虽然这种方式的性能并不优秀,但是由于前面所提的假设,V8依然选择这种方式来管理老生代堆内存。
新生代回收机制(cheney GC 复制算法)
这是生死发生最频繁的地方,因此其算法的步骤也更加复杂。
首先需要明确以下几点:
-
这种机制是用空间的消耗换取时间的高效。
-
这是一种空间复制算法,即使用两个空间来实现高效的迁移与更替
进入正题:
-
首先,将新生代堆内存又进行了一次划分,将其分为了两个空间(From Space、To Space),对象一开始都在From Space中
-
然后,从根节点开始进行遍历,将所有的活对象进行复制,并存储在To Space。
-
紧接着清空From Space
-
最后,将From Space 和To Space进行交换
如图:
上述步骤中我重点标注了遍历,是因为这里我们需要注意的是,如何遍历:我们知道这些对象的结构要么是树,要么是图,那么遍历无非就是两种:深度优先和广度优先。
而V8中采用的是广度优先遍历,为什么呢?因为深度优先遍历的方式通常需要递归,递归的消耗不可小觑,栈溢出并不是我们想要看到的。
而广度优先遍历通常可以利用队列来实现,正好我们有一个现成的To Space来充当队列!广度优先算法十分简单,可以参考我的这篇文章!
除次之外,你是否发现了,垃圾回收还有一个目标就是碎片整理,而该算法在清理的同时也进行了整理👍
新老结合
最后,由于程序在不断壮大的过程中可能会出现新生代堆内存不足的情况。
因此,当第二次GC时如果新生代的内存能够幸存下来,那么他们将成为老生代中的一员。
-
这里有个问题就是如何确定是第二次以及去往何地?
-
为了解决这个问题,V8中在我们第一次清理新生代时,为幸存新生代对象追加了一个转发地址,指向老生代的某个地址。这样不仅解决了第几次的问题,也解决了去向问题。
- 不要忘了,这个转发地址在老生代GC时是要更新的喔。
回收工作方式
之前我们在发展历史中提到了其工作方式的发展,接下来我们逐一介绍三种工作方式。
增量 Incremental + 懒清理Lazy Sweeping
增量执行是指在JS运行中过程中,抽出时间去进行垃圾回收的部分工作。
2011年起,V8将在这些小时间段中进行标记任务(识别活死),而在最后进行清理和碎片整理工作,懒清理指的就是,在不需要清理的时候,我们没必要去清理,标记就足够了。
但是,你会意识到一个问题,JS动态执行的过程中,会不断改变内存的情况,那么标记的就不够准确,为了解决这个问题,V8引入了写屏障技术(Write-barrier)来记录引用关系的变化。
此外,要知道整个GC过程的时间并不会减少,只是分散开了。
并行Parallel 和 并发 Concurrent
2018年开始,引入了并行和并发共同结合的回收方式
并行是指主线程与辅助线程同时执行大致相同的任务,在主线程中依然需要stop-the-world方式阻塞主线程。这样的优势就是GC过程的时间减少了。
并发是指在其他辅助线程中执行,而不阻塞主线程,但是同样会遇到之前增量的问题,就是JS会更改堆中的情况。
为了能够更好的利用以上两种方式,V8在新生代堆内存中使用并行机制,使用更短的时间来进行垃圾回收。
而在老生代中,当堆内存的大小超过某个阈值时,启动并发模式来回收内存,但是并发带来的JS更改堆的问题并没有解决,因此还需要之前提到的Write-barriers写屏障技术。
此时并没有开始进行真正的垃圾回收,只是准备工作就绪。一旦并发任务结束或者内存到达极限时,才真正执行回收,但是仍然需要挂起JS主线程,这次挂起时间并不会很长,因为主线程要做的工作就是check一下标记的正确性,然后再并发的清理内存,并不影响主线程继续执行。
参考
-
[Concurrent marking in V8 · V8](v8.dev/blog/concur...