frontend-notes

V8引擎的垃圾回收

补充:以下内容都是网上收集的一些文章总结,后面发现直接看官方译文更加清楚和权威,官方文档的译文的 github 已经 forked 到 我的github 中。

什么是GC

GCGarbage Collection,在V8引擎逐行执行JavaScript代码的过程中,当遇到函数的情况时,会为其创建一个函数执行上下文(Context)环境并添加到调用堆栈的栈顶,函数的作用域(handleScope)中包含了该函数中声明的所有变量,当该函数执行完毕后,对应的执行上下文从栈顶弹出,函数的作用域会随之销毁,其包含的所有变量也会统一释放并被自动回收。试想如果在这个作用域被销毁的过程中,其中的变量不被回收,即持久占用内存,那么必然会导致内存暴增,从而引发内存泄漏导致程序的性能直线下降甚至崩溃,因此内存在使用完毕之后理当归还给操作系统以保证内存的重复利用。

V8引擎内存限制

JavaScript数据类型分为基础数据类型和引用数据类型:

由于栈内存所存的基础数据类型大小是固定的,所以栈内存的内存都是操作系统自动分配和释放回收的(栈有一个记录当前执行状态的指针,称为 ESP,函数执行完后会通过移动ESP销毁函数上下文,内存就会被回收了) 由于堆内存所存大小不固定,系统无法自动释放回收,所以需要JS引擎来手动释放这些内存,以下说的垃圾回收都是指堆内存的回收

在Chrome中,V8被限制了内存的使用(64位约1.4G/1464MB , 32位约0.7G/732MB),为什么要限制呢?

垃圾回收策略

V8的垃圾回收策略主要是基于分代式垃圾回收机制,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。

为什么需要分代式?

分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率

V8的内存结构

V8的内存结构主要由以下几个部分组成:

内存结构

V8 整个堆内存的大小就等于新生代加上老生代的内存

堆内存

新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

新生代

新生代的对象为存活时间较短的对象,简单来说就是新产生的对象,通常只支持 1~8M 的容量,新生代垃圾回收策略为副垃圾回收器 + Scavenge算法Scavenge算法是一个典型的牺牲空间换取时间的复制算法,在占用空间不大的场景上非常适用。

Scavange算法将新生代堆分为两部分,分别叫from-spaceto-space,具体步骤为以下4步: 1、标记活动对象和非活动对象 2、复制from-space的活动对象到to-space中并进行整理排序(这样就没有内存碎片了) 3、清除from-space中的非活动对象 4、将from-spaceto-space进行角色互换,以便下一次的Scavenge算法垃圾回收

那么,垃圾回收器是怎么知道哪些对象是活动对象,哪些是非活动对象呢? 在 JavaScript 内存管理中有一个概念叫做可达性。什么是可达性呢?就是从初始的根对象(window或者global)的指针开始,向下搜索子节点,子节点被搜索到了,说明该子节点的引用对象可达,并为其进行标记,然后接着递归搜索,直到所有子节点被遍历结束。那么没有被遍历到节点,也就没有被标记,也就会被当成没有被任何地方引用,就可以证明这是一个需要被释放内存的对象,可以被垃圾回收器回收。

新生代中的对象什么时候变成老生代的对象? 在新生代中,还进一步进行了细分。分为nursery子代intermediate子代两个区域,一个对象第一次分配内存时会被分配到新生代中的nursery子代,如果经过下一次垃圾回收这个对象还存在新生代中,这时候我们将此对象移动到intermediate子代,在经过下一次垃圾回收,如果这个对象还在新生代中,副垃圾回收器会将该对象移动到老生代中(也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中),这个移动的过程被称为晋升

另外还有一种情况,如果复制一个对象到to-space时,to-space占用超过了 25%,那么这个对象会被直接晋升到老生代空间中,设置为 25% 的比例的原因是,当完成 Scavenge 回收后,to-space将翻转成from-space,继续进行对象内存的分配,若占比过大,将会影响后续内存分配

老生代

在老生代中,因为管理着大量的存活对象,如果依旧使用Scavenge算法的话,很明显会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法Mark-Sweep(标记清除)Mark-Compact(标记整理)来进行管理。

在早前我们可能听说过一种算法叫做引用计数,该算法的原理比较简单,就是看对象是否还有其他引用指向它,如果没有指向该对象的引用,则该对象会被视为垃圾并被垃圾回收器回收。但是该方法无法解决循环引用导致的内存泄漏问题,截至2012年所有的现代浏览器均放弃了这种算法,转而采用新的Mark-Sweep(标记清除)Mark-Compact(标记整理)算法。

Mark-Sweep(标记清除)

Mark-Sweep(标记清除)分为标记清除两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:

以下几种情况都可以作为根节点:

Mark-Compact(标记整理)

为了解决Mark-Sweep(标记清除)引发的内存碎片化问题,Mark-Compact(标记整理)算法被提了出来,标记整理的标记阶段跟标记清除是一样的,但后续步骤不是直接清除无用对象,而是将所有存活的对象都向一端移动后在进行清除,这样就可以解决内存碎片化的问题了。

在老生代垃圾回收器中这几种策略都是融合使用的 老生代主要使用并发标记,主线程在开始执行 JavaScript 时,辅助线程也同时执行标记操作(标记操作全都由辅助线程完成) 标记完成之后,再执行并行清理操作(主线程在执行清理操作时,多个辅助线程也同时执行清理操作) 同时,清理的任务会采用增量的方式分批在各个 JavaScript 任务之间执行

垃圾回收和代码执行策略

