引言
与某些语言不同,JavaScript 采用隐式垃圾回收机制。这意味着 JavaScript 无法通过 API 直接触发垃圾回收操作,由JavaScript引擎内部完成垃圾回收工作。此外,由于GC通常运行在主线程中,会阻塞JS执行,因此对GC性能要求非常高。当主线程未能在 16.66 毫秒内完成渲染帧时,就会导致丢帧现象,表现为页面卡顿、渲染延迟。因此,对于现代浏览器引擎来说,垃圾回收(GC)的性能优化显得尤为重要。本文将介绍 V8 所采用的垃圾回收器 Orinoco 及其相关优化技术。
术语表
- V8:JS引擎,被广泛运用在 Chrome 和 Edge 浏览器中。
- 页(Page):V8 将其堆内存划分为固定大小的块,称为页。
- 根(Root):GC的起点,包括执行上下文、全局对象和DOM树根节点等。
- Orinoco:V8 的现代化垃圾回收器,采用并行、增量、并发技术优化 GC 性能。
- 空闲列表(free-list):记录老生代中内存碎片的位置,供后续分配复用。
- 空闲时间:主线程提前完成渲染帧剩下的时间。
传统垃圾回收机制
引用计数法
传统JS引擎采用引用计数法,引用计数法通过计数对象被引用的情况来决定是否回收。它的基本思想是:每个对象维护一个引用计数器,记录有多少个引用指向它;当引用计数归零时,说明该对象不再被使用,可以被释放。
let a = {} // 计数1
let o = {}
o.a = a // 被引用,计数2
a = null // 解除引用,计数1
o = null // 由于o引用了a,所以a对象计数减1,当前为0,后续将会被回收这一方法在现代JS引擎中已经被废弃,其最大的问题在于引用计数法无法解决循环引用的问题。
function fn(){
const a = {} // a计数1
const b = {} // b计数1
a.target = b // b计数2
b.target = a // a计数2
}
fn()当函数结束时局部对象a和b被销毁,a的引用计数从2减到1(因为 b.target 仍引用它),b的引用计数从2减到1(因为 a.target 仍引用它),由于两者引用数都是1,所以无法被回收。
标记清除法
现代浏览器通常采用标记清除法以避免引用计数法自身缺陷所带来的内存泄露问题。标记清除法和引用计数法思路不同,引用计数法是记录每个对象的引用数,而标记清除法采用“可达性”的思想,即如果一个对象如果“不可达”,那么就应该被回收。具体实现是GC从一组根(包括DOM树根节点、执行上下文和全局对象等)出发,追踪JavaScript对象的每个指针,并将该对象标记为可访问,递归此过程,直到找到并标记了所有可达的对象,这些可达的对象就是活对象,而不可达的对象就是死对象,接下来GC会回收所有死对象。
标记清除法总体分为以下三个步骤:
- 标记活对象(可达的对象)
- 清除死对象(不可达的对象)
- 压缩整理内存(可选)
标记清除法弥补了引用计数法在处理循环引用时的缺陷。由于无需频繁更新引用计数,该方法的性能开销相对较小。然而,在现代浏览器中,它仍然存在一些不足之处。这里的不足并非指算法本身的缺陷,而是因为垃圾回收器(GC)运行在主线程上,可能会引发性能问题,所以现代浏览器引擎通常会采用一些方式来优化GC的性能。
代际假说
**在垃圾回收中,有一个重要的术语:“代际假说”****,其观点认为绝大多数的对象都是“朝生暮死”的,即大部分的对象被创建后都将很快死亡。**基于这个观点,对象可以被分为“新生代”和“老生代”,一个刚被创建的对象属于“新生代”,因为它可能会很快死亡,如果在经历过几次GC后该对象仍然存活,那么它就会被“提升”至老生代中。
通过代际假说我们可以知道,新生代中对象数量会更多,并且大部分对象很快会死亡,因此需要一个垃圾回收器专门去高效地回收,而大多数JS引擎都会采用主副GC结合的方式来进行垃圾回收,副GC(Scavenger)只负责新生代的垃圾回收,其执行更频繁,而当内存占用达到某个临界值时则会触发主GC(Mark-Compact),对整个堆内存进行垃圾回收。
Minor GC
Minor GC只回收新生代,采用“半空间”的设计,一半存放对象,另一半是空的,最初的那一半存放对象的空间称为“From-Space”,而另一半空间是空的,称为“To-Space”。当Minor GC进行垃圾回收时,会执行下面的步骤:
- 标记:找到所有可达的对象并标记
- 清除:回收死对象
- 将“From-Space”空间中活的对象复制到“To-Space”
- 清理“From-Space”空间中剩余的对象,即不可达的死对象
- 指针更新:更新原始指针到新位置
在最后V8会将原来的“To-Space”作为“From-Space”,“From-Space”作为“To-Space”,后续重复以上操作。
从整体上来看,MInor GC垃圾回收主要是通过三个步骤来完成的:标记、清除和指针更新,需要注意的是三者是交替执行的,而不是按阶段执行,即发现一个活对象就标记、复制,然后更新指针,而不是全部标记后再清除再更新指针。
由于大多数对象的存活时间很短且Minor GC每次只复制活的对象,所以Minor GC的效率非常高。
Major GC
在多次垃圾回收中存活的新生代会被提升至老生代中,而Minor GC只处理新生代,不会对老生代进行垃圾回收,因此还需要一个主GC来对整个堆进行垃圾回收。
Major GC也是通过三个步骤来实现垃圾回收,不过两者实现方式不同,Minor GC是通过“半空间”的设计实现清除的,并且在移动时通过移动到一个连续的内存块中解决内存间隙问题,而Major GC则不同。
Major GC的主要步骤:
- 标记:通过可达性标记活对象
- 清除:清除死对象,同时将死对象留下的空间间隙记录到一个被称为空闲列表(free-list)的数据结构中
- 压缩(仅在高度碎片化时):借助空闲列表,将幸存的活对象复制到其他页的死对象留下的空间间隙中
Major GC 并未采用类似 Minor GC 的半空间设计。这是因为 Major GC 需要处理所有对象,包括新生代和老生代。而老生代中包含大量长寿命周期的存活对象,复制这些对象会导致极高的性能开销。尽管 Major GC 会执行压缩操作,但这种操作仅限于高度碎片化的页,以尽量减少其对系统性能的负面影响。
Major GC 采取的策略是,将死亡对象留下的空间间隙记录到一个名为“空闲列表”(free-list)的数据结构中。当后续需要分配内存时,会查询空闲列表,从而快速找到合适大小的内存空间。
Orinoco
上面的策略已经实现了一定程度的优化,但是现代浏览器引擎所做的还不止于此,以我们V8引擎使用的垃圾回收器Orinoco为例,它利用最新的并行、增量、并发等技术来优化GC,以释放主线程。
并行
在主线程执行GC期间,V8可以运行多个线程来辅助完成GC工作,此时JS执行被暂停,JS堆中不会产生新的对象,主线程和辅助线程一同完成垃圾回收工作,这会直接减少GC在主线程中的时间。
增量
V8可以将GC任务分成多个小任务,并间隙性地插入到主线程中执行,从而避免GC长时间占用主线程。这种方式相较于直接占用主线程执行GC性能要好得多,但更加复杂。因为在间隔JS执行期间,JS堆的状态可能会发生变化,导致之前增量式的工作失效。事实上,这种方式不会减少GC所用的时长,反而会略微增加,但它对解决GC长时间占用主线程导致的页面卡顿问题具有重要意义。
并发
**并发是指JS在主线程中正常执行,通过辅助线程在后台完成GC工作,两者同时进行。**这种方式的难点在于:JS执行过程中堆的状态可能会不断变化,导致之前的工作失效;此外,主线程的JS和辅助线程的GC可能会同时访问或修改某个对象,这会导致竞态问题。但是这种方式的好处也很明显,主线程可以完全自由地执行JS,GC对性能的影响很小。
空闲时间GC
显示器常见的刷新率是60Hz,也就是说需要在16.66ms内完成渲染帧。如果超过这个时间就会导致“丢帧”,在页面中体现为卡顿或延迟。而如果主线程在这个时间内提前完成了渲染帧,那么剩余的时间就被称为空闲时间。V8可能会在主线程空闲时主动塞入GC任务,而不是必须等到内存占用达到临界值时才进行GC。
总结
首先,我们了解了两种常见的垃圾回收机制引用计数法和标记清除法,并且了解到引用计数法在循环引用上的缺陷以及现代浏览器通常采用的是标记清除法。
接下来我们了解了GC中的专业术语“代际假说”,其核心观点是大多数对象都是“朝生暮死”,存活时间很短,因此V8会将对象分为“新生代”和“老生代”,副GC专门处理新生代,采用半空间的设计通过标记、清除、指标更新三个步骤来实现垃圾回收,每次清除都是一次活对象的复制,主GC负责整个堆的垃圾回收,其主要步骤是标记、清除和压缩,在清除中会将死对象的内存间隙保存在空闲列表,以便后续分配新对象时使用。
最后,由于GC运行在主线程中,会阻塞JS执行与页面渲染,因此现代浏览器引擎还在底层做了很多性能优化工作,比如并行、增量、并发和空闲时间GC。并行是指在主线程运行GC时通过创建辅助线程协助完成GC工作,这会直接减少GC时间,增量是指将GC划分成多个小任务间隙性地插入到主线程中,从而避免因GC长时间持续性地占用主线程导致页面卡顿,并发是指在主线程执行JS时,通过辅助线程并发式地执行GC,GC几乎不会占用主线程,性能开销很小,此外V8还会在主线程的空闲时间里主动执行GC,合理地运用这些空闲时间来优化性能。
参考资料
Trash talk: the Orinoco garbage collector · V8