Google V8 引擎(以下简称V8)是 Google 的一个开源项目,旨在构建一个高效的 JavaScript 引擎,是Google特别为Chrome高速运行网页应用(Web App)而开发的。同时,它可以作为一个独立的库被嵌入到其他应用程序中,以提高软件的灵活性和可扩展性。目前,V8引擎由于其高效的性能吸引了越来越多的关注。
Google的好几款应用都是基于JavaScript,其中包括Gmail电子邮件服务、Google Maps地图数据服务、以及Google Docs office套件。这些应用表现出的速度不仅受到服务器、网络、渲染引擎(Rendering Engine)等因素的影响,同时也受到JavaScript本身执行速度的影响。而Google研发的V8 JavaScript引擎通过采取一系列关键技术,大大提升了JavaScript的执行速度,关键技术包括JIT编译 (JIT Compile)、垃圾回收(Garbage Collection)、内嵌缓存(Inline Cache)、隐藏类等。在本文中,重点对V8的垃圾回收引擎进行简单介绍。
什么是垃圾回收
JavaScript的性能是关系到Chrome价值的一个重要方面,因为它涉及到用户能否获得一个流畅的使用体验。从Chrome41版本开始,通过在一些小的、零散的空闲时间内执行昂贵的内存管理操作,V8提高了Web应用程序的响应能力。
许多脚本语言引擎,如V8引擎,对运行的应用程序实施动态的内存管理。引擎可以定期检查分配给应用程序的内存,确定哪些数据不再需要,并清除出来,以腾出内存空间。这个过程被称为垃圾回收。垃圾回收可以大幅简化程序的内存管理代码,降低程序员的负担,减少因长时间运转而带来的内存泄露问题。
什么时候执行垃圾回收
Chrome 41版本包括了一个针对渲染引擎的任务调度器(Task Scheduler),以确保Chrome浏览器一直保持响应和流畅,任务调度器使延迟敏感的任务拥有更高的优先级。为了实现这一目标,任务调度器需要获取多种信息,包括系统的繁忙程度,哪些任务需要被执行,以及这些任务的紧迫程度。在此基础上,任务调度器可以评估Chrome什么时候可能空闲,以及预计会空闲多久。
举一个简单的例子,当Chrome在网页上播放一段视频的时候。视频在屏幕上的更新速率为60帧每秒(FPS),即Chrome大概每次有16.6ms的时间来进行更新。这样,Chrome将在前一帧显示后立刻启动当前帧的工作,为当前帧执行输入和渲染任务。如果Chrome完成所有这些工作用时不到16.6ms,在剩下的时间内, Chrome浏览器处于闲置状态。此时,调度器通过调度一些特殊的空闲任务(Idle Tasks)可以使Chrome能够利用这些空闲时间。
空闲任务是一些特殊的低优先级任务,它们在调度器确定Chrome空闲的时候才被运行。空闲任务拥有一个截止时间,截止时间是调度器估计Chrome能够保持空闲的时间。例如,在视频播放的例子中,截止时间是下一帧应该开始的时间。在其他情况下,截止时间可能是下一个待处理任务计划运行的时间,通常其有一个50ms的上限,以确保Chrome浏览器对突然的用户输入仍能保持响应。空闲任务的截止时间能够被用来估算在不会造成用户输入响应延迟的情况下Chrome可以完成的工作量。
垃圾回收就是一种典型空闲任务,其隐藏在一些关键的、延迟敏感的任务背后。这意味着这些垃圾回收任务是在没有影响用户体验的情况下,在Chrome的空闲时间内被执行。为了理解V8是如何做到这一点,下面我们对V8目前的垃圾回收策略进行深入了解。
深入了解V8的垃圾回收引擎
V8采用了一个分代(Generational)垃圾回收器,将内存堆分割为新生代(Young Generation)和老生代(Old Generation)。新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。由于绝大多数对象的生存期很短,只有少数对象的生存期较长,这种分代策略能使垃圾回收器对新生代对象执行一些规则的、小的垃圾回收(被称为Scavenge)。V8分别对新生代对象和老生代对象使用不同的垃圾回收算法来提升垃圾回收的效率。
对象起初会被分配在新生代内存区(通常很小,只有1-8 MB,具体根据任务分配)。大多数的对象被分配在这里,这个区域很小但是垃圾回收特别频繁。新生代使用半空间(Semi-space)分配策略,其中新对象最初分配在新生代的活跃半空间内。一旦半空间已满,一个Scavenge操作将活跃对象移出到其他半空间中,被认为是长期驻存的对象,并被晋升为老生代。一旦活跃对象已被移出,则在旧的半空间中剩下的任何死亡对象被丢弃。
因此新生代对象的Scavenge操作的持续时间取决于新生代中活跃对象的数量。在大部分新生代对象活跃时间不长的情况下,一个Scavenge操作非常快(<1ms)。然而,如果大多数对象都需要被Scavenge的时候,Scavenge操作的持续时间显然会更长。
Scavenge操作对于快速回收、紧缩小片内存效果很好,但对于大片内存则消耗过大。因为Scavenge操作需要出区和入区两个区域,这对于小片内存尚可,而对于超过数MB的内存就开始变得不切实际了。老生代所保存的对象大多数是生存周期很长的甚至是常驻内存的对象,而且老生代占用的内存较多,通常包含有上百MB的数据。因此,V8在老生代中的垃圾回收采用标记-清除(Mark-Sweep)和Mark-Compact相结合的策略。
当老生代中的活动对象增长超过了一个预设的限制的时候,将对堆栈执行一个大回收。老生代垃圾回收使用Mark-Sweep策略,其采用了几种优化方法来改善延迟和内存消耗。标记时间取决于必须标记的活跃对象的数目,对于一个大的web应用,整个堆栈的标记可能需要超过100ms。由于全停顿会造成了浏览器一段时间无响应,所以V8使用了一种增量标记的方式标记活跃对象,将完整的标记拆分成很多小的步骤,每做完一部分就停下来,让JavaScript的应用线程执行一会,这样垃圾回收与应用线程交替执行。V8可以让每个标记步骤的持续时间低于5ms。
由于标记完成后,所有对象都已经被标记,即不是活跃对象就是死亡对象,堆上有多少空间已经确定。清除时,垃圾回收器会扫描连续存放的死对象,将其变成空闲空间。这个任务是由专门的清扫线程同步执行。最后,为减少老生代对象产生的内存碎片,还要执行内存紧缩(Memory Compaction)。这个任务可能是非常耗时的,并且仅当内存碎片成为问题的时候才进行。
- 总之,有四个主要的垃圾回收任务:
新生代对象的Scavenge,这通常是快速的;
通过增量方式的标记步骤,依赖于需要标记的对象数量,时间可以任意长;
完整垃圾回收,这可能需要很长的时间;
带内存紧缩的完整垃圾回收,这也可能需要很长的时间,需要进行内存紧缩。
为了在空闲时段执行这些操作,V8给任务调度器公布垃圾回收空闲任务。当这些空闲任务运行时,它们被提供一个需要完成的截止时间。 V8的垃圾回收空闲时间处理程序为了减少内存消耗,评估哪些垃圾回收任务应该被执行,同时紧盯截止时间以避免在帧渲染过程中出现用户输入响应延迟。
如果应用的内存分配率显示在下一个期待的空闲时间之前新生代内存区已经满了,垃圾回收器将执行新生代对象的Scavenge操作。此外,它还会计算最近的Scavenge操作所花费的平均时间,可以帮助预测未来Scavenge操作的持续时间,并确保它不会超出空闲任务的截止时间。
当老生代中活跃对象的数量接近堆栈限制的时候,增量标记开始。增量标记的步数与需要标记的字节数成线性比例。根据测得的平均标记速度,垃圾回收空闲时间处理程序尝试尽可能地为一个垃圾回收任务安排多的标记工作。
如果老生代内存区几乎满了,此外任务的截止时间足够长可以完成回收任务,在一个空闲任务中将调度一个完整的垃圾回收任务。回收任务的执行时间是标记速度乘以分配对象的数目。带内存紧缩的完整垃圾回收只有在Chrome空闲足够长的时间才被执行。
性能评价
为了评价空闲时间运行垃圾回收任务的影响,V8使用Chrome的性能遥测基准框架,以评价加载热门网站时页面滚动的平滑度。选择Linux工作站上排名前25位的网站,以及Android Nexus 6智能手机上的一些典型的移动网站,在两种情况下打开流行的网页(包括一些复杂的web应用,如Gmail,Google文档和YouTube),滚动其内容需要几秒钟。 为了保证流畅的用户体验,Chrome的目标是滚动显示保持在60 FPS。
下图显示了空闲时间垃圾回收的比例。相比Nexus 6,工作站因为拥有更好的硬件,导致总体上拥有更多的空闲时间,从而导致其在空闲时间内拥有一个更高的垃圾回收比例(43%,而Nexus6为31%),工作站的jank指标比Nexus 6也高了7%。
事实上,垃圾回收是一个复杂的过程。Google V8的垃圾回收方法能够自动完成垃圾回收,大大减轻了应用开发者的负担,能够让他们集中精力于更重要的事情上。尽管目前V8的垃圾回收引擎并不完美,仍存在一些性能问题而且偶尔会出现奇怪的现象,但我们还是很高兴地看到其正在变得更好,Google的工程师Hannes Payer和Ross McIlroy在其博客中说到,他们一直在努力对垃圾回收做更多的改进。