全停顿 JS代码的运行要用到JS引擎,垃圾回收也要用到JS引擎,那如果这两者同时进行了,发生冲突了咋办呢?答案是,垃圾回收优先于代码执行,会先停止代码的执行,等到垃圾回收完毕,再执行JS代码。这个过程,称为全停顿(Stop-The-World)

新生代(副垃圾回收器)

V8在新生代垃圾回收中,当半空间满了或者空闲时间(空闲时间解释看后面的“什么时候触发垃圾回收”小节)会使用并行回收机制进行一次垃圾回收,在整理排序阶段,也就是将活动对象从from-space复制到to-space的时候,启用多个辅助线程,并行的进行整理。由于多个线程竞争一个新生代的堆的内存资源,可能出现有某个活动对象被多个线程进行复制操作的问题,为了解决这个问题,V8在第一个线程对活动对象进行复制并且复制完成后,都必须去维护复制这个活动对象后的指针转发地址,以便于其他协助线程可以找到该活动对象后可以判断该活动对象是否已被复制。

副垃圾回收器

当然并行回收机制仍然是一种全停顿式的垃圾回收方式。

老生代(主垃圾回收器)

老生代活动对象比较多的时候,停顿时间就会较长,使用全停顿会出现页面卡顿现象。为了提升用户体验,减少全停顿的时间,在 2011 年,V8 对老生代的标记进行了优化,从全停顿标记切换到增量标记,直至2018年,Chrome64Node.js V10启动并发标记(Concurrent),同时在并发的基础上添加并行(Parallel)技术,使得垃圾回收时间大幅度缩短。

Orinoco优化 V8为了解决全停顿问题,提出了增量标记(Incremental marking)懒性清理(Lazy sweeping)并发(Concurrent)并行(Parallel)的优化方法,即Orinoco优化(orinoco为V8的垃圾回收器的项目代号)。

V8在老生代垃圾回收中,如果堆中的内存大小超过某个阈值之后或空闲时间(空闲时间解释看后面的“什么时候触发垃圾回收”小节),会启用并发标记任务。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用,而在JavaScript代码执行时候,并发标记也在后台的辅助进程中进行,当堆中的某个对象指针被JavaScript代码修改的时候,写入屏障技术会在辅助线程在进行并发标记的时候进行追踪。

当并发标记完成或者动态分配的内存到达极限的时候,主线程会执行最终的快速标记步骤,这个时候主线程会挂起,主线程会再一次的扫描根集以确保所有的对象都完成了标记,由于辅助线程已经标记过活动对象,主线程的本次扫描只是进行check操作,确认完成之后,某些辅助线程会进行清理内存操作,某些辅助进程会进行内存整理操作,由于都是并发的,并不会影响主线程JavaScript代码的执行。

主垃圾回收器

以下是官方对并发标记的解释并发标记方案

什么时候触发垃圾回收

JavaScript 是无法去直接访问垃圾回收器的,这些都是在V8的实现中已经定义好的。但是 V8 确实提供了一种机制让Embedders(嵌入V8的环境)去触发垃圾回收,即便 JavaScript 本身不能直接去触发垃圾回收。垃圾回收器会发布一些 “空闲时任务(Idle Tasks)”,虽然这些任务都是可选的,但最终这些任务会被触发。像 Chrome 这些嵌入了 V8 的环境会有一些空闲时间的概念。比如:在 Chrome 中,以每秒60帧的速度去执行一些动画,浏览器大约有16.6毫秒的时间去渲染动画的每一帧,如果动画提前完成,那么 Chrome 在下一帧之前的空闲时间去触发垃圾回收器发布的空闲时任务。

空闲时间垃圾回收器

如果想知道空闲时垃圾回收器更详细的内容,请看这篇文章 our in-depth publication on idle-time GC

概念解释

增量/增量标记 增量就是将一次GC标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮GC标记。 由于分成了多步执行,所以为了知道每次标记执行哪里(正确的暂停和恢复),V8使用的三色标记法;而当标记暂停后回去正常执行js代码,执行代码的过程中对象引用关系会发生改变,为了处理这种情况V8引用了写屏障技术,这两个概念后面会介绍到。 全停顿&增量标记

惰性清理 当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记。

注意:增量标记和惰性清理并没有减少主线程的总暂停的时间,甚至会略微增加,其次由于写屏障机制的成本,增量标记可能会降低应用程序的吞吐量

并行 并行式GC允许主线程和辅助线程同时执行同样的GC工作,这样可以让辅助线程来分担主线程的GC工作,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。 并行回收

并发 并发式GC允许在在垃圾回收的同时不需要将主线程挂起,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作。 并发回收

三色标记法 三色标记法即使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑

三色标记法

采用三色标记法后在恢复执行时可以直接通过当前内存中有没有灰色节点来判断整个标记是否完成,如没有灰色节点,直接进入清理阶段,如还有灰色标记,恢复时直接从灰色的节点开始继续执行就可以。具体介绍看这里

写屏障 增量/增量标记惰性清理并发这些操作都是在代码执行过程中穿插执行的,而JavaScript代码在执行过程中堆中的对象的引用关系随时可能会变化,所以需要在标记阶段需要使用写屏障技术来记录这些引用关系的变化,保证程序正常运行。

写屏障

写屏障需要配合上面的三色标记法理解,即一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作 强三色不变性。具体介绍看这里

什么情况会引起内存泄漏

强引用 & 弱引用 ES6 把引用有区分为强引用和弱引用,这个目前只有再 Set 和 Map 中才有。强引用才会有引用计数叠加,只有引用计数为 0 的对象的内存才会被回收,所以一般需要手动回收内存(手动回收的前提在于标记清除法还没执行,还处于当前执行环境)。而弱引用没有触发引用计数叠加,只要引用计数为 0,弱引用就会自动消失,无需手动回收内存。

参考