面试官:讲一下 V8 垃圾回收机制,越详细越好

2,281 阅读11分钟

💡 V8 的垃圾回收策略主要基于分代式垃圾回收机制

V8 主要将内存分为新生代和老生代两代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或者常驻内存的对象。

为什么要分为新老两个生代呢?

在垃圾回收(GC)中有一个重要的术语:“代际假说”;代际假说表明很多对象在内存中存在的时间很短。从垃圾回收的角度来看,很多对象一经分配,其内存空间很快就变成了不可访问的。这个假说不仅仅适用于 V8 和 JavaScript,同样适用于大多数的动态语言。

代际假说表明大多数对象的生命周期非常短

V8 分代的设计主要是为了利用对象生命周期长短不一的事实。

Scavenge 算法 (Minor GC)

在分代的基础上,新生代中的对象主要通过 Scavenge 算法进行垃圾回收。

💡 Scavenge 采用复制的方式实现垃圾回收

它将堆内存一分为二,每份空间称为 semi-space。在这两个 semi-space 中,只有一个处于使用状态,称为 From-Space。另一个处于闲置状态,称为 To-Space。分配对象时,会先在 From-Space 中分配。当开始垃圾回收时,会检查 From-Space 中的存活对象,这些存活对象将被复制到 To-Space 中。完成复制后,From-Space 和 To-Space 发生交换。

简而言之,新生代的 GC 就是将存活对象在两个 semi-space 之间复制。

存活对象从 From-Space 复制到 To-Space

如果仅仅让存活对象在 semi-space 之间一直复制,那么新生代的内存空间很快就会被耗尽。所以当一个对象经过复制后依然存活时,它会被认为是生命周期较长的对象。这种生命周期较长的对象随后会被移动到老生代中,采用新的算法管理。对象从新生代中移动到老生代中的过程称为晋升。

对象晋升的条件主要有两个(满足其一即可晋升):

  1. 对象经历过 GC
  2. To-Space 的内存使用比超过 25%

经历过一次 GC 后的存活对象完成晋升

当存活对象没有经历过 GC 且 To-Space 使用比小于 25% 时,对象将被复制到 To-Space。否则对象晋升,从新生代移到老生代中。

设置 25% 这个阈值是因为:当这次 GC 完成后,To-Space 将变成 From-Space,接下来的内存分配将在这个空间中进行,如果占比过高,会影响后续的内存分配。

对象晋升后,将会在老生代空间中作为存活周期较长的对象来对待,接受新的回收算法处理。

Full Mark-Compact 算法 (Major GC)

对于老生代中的对象再采用 Scavenge 算法进行 GC 会有两个问题:

  1. 老生代对象存活时间较长,导致存活对象较多,复制存活对象的效率会很低
  2. 采用两个 semi-space 的 Scavenge 算法,导致总有一半的空间被浪费

这两个问题导致应对生命周期较长的对象时, Scavenge 算法显得捉襟见肘。为此,V8 在老生代中主要采用了 Full Mark-Compact 进行垃圾回收。

Full Mark-Compact 主要有三个阶段:标记,清除和整理。

Full Mark-Compact 分为标记(marking),清除(sweeping)和整理(compacting)三个阶段。与 Scavenge 相比,Full Mark-Compact 并不将内存空间划分为两半,所以不存在浪费一半空间的行为。与 Scavenge 复制活着的对象不同,Full Mark-Compact 主要清理死亡对象。三个阶段具体细节如下:

标记阶段(marking)

确定哪些对象可以被回收是 GC 中重要的一步。GC 通过可达性(reachability)来确定对象是否存活。这意味着如果对象在运行时是可达的,那么它应该在内存中保留,如果对象是不可达的,那么它就要被回收。

标记阶段就是找到可达对象的一个过程。GC 从一组对象的指针开始,我们将其称之为根集(root set),其中包括了执行栈和全局对象。然后 GC 会跟踪每一个指向 JS 对象的指针,并将对象标记为可达,同时跟踪此对象中每个属性的指针并标记为可达。这个过程会递归地执行,直到标记到运行时每一个可达对象。

清除阶段(sweeping)

清除阶段就是将死亡对象占用的内存空间添加到一个叫空闲列表(free-list)的数据结构中。一旦标记完成,垃圾回收器会找到不可达对象的内存空间,并将其添加到空闲列表中。空闲列表中用大小来区分内存块,这是为了以后分配内存时,可以快速找到大小合适的内存空间并分配给新的对象。

整理阶段(compacting)

GC 通过一种叫做碎片启发式(fragmentation heuristic)的算法来整理内存页,可以将整理阶段理解为老式 PC 上的磁盘整理。碎片启发式算法是怎么做的呢?它将存活对象复制到当前没有被整理的其他内存页中(即被添加到空闲列表的内存页)。如此,可以利用高度小而分散的内存空间。

GC 时,复制存活对象到没有被整理的其他内存页中有一个潜在的缺点:当要分配内存空间给很多常驻内存的对象时,复制这些对象会带来很高的成本。所以我们只选择整理内存中高度分散的内存页,对其他内存页我们只进行清除死亡对象而不是复制存活对象。

💡 可以看出:Scavenge 只复制活着的对象,而 Full Mark-Compact 只清理死亡对象(除非当前内存页高度分散)

活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收算法能高效运作的原因。

Orinoco

上述算法和优化在很多 GC 相关的文献或着具有 GC 的编程语言中都很常见,但是现在有更先进的垃圾回收机制。测量 GC 所花费时间的一个重要指标就是 GC 时主线程挂起的时间。对于传统的全停顿(stop-the-world)垃圾回收器来说,GC 所花费的时间可以直接简单相加。但这种垃圾回收的方式影响用户体验:会导致页面卡顿,渲染延迟等一系列问题。

Orinoco 是 V8 垃圾回收项目的代号,它利用最先进的垃圾回收技术来降低主线程挂起的时间, 比如:并行(parallel)垃圾回收,增量(incremental)垃圾回收和并发(concurrent)垃圾回收。

并行 GC(Parallel)

并行是主线程和协助线程同时执行同样的工作,这仍然是一种全停顿的垃圾回收方式。但是 GC 所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。这是三种技术中最简单的垃圾回收方式。因为没有 JS 的执行,只要确保最多只有一个线程在访问对象就行。

主线程和协助线程同在一时间做同样的任务

增量 GC(Incremental)

增量式垃圾回收是主线程间歇性的去做少量的垃圾回收工作。我们不会在增量 GC 的时候执行完整个垃圾回收,而只是完成整个垃圾回收过程中的一小部分工作。做这样的工作是极其困难的,因为 JS 也在增量 GC 的时候执行,这意味着堆的状态已经发生了变化,这有可能导致之前的增量回收工作完全无效。从图中可以看出并没有减少主线程暂停的时间(事实上,通常会略微增加)。但这仍然是解决问题的的好方法,通过间歇性执行 JS,同时也间歇性的增量 GC,主线程仍然可以及时响应用户输入或者执行动画。

垃圾回收任务交错的进入主线程执行

并发 GC(Concurrent)

并发是主线程一直执行 JS,而辅助线程在后台 GC。这是这三种技术中最难的一种,堆里面的内容随时都有可能发生变化,从而使之前做的工作完全无效。最棘手的是,存在 I/O 竞争,主线程和辅助线程极有可能在同一时间去更改同一个对象。但这种技术的优势也非常明显,主线程不会被挂起,JS 可以一直执行 。尽管为了保证同一对象在同一时间只有一个线程在使用会带来一些同步开销。

垃圾回收任务完全发生在后台,主线程可以自由的执行JavaScript

V8 使用的几种垃圾回收机制

Scavenge

V8 在新生代 GC 中使用并行清理,每个协助线程会帮忙将所有的存活对象都移动到 To-Space。因为不同的线程可能通过不同的路径找到相同的对象,并尝试将这个对象移动到 To-Space,所以线程必须确保原子化的读、写、比较和交换操作。无论哪个线程成功移动对象到 To-Space,都必须更新这个对象的指针,并且在 From-Space 留下转发到 To-Space 的地址,以便于其他线程可以找到该存活对象更新后的地址。为了快速给存活对象分配 To-Space 的内存,线程会使用为每个线程分配的局部缓冲区。

并行清理在主线程和多个协助线程之间分配清理任务,所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)

Full Mark-Compact 主垃圾回收器

V8 中的 Major GC 主要使用并发标记,堆的内存分配接近极限时将启动并发标记。这时每个辅助线程都会被分配一定的对象指针去追踪它们的引用。并发标记在后台进行。

主垃圾回收器并发的去标记和清除对象,并行的去整理内存和更新存活对象的指针

当并发标记完成或者内存分配到达极限的时候,主线程会执行最终的快速标记步骤。在这个阶段主线程会被暂停,这段时间也就是主垃圾回收器执行的所有时间。这个阶段主线程会再一次的扫描根集以确保所有的对象都完成了标记,然后辅助线程就会去做更新指针和整理内存的工作。并非所有的内存页都会被整理,之前提到的加入到空闲列表的内存页就不会被整理。在暂停的时候主线程会启动并发清理的任务,这些任务都是并发执行的,并不会影响并行内存页的整理工作和 JS 的执行。

空闲时垃圾回收器

JS 无法直接访问垃圾回收器,这是 V8 实现所决定的。但是 V8 提供了一种机制让嵌入 V8 的环境去触发垃圾回收。垃圾回收器会发布一些 “空闲时任务(Idle Tasks)”。像 Chrome 这些嵌入了 V8 的环境会有一些空闲时间的概念,比如:在 Chrome 中,以每秒 60 帧的速度去执行一些动画,浏览器大约有 16.6 毫秒的时间去渲染动画的每一帧,如果动画提前完成,那么 Chrome 在下一帧之前的空闲时间可以运行垃圾回收器发布的空闲时任务。

空闲时垃圾回收器,利用主线程上的空闲时间主动的去执行垃圾回收工作

思考

大部分 JavaScript 开发者不需要考虑 GC,但了解一些 GC 的内部原理,可以帮助了解内存的使用情况,以及采取合适的编程范式。比如:从 V8 堆内存的分代结构和垃圾回收器的角度来看,创建生命周期较短的对象的成本是非常低的,但是对于生命周期较长的对象来说成本比较高。

引用资料 & 延伸阅读

  1. V8 trash talk 以及 译文,推荐看 演讲视频
  2. 《深入浅出 Node.js》—— 朴灵