not a better man

前端技术

闲聊v8垃圾回收器的演变历史(3)

这一章会介绍Orinoco垃圾回收器对应知识,之前垃圾回收是有序执行,stop-the-world(STW),Orinoco垃圾回收器是一个在大部分情况下进行并行以及并发运作,来快速的进行垃圾回收。下面是相关视频的介绍

Orinoco 垃圾回收介绍

我们都清楚任何垃圾回收器都有一些周期性需要完成的工作

  • 识别存活/死亡的对象
  • 回收/重用死亡对象占用的内存空间
  • 压缩/回收内存空间

上述的工作可以有序的执行,也可以强制交叉执行,最粗暴的方式就是在主线程上有序的执行这些任务(stop-the-world产生),这个时候会暂停javascript的执行。造成应用程序卡顿,也会减少应用程序的吞吐量。

V8 分为Major GC 和 MinorGC

Major GC (Full Mark-Compact)

Major GC 负责从 heap 中回收垃圾

Major GC 图

标记(Marking)

我们首先要找到哪些对象可以被回收,这是垃圾回收过程中的一个必须步骤。垃圾回收器根据对象可达性来作为存活的指标。这表明任何在运行中可以被访问的对象都必须保留下,那些无法抵达的对象可以回收。

标记是找出可达对象的流程。垃圾回收器从一系列已知的对象指针开始查找,这些对象被称为根节点。这些根节点包含了执行栈和全局对象。从根节点跟着指针走,将一个个可达对象标记起来。垃圾回收器会追踪对象内的所有指针,然后递归这个流程,直到所有运行时内所有可达对象被找到并标记起来。

清除(Sweeping)

清除(Sweeping)也是一个流程,将死亡对象所遗留的内存空间地址添加到一个被称为free-list数据结构中。一旦当标记流程结束,垃圾回收器寻找连续的不可达对象占用的内存空间,并将它们加到适当的free-list中。Free-list根据内存块的大小分类,以便引擎可以快速定位利用。之后,当有内存分配需求的时候,只需要查找对应尺寸的free-list即可。

压缩(Compaction)

Major GC 会根据内存碎片的程度,有选择的执行压缩或疏散内存页。我们可以将压缩内存页面像PC硬盘碎片整理。引擎会将存活的对象从一个内存页拷贝到另一个当前并未在进行压缩的内存页上(该内存页可以根据free-list来找到)。我们可以用这种方法,充分利用死亡对象留下来的小块的,分散的内存空间。

垃圾回收器执行内存对象拷贝行为有个弱点,当存在大量长期存活对象的情况下时,拷贝行为的代价非常大,这也是为什么只针对那些碎片化程度特高的内存页进行压缩操作,对内存碎片化程度不高的进行清除操作,因为清除操作不会拷贝对象。

代际分层(Generational layout)

V8引擎将内存堆分成了不同的区域,我们把这些区域分成代,分别称为新生代和老生代。把新生代又分为nursery 和 intermediate 两个子代。新建的对象首先被分配在新生代的nursery 子代中,如果下次GC过程中,这些对象仍然存在。这些对象会存储在新生代的Intermediate 子代中。如果在在接下来的GC过程中,这些对象还存在,那么这些对象会被移动到老生代(old Generation)中。如下图所示

V8中的堆被划分成不同的代,当进行GC的时候,对象被移动到不同的Generation里。

在垃圾回收中,有个重要的名词叫代际假说。提出这个名的原因是,在实际的程序中,大部分对象存活的时间不会太长。从GC的角度来说,大多数对象第一次分配之后,马上就是不可达的了,这在很多动态语言中,都有体现。

V8 中heap的划分就根据代际假说而设计的。V8中的GC是个压缩/移动的GC,也就是说V8会复制那些在GC过程中幸存下来的对象。这个看起来感觉很奇怪,在GC时间内复制对象这个成本是昂贵的。根据代际假说,只有很少的对象在垃圾回收过程中能过存活下来。我们只移动那些存活下来的对象,那么未移动的对象就变成了垃圾。我们的成本是移动存活对象,这些时间并不好花费太多。

Minor GC(Scavenge 算法)

V8中有两种垃圾回收器 ,Major GC (Mark-Compact 算法)从整个堆中进行垃圾回收。 MinorGC 在新生代收集垃圾。

