作者:xcat
本文详细介绍了 JavaScript 中次要垃圾回收的 Scavenger 算法和主要垃圾回收的标记-清除算法的实现细节,以及各种算法在并行、并发、增量上的优化方案,最后介绍了 JS 中垃圾回收的触发时机。
在详细介绍 JavaScript 的垃圾回收算法之前,我们先来了解下 V8 引擎中的分代布局。
1. 分代布局
分代假说(The Generational Hypothesis)认为大多数对象的生命周期非常短暂,即从垃圾回收的视角来看,大多数对象在被分配后几乎立即变成不可访问状态。这一规律不仅适用于 V8 或 JavaScript,对大多数动态语言都成立。
V8 的分代式堆内存布局正是基于这种对象生命周期特征而设计。堆内存被分为「年轻代」(进一步划分为新生区和中间区两个子代)和「老年代」。对象首先会被分配在「新生区」。如果它们在下一次垃圾回收中存活,就会被保留在年轻代但晋升为「中间区」状态。如果它们再次在垃圾回收中存活,就会晋升至老年代。
JavaScript 的垃圾回收算法分为两种:
● 次要垃圾回收:使用「Scavenger 算法」,回收年轻代中的垃圾
● 主要垃圾回收:使用「标记-清除算法」,回收老年代中的垃圾
2. Scavenger 算法
V8 中的次要垃圾回收(Minor GC)正是基于分代假说,使用的是 Scavenger 算法。
它分为「标记」、「转移」和「指针更新」三个步骤:
● 标记(marking):找到年轻代中的活跃对象
● 转移(evacuating):将标记的对象复制到中间区或老年代(取决于是否已转移过)
● 指针更新(pointer-updating):更新被复制对象的所有引用指针
2.1. 标记
Scavenger 算法的第一步是找到年轻代中的活跃对象。这个类似于标记-清除算法中的标记阶段,需要从 GC Roots 开始,遍历完整个引用图,才能确定年轻代中哪些是存活的(其他的都是死亡的)。
GC Roots
GC Roots(根集合)是垃圾回收器进行可达性分析的起点,所有从 GC Roots 出发能直接或间接访问到的对象都被视为存活对象。GC Roots 主要包括以下内容:
1.全局对象
a. window
b. global
2.当前执行上下文中的活动对象
a. 正在执行的函数内部的变量
b. 闭包中引用的外部变量
3.DOM 节点
a. 所有未被移除的 DOM 元素的引用
4.活动线程和事件队列中的引用
a. setTimeout、Promise 回调中引用的对象
b. 未解绑的事件监听器
5.内置对象和系统引用
a. 当前正在执行的作用域链(Scope Chain)
b. 内置对象(如 Math、JSON)的引用
跨代引用列表
根据分代假说,老年代中的对象大部分都是长期存活的,这意味本来只是为了找出年轻代中的活跃对象,结果却遍历了几乎整个老年代对象。
为了避免遍历几乎整个老年代,V8 实现了一个机制,通过写屏障(Write Barrier)维护了一个「老年代对年轻代的跨代引用列表」(a list of old-to-new references)。然后从 GC Roots 开始遍历时,遇到老年代对象就直接跳过,仅遍历其中的年轻代对象,这样可以找到所有「从 GC Roots 出发,不经过老年代的年轻代」。接着再将跨代引用列表中的对象加入到 GC Roots 中遍历,同样是遇到老年代对象就直接跳过,这样就可以找到所有「被老年代引用的年轻代」。
通过维护了一个「跨代引用列表」的方式,V8 不遍历老年代就能找到所有年轻代中的存活对象。
写屏障
写屏障是一种在垃圾收集过程中用于保持对象引用完整性和一致性的重要技术。在 V8 引擎中,写屏障不仅在次要垃圾回收的标记阶段用于维护跨代引用列表,也在主要垃圾回收的并发标记和增量标记阶段用于更新存活的对象。
写屏障在 js 执行写操作(比如 object.field = vaule)的时候会被触发,若一个对象更新了其引用信息,则会在写屏障中更新跨代引用列表。保证 Scavenger 算法的标记阶段能够获得准确的跨代引用信息。写屏障在并发标记和增量标记的应用将会在后续章节中介绍。
2.2. 转移
Scavenger 算法的第二步是将标记的对象复制到中间区或老年代(取决于是否已转移过一次)。
复制(转移)是垃圾回收中开销非常大的操作。不过根据分代假说,年轻代中实际存活的对象比例极低,需要复制的对象也很少。通过仅移动存活对象,其他所有内存都成为了可回收的垃圾。这意味着我们只需承担与存活对象数量成正比(而非与总分配量成正比)的复制成本。
半空间
在针对年轻代的回收过程中,存活的对象始终会被转移到新的内存页。V8 为年轻代采用了半空间(Semi-Space)设计,这意味着总空间的一半始终预留为空,以支持转移操作。
在回收期间,初始为空的部分称为 To-Space,而需要复制的来源区域称为 From-Space。转移步骤会将所有存活对象移动到连续的内存块中(位于同一内存页内),从而完全消除内存碎片(即死对象留下的间隙)。
随后会交换两个半空间的角色——To-Space 变为From-Space,From-Space 变为 To-Space。垃圾回收完成后,新对象的内存分配将从新的 From-Space 的下一个空闲地址开始。
下一次垃圾回收时,From-Space 中刚分配的对象会被转移到 To-Space,而 From-Space 中已经移动过一次的对象则会被转移到老年代:
2.3. 指针更新
Scavenger 算法的最后一步是更新引用地址。注意无论对象是第一次转移(从 From-Space 到 To-Space),还是第二次转移(从 From-Space 到老年代),都会在原来的位置留下一个转发地址,用于更新原始指针的地址。
接下来 V8 需要知道内存中哪些地方引用了这次转移的对象,更新它们的指针。那么如何找到所有引用了这些对象的对象呢?由于对象的引用是单向的关系,似乎只能重新完全遍历一次所有内存,才能找到引用了该对象的对象。这肯定是不现实的,它会非常慢。所以 V8 引入了存储缓冲区,将对象的引用关系由单向变成了双向的,解决了「如何找到引用了该对象的对象」这个问题。
存储缓冲区
V8 在内存中每个对象建立引用关系时,反向记录了一个地址,指向了引用该对象的对象,使得对象的引用关系就不是单向的了,而是双向的。这个信息就存储在「存储缓冲区」(Store Buffer)中:
如图所示,Page1 中的对象如果移动了位置,就能通过 Page1 的 Store Buffer 找到引用了 ObjectA 和 ObjectB 的所有对象,然后分别更新其指针即可。但是这样的结构有个缺点,两个存储缓冲区可能包含了同一个指针记录,当多线程并行执行指针更新时,多个线程可能同时更新同一个指针,造成数据竞争。V8 使用了「记忆集」来解决这个问题。
记忆集
为了解决多线程并行执行指针更新时的数据竞争问题,V8 使用了「记忆集」(Remembered Set)替代「存储缓冲区」来记录对象的引用关系。
因为每个内存页的大小是固定的,所以可以给 Page2 分配一个固定大小的记忆集,将它「引用的对象的地址的偏移量的位置」标记为红色:
这样的话,一旦 Page1 中的对象移动了位置,就可以在每页的记忆集中寻找固定偏移量的位置,看看是否为红色,如果为红色,则说明 Page2 需要更新该引用的指针。多线程可以按页来分配任务,每个线程更新自己的页的指针即可,如此一来,再也不会出现多个线程更新同一个页的指针的情况了。
Scavenger 算法中,标记、转移、指针更新这三个步骤是交错进行的,而不是严格分阶阶段完成。具体来说,标记步骤会遍历 GC Roots,一旦找到活对象,则立即将它转移到 To-Space(或老年代),然后立即更新它的引用指针,接着再继续遍历 GC Roots。这样交错执行的好处是可以减少 GC 过程的内存占用,提高复制效率。
2.4. 并行
并行(Parallel)是将垃圾回收任务分配给多个线程并行执行,但它仍然会阻塞主线程(GC Stop-The-World),只是相对来说阻塞的时间变少了:
并发(Concurrent)则是将任务完全交给其他线程,完全不阻塞主线程:
并行是一种相对简单的技术,因为主线程的 js 已经暂停了,不会再修改内存。只需要确保多个线程访问同一个对象时能得到及时的同步。而并发则是比较困难的技术,因为 js 主线程可能随时读写内存,使得垃圾回收中的标记任务变得无效,还需担心主线程和辅助线程读写同一个对象时造成的数据竞争。
次要垃圾回收时,因为只需要扫描年轻代内存,所以标记阶段耗时很小。大部分耗时都在转移阶段,而转移阶段是无法并发的——转移阶段肯定不能让主线程继续执行 js。次要垃圾回收只能在标记和指针更新阶段引入并发,带来的性能提升很小,反而还增加了写屏障、线程同步等耗时,得不偿失。所以次要垃圾回收只使用了并行技术。
在 v6.2 之前,V8 的次要垃圾回收使用的是一种没有并行技术的「单线程 Cheney 半空间复制算法」(Single-threaded Cheney’s Semispace Copy)。这种算法其实就是前文中介绍 Scavenger 算法中不包含并行的部分,它简单易实现,适合单核环境,但也会完全阻塞主线程,没有利用到多核的性能优势。
V8 对比了以下这三种算法,最终选择了如今这种「并行的 Scavenger 算法」:
● 单线程 Cheney 半空间复制算法
● 并行标记-转移算法
● 并行的 Scavenger 算法
这里我简单介绍下这三种算法的差异。
单线程 Cheney 半空间复制算法
这个算法其实就是前文中介绍 Scavenger 算法中不包含并行的部分:
将内存空间分位年轻代和老年代,年轻代分位两个半空间 From-Space 和 To-Space。垃圾回收器从 GC Roots 开始遍历,交错的执行这三个步骤:
● 标记(marking):找到年轻代中的活跃对象
● 转移(evacuating):将标记的对象复制到 To-Space 或老年代(取决于是否已转移过)
● 指针更新(pointer-updating):更新被复制对象的所有引用指针
这三个步骤交错进行,而不是严格分阶阶段完成。
并行标记-转移算法
将单线程 Cheney 半空间复制算法改造成多线程的难点在于:
● 单线程环境下,对引用图的遍历是线性的,如果多线程并行遍历,则容易同时遍历到同一个对象,造成数据竞争
● 多线程并行转移对象时,内存分配容易冲突
● 指针更新时,另一个线程可能已读取了未转移的对象
并行标记-转移算法(Parallel Mark-Evacuate)为了解决这几个问题,放弃了单线程 Cheney 半空间复制算法中的交错执行方式,改为阶段性执行:
标记阶段:多线程并行执行标记任务,即使重复标记了也没关系 转移阶段:等所有标记任务全部完成后,再给多线程分配转移任务,并行转移到 To-Space 或老年代中。转移时每个线程都有自己的本地分配缓冲区(local allocation buffers, LABs),转移完成后会合并到一起 指针更新阶段:转移任务全部完成后,才会多线程并行执行指针更新任务
并行标记-转移算法使用了分阶段执行这种简单的方式解决了数据竞争、内存分配等问题。但是它没有考虑到任务的负载均衡问题,部分线程可能任务负载过重,而另一部分线程比较空闲,没有充分利用到多线程的优势。
并行的 Scavenger 算法
V8 最终使用的并行的 Scavenger 算法(Parallel Scavenge)则是更为极致的优化,它维护了一个全局工作列表,多线程首先从多个 GC Roots 出发并行遍历,每个线程不会遍历完它的整个图,而是在遍历时选择性的将子节点的遍历任务加入到全局工作列表中。当单个线程空闲时,就会从全局工作列表「窃取」(stealing)一个任务来处理。
这样就解决了线程间的负载均衡问题。
另外,并行的 Scavenger 算法中实现了一个屏障(barrier)机制,使得在遍历时如果遇到一些不适合并行处理的任务时,不会将它放到全局工作列表中(比如线性对象链 linear chain of objects)。这个屏障也保证了标记、转移、指针更新这三个阶段可以交错执行而不会出错。
通过升级为并行的 Scavenger 算法,次要垃圾回收总时间减少了 55%
2.5. 增量垃圾回收
增量垃圾回收是一种在浏览器空闲时段进行垃圾回收的技术。
空闲时段
大多数浏览器的刷新率是 60Hz,这也是衡量网页是否卡顿的标准。所以 Chrome 在绘制每一帧之间,有 16.6 毫秒的时间来计算渲染任务。如果 Chrome 在不到 16.6 毫秒的时间内完成了任务,那么在开始渲染下一帧之前,浏览器就有空做一些其他时间,这个时间段就称为空闲时段(Idel period)。浏览器提供了一个接口 requestIdleCallback 可以用来注册空闲任务。
空闲时段只能执行低优先级的任务,包括 js 注册的空闲任务和空闲垃圾回收任务,空闲任务会有一个截止期限,这是调度器对其预计空闲时间的预估,它的上限是 50ms,以确保浏览器能及时响应用户突然的输入。空闲任务使用截止期限来估计它可以完成多少工作而不会导致输入响应卡顿或延迟。
为了在空闲期间执行这些操作,V8 会将垃圾回收的空闲任务提交给调度器。当这些空闲任务运行时,它们会设定一个应该完成的最后期限。V8 的垃圾回收空闲时间管理器会评估应该执行哪些垃圾回收任务,以减少内存消耗,同时遵守最后期限以避免未来在帧渲染或输入延迟方面的卡顿。空闲垃圾回收任务可能包括次要垃圾回收或主要垃圾回收。
次要垃圾回收的速度很快,如果在空闲时段执行的是次要垃圾回收,则会在这次任务中完成整个次要垃圾回收任务。而主要垃圾回收只会在空闲时段执行增量标记任务。这将在下一章介绍。
3. 标记-清除算法
标记-清除(Mark-and-sweep)是垃圾回收中的经典算法。JavaScript 中的主要垃圾回收(Major GC)就是使用这个算法来收集老年代中的垃圾。
它分为「标记」、「清除」两个主要步骤,以及「压缩」这一可选步骤:
● 标记(marking):找到活跃对象
● 清除(sweeping):回收死内存
● 压缩(Compacting)(可选):整理内存碎片
3.1. 标记阶段
标记阶段用来确定哪些对象可以被回收,它是垃圾回收的核心环节。
垃圾回收器通过「可达性」来判断对象的「存活状态」。这意味着当前运行时环境中所有可达的对象必须保留,而不可达的对象则需要被回收。标记过程即寻找可达对象的过程。垃圾回收器从一组已知的指针起点(称为 GC Roots)开始遍历,沿着每个指向 JavaScript 对象的指针进行追踪,将找到的对象标记为可达。回收器会递归地追踪这些对象内部的所有指针,直到标记出运行时环境中所有可达对象。
并发标记
并行(Parallel)是将垃圾回收任务分配给多个线程并行执行,但它仍然会阻塞主线程,只是阻塞的时间变少了:
并发(Concurrent)则是将任务完全交给其他线程,完全不阻塞主线程:
并行是一种相对简单的技术,因为主线程的 js 已经暂停了,不会再修改内存。只需要确保多个线程访问同一个对象时能得到及时的同步。而并发则是比较困难的技术,因为 js 主线程可能随时读写内存,使得垃圾回收中的标记任务变得无效,还需担心主线程和辅助线程读写同一个对象时造成的数据竞争。
次要垃圾回收时,因为只需要扫描年轻代内存,所以标记阶段耗时很小。大部分耗时都在转移阶段,而转移阶段是无法并发的。所以次要垃圾回收只使用了并行技术。引入并发只能减少标记和指针更新阶段耗时,反而增加了写屏障、线程同步等耗时,得不偿失。
主要垃圾回收时,因为需要扫描整个老年代内存,所以标记阶段耗时比较长。所以主要垃圾回收可以利用并发标记技术,使得 V8 的标记阶段都在辅助线程中执行时,完全不阻塞 js 主线程:
三色标记
标记阶段的工作可以看作是图的遍历。堆内存上的对象就是图的节点,一个对象对另一个对象的指针就是图的边。标记阶段的目标就是从 Roots 出发,找到所有引用到的对象。
假如是单线程遍历图,可以在一个调用栈中直接广度或深度遍历即可。但要实现多线程并发标记,则需要有一个标记工作列表(marking worklist),每个线程都从标记工作列表中拿取一个工作,并且把下一层级的工作推入到标记工作列表中。
V8 在标记阶段会将每个节点标记为三种颜色:
● 白色:尚未发现的节点
● 灰色:发现白色节点,将其推入到标记工作列表中,变成灰色节点
● 黑色:从标记工作列表中拿取一个灰色节点,访问其所有字段,这些字段中若有白色节点则推入到标记工作列表中变成灰色节点,访问完所有字段后,将该节点变成黑色
当标记工作列表为空时,就意味着完成了整个标记阶段,确定了图中所有的活跃对象(黑色节点)和死亡对象(白色节点)。
写屏障
并发标记时,主线程还在执行 js:
此时主线程可能修改内存中的引用关系,多线程的读写操作造成了数据竞争,这导致辅助线程中的三色标记失效。
写屏障解决了数据竞争的问题。写屏障在 js 执行写操作(比如 object.field = vaule)的时候会被触发,若一个字段从一个对象指向一个新的对象时,写屏障会检查并调整新对象的颜色(标记状态),如果该对象是白色(未被标记为活的),将其改为灰色并加入待处理队列。
写屏障会带来一定的性能开销,但它确保了三色标记的正确性。
并行标记
大部分情况下,并发标记不阻塞主线程,是更优的选择。
但有时候,对象的引用关系可能涉及复杂的线程同步问题,使得标记工作难以并发执行。
此时辅助线程会将此标记工作任务推送到一个名为救援工作列表(Bailout worklist)的列表中,求助于主线程通过阻塞 js 来执行此标记任务。
救援工作列表中的任务只会被主线程取走,此时会阻塞主线程的 js 执行,所有线程都会并行的执行标记任务:
增量标记
浏览器在下一帧渲染之前,可能有一些空闲时段,这个时段也可以用来做主要垃圾回收的增量标记任务。
为了减少对应用程序性能的影响,增量标记任务可以在多个空闲时段中执行,每个空闲时段内执行一段时间,然后中断以便其他重要任务(如主线程的 JavaScript 任务)可以继续执行。在下一个空闲时段,再继续未完成的标记任务。
在增量标记过程中,垃圾回收并非一次性完成,而是分成许多小的步骤。在主线程 js 执行过程中,内存中对象的引用关系可能发生变化,这会导致之前的标记失效了。为了解决这个问题,V8 使用写屏障来标记那些引用关系发生变化的对象。这样,即便在垃圾回收暂停期间对象的引用关系发生了变化,垃圾回收器依然能够准确地识别和处理这些变化。
由于清除任务和压缩任务的耗时较长,空闲时段不会用来做清除任务和压缩任务。
黑色分配
根据分代假说,大多数对象的生命周期非常短暂,在次要垃圾回收中就会被回收掉。而经历了两次次要垃圾回收都没被回收的对象,就会被晋升到老年代。
我们可以认为,刚从新生代晋升到老年代的对象,大概率是一个长期活跃的对象,至少在下次主要垃圾回收时也还是活跃的对象,不会被回收——假如一个刚晋升到老年代的对象马上就被回收了,说明这个分代假说本身就有问题了。既然它大概率在下次主要垃圾回收时还保持活跃,那么就没必要在标记阶段扫描它了,直接将它标记为活跃即可。这就是黑色分配的理论依据——刚晋升到老年代的对象,至少应该在下一次主要垃圾回收中存活下来。
在次要垃圾回收的标记阶段,V8 将准备从新生代晋升到老年代的对象染成黑色。在次要垃圾回收的转移阶段,黑色对象会被移动到一个特殊的黑色内存页中,这个内存页的所有对象都是黑色的。在下次主要垃圾回收的标记阶段,将会直接跳过黑色内存页的扫描。
黑色分配这一优化手段将吞吐量和延迟得分提高了约 30%,同时由于标记进度更快且整体垃圾收集工作更少,内存使用量减少了约 20%。
3.2. 清除阶段
清除过程会将死亡对象留下的内存空隙加入名为「空闲列表(free-list)」的数据结构。当标记完成后,垃圾回收器会扫描整个堆内存,找到由不可达对象形成的连续内存空隙,并将其加入对应大小的空闲列表。空闲列表按内存块大小分类存储以便快速检索。后续需要分配内存时,只需查询空闲列表即可找到合适大小的内存块。
并发清除
因为待清除的内存都是死亡内存,绝对不会再被主线程访问到了。所以清除任务可以完全放在辅助线程中并发执行。并且即使主线程 js 已经恢复执行时,辅助线程的清除任务还可以继续执行。
3.3. 压缩阶段
基于碎片化启发式算法(fragmentation heuristic),主要垃圾回收会选择性地对某些内存页执行对象迁移/压缩操作。
这个过程可以类比老式电脑的硬盘碎片整理:我们将存活对象复制到当前未被压缩的其他内存页(利用其空闲列表)。通过这种方式,可以充分利用死亡对象遗留在内存中的零散小空隙。复制存活对象存在一个潜在问题:复制大量长生命周期对象会带来高昂成本。因此我们选择只压缩碎片化程度较高的内存页,而对其他内存页仅执行清除操作(不复制存活对象)。压缩阶段会阻塞 js 主线程,避免数据竞争带来的问题。压缩阶段会并行执行。
最后总结一下,标记-清除算法的整个流程如下:
并发、增量标记:当堆内存接近动态计算的上限时,V8 会启动并发、增量标记。浏览器会在主线程空闲阶段执行增量标记,在非空闲阶段执行并发标记。主线程执行期间可能会产生新的对象引用关系,V8 采用写屏障(Write Barrier)机制来记录 js 在并发、增量标记阶段创建的新对象引用,确保标记结果的准确性,当遇到难以并发执行的标记任务时,会推入到救援工作列表中,交给最终标记阶段执行。 最终标记(并行标记):并发、增量标记完成后,主线程会暂停执行 js,此时进入最终标记阶段(marking finalization),多线程并行执行救援工作列表的工作,然后重新扫描 GC Roots,确保所有存活对象都被标记了。 并行压缩阶段:主线程继续暂停执行 js,多线程并行执行压缩任务,将存活对象移动到连续内存块以减少碎片,并更新相关指针。部分无法压缩的页则通过空闲列表(free-list)进行内存回收。 并发清除阶段:与此同时,辅助线程执行并发清除任务,这些任务与并行压缩及主线程代码执行同时进行,即使主线程 js 恢复运行,清除任务仍可在辅助线程中继续执行。
4. 垃圾回收的触发时机
JavaScirpt 的垃圾回收时机无法用程序控制,这是设计如此的:
程序员可能希望在时间关键的应用阶段关闭垃圾回收,以避免因垃圾回收引起的帧丢失。然而,这会使应用逻辑变得复杂,维护变得困难。如果在代码的某个分支中忘记重新开启垃圾回收,可能会导致内存耗尽。 程序员无法预估手动触发的垃圾回收需要多长时间,可能会导致应用程序本身引入卡顿,反而无法达到预期的性能优化效果。 这会给 js 引擎带来额外的工作,手动触发垃圾回收可能会干扰垃圾回收器的启发式算法,导致不可靠的内存管理行为。
4.1. 次要垃圾回收
● 当年轻代的活动空间被填满时触发
● 当程序请求分配新的内存,并且年轻代没有足够内存时触发
● 当空闲时段有足够的空闲时间时触发。但并不是一定会触发,因为频繁的触发可能导致本可以在次要垃圾回收中得到回收的对象被移入了老年代
4.2. 主要垃圾回收
● 当老年代中的对象占用空间增长到超过某个启发式计算的内存限制时触发
● 如果整个堆的使用情况超过了特定的内存阈值,基于启发式算法,系统可能触发主要垃圾回收
● 当堆大小达到某个策略设定的开始增量标记的限制时,会开始在空闲时段执行增量标记,增量标记完成后,开始清除和压缩,最终完成主要垃圾回收
● 在检测到应用的长期不活跃状态时,甚至在没有达到内存限制的情况下,可能主动进行主要垃圾回收来减少内存占用
4.3. 动态的垃圾回收频率
在一次完整的垃圾收集结束时,V8 的堆增长策略会根据存活对象的数量和内存的余量,来决定下一次垃圾收集的时间。所以垃圾回收的频率会根据内存的状态实时变化。
4.4. 低内存模式
垃圾回收的吞吐量、造成的页面延迟以及占用内存之间是一个不可能三角。针对不同的设备,需要有不同的内存回收策略。对于内存较低的移动设备,即内存少于 512 MB 的设备,优先考虑延迟和吞吐量而不是内存消耗可能会导致内存不足而崩溃。
为了更好地平衡这些低内存移动设备的权衡,V8 引入了一种特殊的低内存模式,该模式调整了一些垃圾收集启发模式以降低 JavaScript 垃圾收集堆的内存使用量。一般来说,在一次完整的垃圾收集结束时,V8 会根据存活对象的数量和内存余量来决定下一次垃圾收集的时间。在低内存模式下,内存余量更少,所以垃圾回收会更频繁的触发。
一般来说,主要垃圾回收会在内存还有空余时触发,此时会在空闲时段执行增量标记,等标记任务完全完成后,才会阻塞主线程,开始执行清除和压缩。但在低内存模式下,由于内存余量更少,可能在增量标记还未完成时,就触发了主线程的垃圾回收,此时主线程 js 阻塞,主线程和辅助线程并行的执行剩余的标记任务和后续的清除、压缩任务。低内存模式虽然使垃圾回收更频繁了,但是也使得移动端设备上的堆内存消耗减少了 50%
4.5. 不活跃的网页
一般来说,浏览器会对每个网页限制内存,一旦达到限值,则会启动主要垃圾回收。然而,如果网页在达到分配限制之前变得不活跃,那么在网页整个不活跃期间都不会进行主要的垃圾回收——这正是大多数网页会遇到的情况——大多数网页在加载页面时会使用较多内存,因为它们正在初始化其内部数据结构,加载后不久(几秒或几分钟内),网页通常变得不活跃。如果此时还未达到内存限值,就不会进行主要垃圾回收了。
这会导致不活跃的网页迟迟得不到内存回收。所以 Chrome 实现了一个名为「Memory Reducer」的控制器,它会检测网页何时变得不活跃,并主动调度一次主要垃圾回收——即使此时还未达到内存分配限制。
5. 参考文章
v8.dev 中内存管理和垃圾回收相关文章:
● Getting garbage collection for free · V82015-08Chrome 41 引入了一种在空闲时段执行垃圾回收的技术,通过将内存管理操作隐藏在未使用的空闲时间内,提高了 Web 应用的响应速度,减少了卡顿现象。
● Jank Busters Part One · V82015-10Chrome 41 到 46 通过减少簿记数据结构、引入静态逃逸分析以及将垃圾回收操作移至并发线程,显著降低了主线程的停顿时间,提升了 WebGL 游戏等应用的流畅性。
● Jank Busters Part Two: Orinoco · V82016-04V8引擎通过Orinoco垃圾回收器的并行压缩、并行记忆集处理和黑页分配等优化,显著减少了垃圾回收导致的卡顿和内存消耗,提升了性能。
● Optimizing V8 memory consumption · V82016-10V8团队通过优化垃圾回收策略、减少堆页面大小、改进解析器和编译器的区域内存管理以及手动压缩抽象语法树节点,显著降低了JavaScript虚拟机的内存消耗。
● Fast properties in V8 · V82017-08V8引擎通过不同的内部表示和处理机制来优化JavaScript属性的访问速度,包括区分命名属性和整数索引属性,并利用HiddenClasses来动态跟踪对象结构,从而实现高效的属性访问和修改。
● Orinoco: young generation garbage collection · V82017-11V8引擎引入了并行Scavenger垃圾回收算法,通过多线程并行处理年轻代垃圾回收任务,动态分配工作负载并利用工作窃取机制,显著减少了主线程的回收时间和停顿,提升了整体性能。
● Tracing from JS to the DOM and back again · V82018-03Chrome 66的DevTools通过新的C++追踪机制,能够更精确地追踪和显示JavaScript与C++ DOM对象之间的引用关系,从而更容易调试内存泄漏问题。
● Concurrent marking in V8 · V82018-06V8引擎通过引入并发标记技术,允许JavaScript应用在后台线程进行垃圾回收标记,解决了数据竞争和对象布局变化等难题,采用写屏障、原子操作、回退工作列表和对象快照协议等机制,显著减少了主线程的停顿时间,提升了应用性能和响应速度。
● Trash talk: the Orinoco garbage collector · V82019-01V8引擎的Orinoco垃圾回收器通过并行、增量和并发技术,将传统的全停顿垃圾回收器改造为以并行和并发为主、增量回退为辅的高效回收器,显著减少了主线程停顿时间,提升了JavaScript应用的性能和用户体验。
● A lighter V8 · V82019-09V8 Lite项目通过一系列内存优化技术,如延迟反馈分配、延迟源位置生成、字节码刷新和函数模板信息优化,显著减少了V8引擎的内存占用,典型网页的堆内存平均减少了18%,在低端Android设备上节省了1.5 MB内存,而Lite模式进一步牺牲部分性能换取更高的内存节省,平均减少22%内存占用,某些页面甚至减少32%。
● Pointer Compression in V8 · V82020-04V8引擎通过指针压缩技术,将64位指针压缩为32位偏移,显著减少了内存占用,平均降低了40%的V8堆内存,使Chrome渲染进程内存减少了20%,同时保持了64位应用的性能,并提升了实际网页浏览时的CPU和垃圾回收效率。
欢迎关注: