V8 引擎的垃圾回收机制
在过去几年中,V8 垃圾收集器(GC)发生了很大的变化。Orinoco (V8垃圾回收代号)之前采用的是全停顿(stop-the-world)1的回收方式,现已将它转换成 一个大多数并行且并发的、具有增量回收的收集方式。
以下 GC 均表示 垃圾回收 。 garbage collector
任何 GC 都必须定期执行以下基本任务:
- 识别(Identify) 出 存活/死亡 对象
- 回收(Recycle) 死亡对象占用的内存
- 整理 (compact) 回收后的内存碎片
执行这些任务的一种直接的方法是 全停顿(stop-the-world)
,也就是暂停 JavaScript 执行并在主线程上按顺序执行这些任务。但这会导致主线程卡顿,在浏览器中 JavaScript 与 UI 都在共用主线程,后果不堪设想。
V8垃圾回收策略
自动垃圾回收有很多算法,由于不同对象的生存周期不同,所以无法只用一种回收策略来解决问题,这样效率会很低。
V8 垃圾回收策略主要基于 分代式垃圾回收机制 。
它将内存分为两个生代:新生代(young generation)和老生代(old generation)。
- 新生代:对象的存活时间较短。新生对象或只经过一次垃圾回收的对象。
- 老生代:对象存活时间较长。经历过一次或多次垃圾回收的对象。
分别对新老生代采用不同的垃圾回收算法来提高效率。
V8的内存分配
在默认设置下,如果一直分配内存,在64位系统和 32位系统下分别只能使用约 1.4G 和约 0.7G 的大小。
类型\系统位数 | 64位 | 32位 |
---|---|---|
新生代 | 32M | 16M |
老生代 | 1.4G | 0.7G |
新生代将内存一分为二,分为 **托儿所(nursery) **子代 和 中间(intermediate) 子代。对象最开始都会先被分配到 托儿所(nursery) ,如果它们在下一次 GC 中存活下来,就会被分配到 中间(intermediate) 子代。接着,如果它们在另一次的 GC 中存活下来,会被移到老生代,这个过程也叫晋升,下面会详细介绍。
注意!若新生代内存空间不够,直接分配到老生代。
在垃圾回收中有一个重要的术语:“世代假设”。这基本上表明大多数对象都是年轻死去。换句话说,从 GC 的角度来看,大多数对象在被分配之后,都是不能够再去访问(可能作用域变化),都是垃圾。这不仅适用于 V8 和 JavaScript,也适用于大多数动态语言。
V8 的分代内存结构就是利用对象生命周期这一事实。V8 的 GC 是一个 整理/移动 执行的 GC,这意味着它会复制垃圾回收中存活的对象。这似乎有违直觉:在 GC 时复制对象很昂贵的2。但我们知道,根据 “世代假设”,只有很小比例的对象能够在 GC 中存活下来,其他都是垃圾对象。这意味着我们只支付与存活对象数量成比例的复制成本,而不是分配对象总数量。
接下来分别介绍新老生代垃圾回收算法。
老生代 GC (Mark-Compact)
也称为 Major GC ,从整个堆中回收垃圾。下面的 新生代 GC(也称为 Minor GC) 只发生在新生代内存。
1. 标记(Making)
GC 可依据该对象是否可访问来判断是否存活。也就是说,会保留在当前运行时可访问到的对象,并且可以回收任何无法访问的对象。
**标记(Making)是找到可访问对象的过程。**如下:
GC从一组已知的对象指针开始,称为根集。这包括执行堆栈和全局对象。然后,它跟踪每个指向 javascript 对象的指针,并将该对象标记为可访问。GC 跟踪该对象中的每个指针,并递归地继续这个过程,直到找到并标记了运行时中可以访问的每个对象。
将A、C、E标记为存活对象:
2. 清除(Sweeping)
清除(Sweeping)是清除 死亡对象(未标记对象) 的内存空间,并将空间添加到称为空闲列表的数据结构中的过程。
清除完成后,内存空间会出现不连续的状态
。这种 内存碎片
会对后续的内存分配造成问题。
如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。
将未标记的对象B、D、F清除:
3. 整理(Compaction)
为了解决 Sweep 的内存碎片问题,**整理(Compaction)**被提出来了。
我们将存活的对象(标记的对象)向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存。
注意! 复制存活对象的 GC 的一个潜在弱点是,当我们分配很多长久存活的对象时,复制这些对象的成本很高。所以我们只选择整理一些高度零散的内存,对于其他内存只会执行清除(sweeping),而不复制移动存活的对象。
新生代 GC (Scavenger)
老生代的 GC 可以有效地从整个堆中垃圾回收,但是“世代假设”告诉我们新分配的对象很有必要进行垃圾回收。
新生代采用 Scavenge 垃圾回收算法,在算法实现时主要采用 Cheney 算法。Cheney 算法将内存一分为二,叫做 semispace
,一块处于使用状态,一块处于闲置状态。处于使用状态的 semispace
称为 From空间 ,处于闲置状态的semispace称为 To空间 。
第一步,在进行 GC 过程中,将 From空间 所有存活的对象移动到 To空间 连续的内存中,并会添加标记。这样做的好处是,可以删除内存碎片。
第二步, 交换 From 空间 和 To 空间。在下一次 GC 时,如果标记的对象依然存活,则将其移动到 老生代 。
最后一步,更新移动到 老生代 的存活对象的引用指针。
在 新生代GC 时,我们实际上交错执行标记
、移动
、指针更新
三个步骤。
Orinoco
衡量垃圾回收性能的一个重要指标是主线程在执行 GC 时暂停的时间。
对于传统的 全停顿(stop-the-world)
垃圾回收方式,在 GC 上花费的时间直接影响了用户体验(画面质量差、渲染和延迟差)。
Orinoco是 V8 GC 的代号,它利用最新最好的并行、增量和并发技术进行垃圾回收,以释放主线程。这里有一些术语在GC上下文中具有特定的含义,值得详细定义它们。
Parallel (并行)
并行是指主线程和辅助线程同时执行大致相等的工作量。
这仍然是一种“全停顿”的方法,但是总的暂停时间现在被参与的线程数(加上一些同步开销)所除。这是三种技术中最简单的一种。由于辅助线程没有运行任何 JavaScript,因此每个辅助线程只需确保它同步对其他线程可能也要访问的任何对象。
Incremental(增量)
增量是指主线程间歇性地执行少量工作。我们不需要在增量暂停中完成整个 GC,只需进行 GC 全部工作中一小部分。这更困难,因为 JavaScript 在每个增量工作段之间执行,这意味着堆的状态已经更改,这可能会使以前增量完成的工作失效。正如您从图中看到的,这并不能减少花费在主线程上的时间(实际上,它通常会稍微增加一些),而是随着时间的推移将其分散开来。这仍然是一种很好的技术,可以解决我们的一个原始问题:主线程延迟。通过允许 JavaScript 间歇运行,同时继续 GC 任务,应用程序仍然可以响应用户输入并在动画方面取得进展。
Concurrent(并发)
并发是指主线程不断地执行 JavaScript,而辅助线程在后台完成 GC 工作。这是三种技术中最困难的一种:JavaScript 堆中的任何内容都可以随时更改,从而使我们以前所做的工作失效。除此之外,现在还有共同读/写需要担心,因为辅助线程和主线程可能同时读取或修改相同的对象。这里的优势在于,主线程完全可以自由执行JavaScript——尽管由于与辅助线程的某些同步,开销很小。
如果你想更深入了解 V8引擎的垃圾回收机制,请查阅官网。
参考
- Trash talk: the Orinoco garbage collector by V8官网
- 深入浅出NodeJs by 朴灵
- 聊聊V8引擎的垃圾回收
- 深入理解V8的垃圾回收原理
- 全停顿的目的,是为了解决应用逻辑与垃圾回收器看到的情况不一致的问题。↩
- 复制或移动对象很占内存。↩