在新生代,我们使用Scavenge算法进行垃圾回收,存活的对象会被分配到新的内存页中,在新生代,采用半空间设计。也就是有一半内存空间始终是空的,为了方便的进行这个分配动作。在收集存活对象的过程中,为空的那个部分内存空间 我们叫做 “To-Space”,复制对象的空间区域叫做“From-Space”.极端情况下,每个对象都可以从清除过程中存活下来,这个时候,我们需要复制每一个对象。

在清扫过程中,我们有一组额外的根,这就是旧到新的引用。这些是旧空间中的指针,它们指向年轻一代的对象。我们不是为每一次清扫而追踪整个堆图,而是使用wirte barrier 机制来维护一个老到新的引用列表。当与栈和globals相结合时,我们知道进入年轻一代的每一个引用,而不需要追踪整个老一代。

疏散步骤将所有存活的对象移动到一个连续的内存块中(在一个页面内)。好处是原来的From-space空间剩下的全是无用的对象,可以将他们清除掉,然后,我们将两个空间进行切换,即To-Space变成From-Space,反之亦然。GC完成后,新的分配发生在From-Space的下一个空闲地址。

scavenger 移动活动的对象到一个 空闲空间中

这个策略最大的问题是内存利用率低,新生代的空间很容易被消耗完,所以在第二GC存活下来的对象会被移动到老生代。清除的最后一步是更新那么被移动对象的指针。每一个被复制的对象都一个前向指针,这个是为了更新原指针指向对象所处的新位置。(主要是对象被移动到old generation 需要进行指针的更新。)

Scavenger 将 intermediate 代对象移动到老生代对象中,将nersery 代中的对象移动到空置的内存页中

在scavenge过程中,实际上是在做 标记,移动 指针更新。这三个步骤是交错进行的,而不是分阶段进行。

Orinoco

垃圾回收算法和对应的优化工作,在很多关于垃圾回收相关论文中提及到,并且在很多带GC的语言中找到,但是打造一个性能强劲的GC走过了一段很长的路程。衡量垃圾收集所花费的时间一个重要指标是GC执行过程中主线程暂停的时间。传统的 stop-the-word(stw)GC来说,这个时间比较长,这个过程会直接影响到用户体验,现象是页面卡顿,渲染延迟(题外话,对于实时性要求很高程序来说,比如在高频交易,时间要求精度高,一般采用不带GC机制的语音开发如 c ,c++ ,主要原因是带有GC机制的语言,GC的时机是无法把控的,此外采用增量式GC,还是会有stw的存在,即便时间很短,还是会有影响,Rust语言的出现兼容了c.c++ 的高效,也解决了GC的问题,它提出了所有权的概念。

V8的Orinoco 使用了GC中的最新技术,利用到了增量式GC和并行,并发式GC技术,最大限度的将主线程从GC中解放出来。接来下我将介绍Orinoco GC中这些特有的技术

Orinoco 项目Logo

并行(Parallel)

并行是指主线程和辅助线程在同一时间做大致等量的工作。这仍然是一种 “stop-the-world”的方法,但是总的暂停时间由于参与GC的线程数量(当然加上同步的一些开销)而减少。这是三种技术中最简单的一种。这个期间JavaScript堆是暂停的,因为没有JavaScript运行,所有每个线程只需要确保同步访问对象的状态,以保证自己访问的对象不会被其他辅助线程处理了(保证两个线程不同时访问一个对象)

主线程和辅助线程在同一时间进行GC工作 ,目的就是减少GC时间

增量式GC(Incremental)

增量式就是主线程断断续续做一部分GC的工作,每次GC不是做整个GC流程中所有工作,这个实现逻辑比较困难,就是在增量GC工作之间,javascript是会执行的,在执行的过程会导致堆的状态发生变化,这有可能导致之前的GC是无效的。我们可以从下图中可以看出来,这种方式并没有减少GC总时间(其实比之前的方式的时间有所增加),但是这种方式把GC的时间给分散了,之前传统GC方式会导致主线程延迟时间长,这种方式能够主线程间断的执行,也在进行垃圾收集任务。应用程序也能够响应用户的输入操作,动画也会变得流畅。(为了页面的流畅度,想尽办法)

每一块GC任务的交错到主线程执行中。

并发式GC(Concurrent)

并发式GC 是指主线程不间断的执行,辅助线程在后台进行GC工作,这是三种技术中最难的一种。主要是javascript堆的状态时刻发生者变化,使gc的工作无效,此外需要考虑读写竞争的关系。主要是主线程和辅助线程会同时读取或修改同一个对象。这种技术的好处是主线程能够自由的执行程序,虽然会有和辅助线程同步的一些开销。

GC任务完全在后台发生,主线程可以自由运行JavaScript。

V8 GC 的现状

如今,V8使用并行scavenging将新生代的垃圾回收工作分发给辅助线程执行。每个辅助线程会收到一些指针,进行处理,这些线程快速地将存活对象拷贝到To-Space。当清理一个对象的时候,scavenging任务必须通过原子性的 读/写/比对清理(compare-and-swap)操作来同步状态;其他的scavenging任务可能会通过不同的路径定位到同一个对象,并也想处理它。无论哪个辅助线程成功完成了对这个对象的操作都会返回并更新对应的指针。每个一个forwarding指针会被保留下来,其他辅助线程可以根据它们找到这个对象的指针进行对应的更新。对无需同步状态的存活对象进行的内存分配,scavenging任务使用线程本地的内存buffer来处理。

并行清扫将清扫工作分布在多个帮助线程和主线程上

Major GC

V8中的MgjorGC是从并发标记开始的。当堆接近一个动态计算的极限时,并发标记任务被启动。每一个辅助线程都会被赋予一些指针,他们会按照发现的对象的所有引用来标记他们发现的每个对象。并发标记完全在后台进行,而JavaScript在主线程上执行。写障碍用于跟踪JavaScript在帮助程序并发标记时创建的对象之间的新引用。

Major GC 采用并发标记和扫除,并行压缩和指针更新。

当并发标记完成后,或者我们达到了动态分配的极限,主线程就会执行快速标记最终化步骤。在这个阶段,主线程暂停开始。这代表了主GC的总暂停时间。主线程再次扫描根部,以确保所有的活对象都被标记,然后和辅助线程一起,开始并行压缩,进行指针更新。并非所有旧空间的页面都有资格进行压缩–那些不符合条件的页面将使用前面提到的自由列表进行扫除。主线程在暂停期间启动并发的扫除任务。这些任务与并行压实任务和主线程本身同时运行–即使JavaScript在主线程上运行,它们也可以继续进行。

Idle-time GC

JavaScript的用户不能直接访问垃圾收集器,它完全是由实现定义的。然而,V8确实提供了一个机制,让嵌入者触发垃圾收集器,即使JavaScript程序本身不能。GC可以发布 “闲置任务”,这些任务是可选的工作,最终还是会被触发。像Chrome这样的嵌入器可能有一些空闲或闲置时间的概念。例如在Chrome浏览器中,以每秒60帧的速度,浏览器大约有16.6毫秒的时间来渲染每一帧动画。如果动画工作提前完成,Chrome可以选择在下一帧之前的空闲时间运行GC创建的这些闲置任务

闲置GC利用主线上的空闲时间,主动进行GC工作


V8中的垃圾收集器自诞生以来已经取得了长足的进步。在现有的GC中加入并行、增量和并发技术是多年的努力,但已经得到了回报,将大量的工作转移到后台任务中。它极大地改善了暂停时间、延迟和页面加载,使动画、滚动和用户交互更加流畅。并行的Scavenger使主线程年轻代垃圾收集总时间减少了约20%-50%,这取决于工作负载。闲置时GC在闲置时可以将Gmail的JavaScript堆内存减少45%。并发标记和扫除使重度WebGL游戏的暂停时间减少了50%。

但这里的工作还没有结束。减少垃圾收集的暂停时间对于给用户提供最佳的网络体验仍然很重要,我们正在研究更先进的技术。除此之外,Blink(Chrome浏览器中的渲染器)也有一个垃圾收集器(叫做Oilpan),我们正在做的工作是改善两个收集器之间的合作,并将Orinoco的一些新技术移植到Oilpan上。

大多数开发人员在开发JavaScript程序时不需要考虑GC,但了解一些内部结构可以帮助你思考内存使用和有用的编程模式。例如,通过V8堆的生成结构,从垃圾收集器的角度来看,短命的对象其实是非常便宜的,因为我们只为收集后存活下来的对象付费。这类模式对很多垃圾收集语言都很有效,不仅仅是JavaScript。

发表评论