v8GC

  • 朴灵_ 大师 的两篇文章,贴在了一起。
  • 前言

本文基于我在 Node.js 基金会主办的 Node Live Beijing 的分享,因为微软准备了一个翻译,现场临时把英文的分享改成中文了,有点磕巴。加上分享时长有限很多地方没有展开,于是现在事后来用文字再详细写一下这个题目。

本文是该系列的第一篇,第二篇请点这里:解读 V8 GC Log(二): 堆内外内存的划分与 GC 算法,第三篇还在编写中。

背景:阿里的 Node.js 应用

阿里是国内的大公司里使用 Node.js 较多的一家,目前大部分的场景是在阿里内部的一系列分布式系统/服务/中间件之上,使用 Node.js 来开发原来使用 PHP/Java 开发的应用层的程序。在解决回调维护的问题上,大多使用 ES6 generator 来编写视觉上同步的代码。目前阿里基于 Koa 开发了一个企业级框架来集成运维接入、基础设施接入等各部门内共同的需求,各个部门再在此之上根据各自的业务和技术架构定制不同的 Web 框架。

应用层的主要任务是与各种数据源和底层系统使用基于 HTTP 或者 RPC 的 API 交流,加上一定的业务逻辑,对数据做适当的处理后,渲染 HTML 或者拼装 JSON,通过负载均衡服务等设施与前端/客户端沟通。按照 MVC 的划分,这一层中比较复杂的 Model 和 Controller 一般变动不大,对稳定性、事务可能会有较多的要求,适合使用 Java 这类技术开发。而业务逻辑和模型较为简单,需求变化十分频繁的 View 和部分 Controller,如果沿用下层系统的技术,牺牲开发效率和灵活性,继续追求用严格的检查和重重约束保障的稳定性,就显得不太划算了。例如,阿里过去前后端合作的边界常常在后端模版这一层,前端依然要动归属于后端仓库的代码,却只能用有限的 DSL 来满足展现逻辑需求的变化。即使是只提供 API 的地方,后端在应用路由、Controller 与对接 View 的部分囿于框架的重重约束、检查和封装规定,框架的优势没有显现出来,反而显得碍手碍脚,影响开发效率。这一层需要能够快速地修改和灵活地定制以满足运营和业务的需求,减少仪式化的代码(boilerplate code),主要的瓶颈在 I/O 上,而且跟大量使用 JavaScript 的前端/客户端团队联系紧密。在没有事务等复杂要求,或者能够通过服务封装掉复杂要求的场景下,使用 Node.js 来开发这部分应用,开发、测试、部署、冷启动的速度更快,与前端交流更方便,甚至可以直接合并职能到同一拨人身上,将前后端合作的边界后移到变化没那么频繁的 Model 层。

这种架构离不开阿里内部一系列成熟的底层系统,这些静态语言开发的系统在 ACID、分布式和计算密集等场景下很好地弥补了 Node.js 的短板,与语言灵活、开发速度快的 Node.js 在一起,形成了一个多语言(polyglot)的生态系统,各取所长。除了国内的阿里,国外的 Paypal、Netflix 等体量比较大的公司也是出于类似的考虑使用 Node.js 来做类似的事情(有趣的是,这些公司不少也是 Java 起家的,而且大多也像阿里一样,有比较成熟的底层系统)。

虽然选中 Node.js 开发应用看中的是它的灵活和开发效率,并且处理的是逻辑相对简单的部分,但是用于开发一个企业的线上系统,监控、日志、性能调优等一系列配套是一定要跟上的,测试也是必不可少的。在 Node.js 推广的过程中,不少架构师质疑 Node.js (或者说,使用 Node.js 开发的工程师们)在这方面的短板,认为它难堪重任。特别是由于 JavaScript 在后端的历史较短,大部分 JavaScript 的相关工具针对的并不是后端这种长时间运行的场景,Node.js 在这方面性能调优和分析的手段有限(比如,“Node.js 应用出现了内存泄漏怎么办?”)。一个典型现象是,Java 程序员们大多对 JVM 和 GC 都有一定的了解,面试中也经常会出现相关的问题,而 JavaScript 的程序员们大部分来自前端背景,写的程序并不在长时间运行的后端场景下,因此相对而言在这方面的了解较少,Node.js 开发的面试中也较少会问 V8 相关的问题。

在这种背景下,诞生了 alinode。我们是从为阿里内部提供 Node.js 相关性能服务起家的,产品是一个改造过的 Node.js 运行时(和 LTS 保持兼容),用于提供一系列企业级应用需要的性能管理支持,以及一个配套的 SaaS 平台,提供性能监控管理、分析优化、安全漏洞提示以及一系列 Node.js 周边服务。在搬家到阿里云之后,也开始对外部客户提供服务。

为什么要了解 V8 的垃圾回收日志?

在后端的长时间运行场景下,对虚拟机有垃圾回收(Garbage Collection,下称 GC)的语言,GC 是一个需要重点关注的方面,它不仅影响内存使用的增长,也会在运行不畅的时候影响 CPU 的利用,进而影响程序的可用性和响应速度。JavaScript 作为一个来自客户端场景的语言,在 GC 方面的调优工具存在一定的短板。JavaScript 调优必备的 Chrome Devtools 针对前端场景,提供了 CPU Profile、Heap Snapshot 和 Heap Timeline 三种工具,却没有将 GC 日志直接暴露出来的功能,也没有相应的分析功能,相关的文档更是缺乏,日志的格式、字段的意义都免不了要阅读 V8 的源代码才能理解。这篇文章的目的之一,也是补足这方面的空白。

那么,GC 日志的主要使用场景有哪些呢?

  • 由于 V8 在做 GC 时,代码的执行会有一定的停顿(在 V8 引入并行 GC 前更为严重),如果代码中出现了对象的频繁分配与回收,那么程序将会花费不少时间在 GC 的停顿上,影响应用的响应速度。GC 日志能够展现出 GC 停顿发生的时间、时长与模式,并且指明大约是哪种对象(新对象、老对象、大对象、代码、隐藏类?)的 GC 导致了停顿、在 GC 的哪一步中耗时最长,帮助你确定应用的性能问题是否与 GC 有关,如果有,那么还能帮助你追溯到问题的来源。
  • 当代码中存在内存泄漏时,GC 日志会有较为明显的特征。一条内存使用的折线只能告诉你发生了内存泄漏,而 GC 日志中多维度的信息能够告诉你堆上的哪个空间发生了泄漏,泄漏的模式如何,有什么规律。线下修复泄漏后再将新代码上线,重新做一次 GC 日志,对比一新代码下 GC 的模式以及各空间的变化规律,也能帮助你确定新的代码修复了泄漏,而不是治标不治本,埋藏了一个定时炸弹
  • 虽然 Heap Snapshot 和 Heap Timeline 能为你指出具体什么对象(甚至哪段代码)出现了内存泄漏,但在一个较为复杂的应用里,直接看这两个数据容易被细节淹没,迷失在微观的视图里。GC 日志能够帮助你形成一个宏观的印象,定位出问题代码的方位,并且能起到排除的作用。抓内存泄漏就像破案,只有犯罪现场的指纹和DNA,但案犯没有前科,数据不在系统里,没有其他线索来找到嫌疑人来比对,那么破案依然是十分困难的。如果工程师不认识堆上的可疑对象(比如泄漏的函数或者对象的构造函数没有命名,像asyncOperation(function(err, data) {})里头的回调或var Class = function(){}这样的构造函数,或者对象构造函数命名重复太多导致无法对应到位置),只看堆上的数据,也会一头雾水。正如警探们需要从作案动机、作案时间、现场位置、作案工具等线索入手,排除有不在场证明的嫌疑人,逐步定位目标,再用指纹和DNA证明某个嫌疑人就是案犯一样,我们也需要一切能获取到的线索,才能排除噪音,逐步缩小目标范围,定位到罪魁祸首。

V8 GC 概述

为了避免跑题,这里不会讲过多的算法和代码细节,到能看懂 V8 垃圾回收日志的程度即可。为了保证我们在一个起跑线上,这里先介绍一下什么是 GC。

什么是 GC

Garbage Collection 是在内存中回收垃圾(Garbage)的过程,所谓的垃圾,就是内存中不会再被使用的部分。在 C/C++ 等语言里,我们需要手动分配(malloc/new)和释放(free/delete)内存,虽然人工调优的代码能够实现细粒度的控制,充分榨干资源,但人工的介入也意味着失误率的提高,如果出现了管理不当,便会发生引用错误(太早释放,悬挂引用)和内存泄漏(忘记或太晚释放)等各种问题。有时判断何时应该释放内存需要全局的知识,而释放的决定需要在局部作出,本身就是一个困难的问题。同时,手工管理内存通常免不了要在 API 中体现一定的约束,增加了模块之间的耦合度。管理不当的内存还可能引发安全风险(就像不把重要文件塞进碎纸机,直接拿去循环利用)。这些问题一般统称为内存安全问题,给开发者增加了一定的思维负担。

针对内存安全问题,系统级语言为了减少运行时的开销,保留细粒度的控制,一般会使用特定的写法或者语言特性/库来保证安全(如 C++ 的 RAII/智能指针、Rust 的 ownership)。而那些本来就有意在虚拟机上进行抽象的语言,如 JavaScript、Java 等,则常常在语言的运行时中内置垃圾回收的机制,使得开发者在编写代码时不需要关心内存管理。这些运行时能够自动分析出不会再被使用的内存并加以回收利用,而垃圾回收器作为一个能够获取全局信息的存在,也能比较好地解决何时释放内存这个常为全局性的问题。虽然开发者失去了一定程度上的控制和优化能力,但得到了更高的开发效率,更低的耦合度和一定程度上的内存安全保证,对于许多偏应用的开发来说是一个合适的权衡选择。

V8 的 GC 概述

JavaScript 的标准 ECMAScript 里没有对GC做相关的要求,因此 JavaScript 的 GC 机制完全由引擎决定。这篇文章里的内容主要基于 V8 4.5.103.35(即当前的 LTS Node.js 4.x 使用的版本)。Node.js 下一个 LTS 升级到了 V8 5.x,其中的 GC 引入了一系列改进(主要是并行/并发 GC 方面的改进,这些改造统称为 orinoco,参见 V8 的博客),但 GC 日志大体格式还是与之前差不多,整体的 GC 策略也与原来类似,这篇文章的大部分内容还是适用的。

JavaScript 中通常会存在一些根对象(比如浏览器环境下的 window,Node 环境下的 global,JavaScript 的内置对象,当前调用栈上的本地变量和函数参数等),V8 的垃圾回收器回收的“垃圾”,通常都是无法从根对象沿着引用遍历到的对象,即不可达(unreachable)的对象。V8 中的垃圾回收出现在程序需要分配更多内存,而已分配的内存(至少是新对象应该被放置到的那部分内存)不够用的时候,因此 V8 的垃圾回收通常是由内存分配的需求触发的(有一部分由内存使用量的阈值触发,详情参见本系列的第二篇文章)。V8 在分配内存失败后,会先尝试一次 GC 后再分配,如果还是失败,再尝试一次 GC 和分配。这两次 GC 的触发原因在日志里叫做 allocation failure。如果第二次 GC 后依然无法分配出足够的内存,V8 会进行一次更彻底的 GC,在回收弱引用的时候(弱引用的对象不介意何时被 GC 回收,在计算可达性的时候弱引用不算可达),强制触发相关的 GC 回调,这次 GC 的触发原因在日志里叫做 last resort gc。如果这次 GC 后依然分配失败,V8 将会由于进程内存用尽(process out of memory)退出。

按照 V8 的官方文档,它拥有一个 stop-the-world, generational, accurate garbage collector。这些设计元素决定了 V8 的垃圾回收日志是我们现在看到的样子,如果不清楚它们的含义便很难读懂日志,因此有必要做一定的了解。

Stop-the-world

Stop-the-world 找不到对应的中文翻译,它指的是在执行垃圾回收的过程中,运行时会暂停程序的执行:由于程序的执行可能会产生新对象,或者修改对象的引用,造成对象的生存状态改变,假如没有准备相应的手段确保程序执行时不会修改正处于回收过程中的对象,就必须暂停执行来保证对象能够被安全回收。这就好比在清洁阿姨上门打扫的过程中,如果你家里还有熊孩子在活动,就可能突然产生新的垃圾(打碎个花瓶啥的),或者原来是垃圾的东西突然被熊孩子们当成宝拿走,给阿姨添麻烦。

Stop-the-world 是暂停时间最严格的,除此之外还有:

  • 增量式 GC(incremental),即程序不需要等到垃圾回收完全结束才能重新开始运行,在垃圾回收的过程中控制权可以临时交还给运行时进行一定的操作
  • 并发式 GC(concurrent),即在垃圾回收的同时不需要停止程序的运行,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作

此外还有另一种优化(可以与上面的搭配),并行式 GC(parallel),即在 GC 的时候使用多个线程一起来完成 GC 工作,提高单位时间的 GC 吞吐量。

一般能在垃圾回收的过程中修改对象的存在,不管是垃圾回收器本身还是运行时,或者是正在执行的程序,都统称为 mutator(翻译不详)。这两种对程序执行更宽松的 GC,都需要运行时从整体设计上保证 mutator 不会在垃圾回收的过程中与垃圾回收器同时修改对象,造成无法预料的后果。比如清洁阿姨打扫一个房间的时候可以把房间的门先关上,这样熊孩子就进不来了,但熊孩子们依然可以在屋子里的其他地方活动。在程序运行的同时进行垃圾回收虽然可能导致垃圾回收的周期变长(即降低了垃圾回收单位时间内的吞吐量),但是可以降低每次暂停的时间,进而提高程序的响应效率,这对活跃在交互式应用的 JavaScript 来说至关重要。

虽然 V8 的文档说它的垃圾回收器是 stop-the-world 的,但其实 2011 年已经引入了增量式 GC(主要发生在 Mark-Sweep/Mark-Compact 的 marking 阶段),所以在阅读 V8 垃圾回收日志的时候可以看到类似 incremental_marking_throughput 的输出。V8 目前对 GC 的改造也在朝着增量、并发、并行的方向前进(即前面提到的 orinoco 计划),对 GC 的不同阶段添加不同的优化。

弱分代假设(The Weak Generational Hypothesis)

在 GC 的研究中有一个广为人知的现象,叫做弱分代假设(The Weak Generational Hypothesis),许多 GC 的研究者/开发者们观察到:

  1. 大多数对象死的早
  2. 那些死得不早的对象,通常倾向于永生

基于这个现象,许多垃圾回收器将对象进行分代,对生存周期长度不同的对象采用不同的垃圾回收算法。那些新分配的对象会得到更多的“关照”,检查它们是否已经无人引用可以回收。而那些多次检查后依然顽强生存下来的对象会被晋升到下一代,之后被检查的频率也会越来越低。这样,垃圾回收器可以尽快地回收掉大量短命的对象,节省了内存,又避免了频繁检查那些按照弱分代假设倾向于永生的老对象,节省了时间。

V8 的垃圾回收器也是基于弱分代假设将对象进行分代回收的一员,它将 JavaScript 对象分成了两代:新生代(new generation,或称 young generation)和老生代(old generation),大部分的新对象都诞生在新生代,使用拿空间换时间的 Scavenge 回收策略,快速回收内存。在新生代中经历了两次 GC 还没有被回收掉的对象,会在第二次回收时被晋升(promote)到老生代,老生代使用 Mark-Sweep-Compact 回收策略,在空间和时间中取得平衡,并减轻单纯的 Mark-Sweep 引入的内存碎片问题。因此在垃圾回收日志中,我们会看到一些 new,old,以及 promotion 相关的字段(更多介绍请看本系列的第二篇文章)。

准确式 GC (Accurate GC)

虽然 ECMAScript 中没有规定整数类型,Number 都是 IEEE 浮点数,但是由于在 CPU 上浮点数相关的操作通常比整型操作要慢,大多数的 JavaScript 引擎都在底层实现中引入了整型,用于提升 for 循环和数组索引等场景的性能,并配以一定的技巧来将指针和整数(可能还有浮点数)“压缩”到同一种数据结构中节省空间。

在 V8 中,对象都按照 4 字节(32 位机器)或者 8 字节(64 位机器)对齐,因此对象的地址都能被 4 或者 8 整除,这意味着地址的二进制表示最后 2 位或者 3 位都会是 0,也就是说所有指针的这几位是可以空出来使用的。如果将另一种类型的数据的最后一位也保留出来另作他用,就可以通过判断最后一位是 0 还是 1,来直接分辨两种类型。那么,这另一种类型的数据就可以直接塞在前面几位,而不需要沿着一个指针去读取它的实际内容。在 V8 的语境内这种结构叫做小整数(SMI, small integer),这是语言实现中历史悠久的常用技巧 tagging 的一种。V8 预留所有的字(word,32位机器是 4 字节,64 位机器是 8 字节)的最后一位用于标记(tag)这个字中的内容的类型,1 表示指针,0 表示整数,这样给定一个内存中的字,它能通过查看最后一位快速地判断它包含的指针还是整数,并且可以将整数直接存储在字中,无需先通过一个指针间接引用过来,节省空间。

由于 V8 能够通过查看字的最后一位,快速地分辨指针和整数,在 GC 的时候,V8 能够跳过所有的整数,更快地沿着指针扫描堆中的对象。由于在 GC 的过程中,V8 能够准确地分辨它所遍历到的每一块内存的内容属于什么类型,因此 V8 的垃圾回收器是准确式的。与此相对的是保守式 GC,即垃圾回收器因为某些设计导致无法确定内存中内容的类型,只能保守地先假设它们都是指针然后再加以验证,以免误回收不该回收的内存,因此可能误将数据当作指针,进而误以为一些对象仍然被引用,无法回收而浪费内存。同时因为保守式的垃圾回收器没有十足的把握区分指针和数据,也就不能确保自己能安全地修改指针,无法使用那些需要移动对象,更新指针的算法。

准确式的 GC 避免了保守式 GC 带来的弊端,能够尽早无遗漏地回收内存,并且能够在 GC 过程中移动对象以缓解内存碎片问题或使用对这方面有需求的算法(如第二篇文章中将会介绍的 Scavenge 算法)。

小结

阿里的 Node.js 应用建立在一系列基础架构之上,与其他技术各取所长,适应了应用层快速应对运营和业务需求变化的需要。但它背后的 JavaScript 以及 V8 引擎在后端长时间运行的场景下积累的优化经验还有待完善。V8 的 GC(Garbage Collection,垃圾回收)设计针对的是前端富交互的场景,相应的开发者工具功能和文档都较少,相比 HotSpot JVM 等技术在后端的积累,还不够成熟。因此本系列文章尝试介绍 V8 GC 的设计与实现,帮助读者不用阅读 V8 源代码,也能理解 V8 GC 日志并用于解决 Node.js 应用的性能问题。

V8 的垃圾回收器是 stop-the-world/incremental/concurrent/parallel 兼而有之的,对垃圾回收的不同阶段做了不同的优化。它将 JavaScript 对象分为趋向于频繁诞生于死亡的新生代与常驻内存的老生代,使用不同的策略进行回收,来降低垃圾回收的开销。此外,V8 通过 SMI 的结构快速区分指针和整数,实现准确式的 GC,以便尽早无遗漏地回收内存。

 

解读 V8 GC Log(二): 堆内外内存的划分与 GC 算法

V8 堆外内存的划分

在 V8 中,大部分的对象都直接在堆上创建(虽然 V8 的优化编译器会将一些静态分析后确定完全本地的对象放到栈上,即所谓的逃逸分析 = Escape Analysis,此处不赘述)。V8 将堆划分成了几个不同的空间(space,以下 以 4.x 为准,老版本有更多),其中新生代包括一个 New Space,老生代包括 Old Space,Code Space,和 Map Space,此外还有一个特殊的 Large Object Space 用于存储特别大的对象(也属于老生代)。V8 的用户还可以自行维护堆外内存,并将这些内存的数据上报给 V8,帮助 V8 调整 GC 的策略和时机。

新生代的 New Space 使用 Scavenge 算法回收,而老生代的几个 space 都使用 Mark-Sweep-Compact 回收。内存按照 1MB 分页,并且都按照 1MB 对齐。新生代的内存页是连续的,而老生代的内存页是分散的,以链表的形式串联起来。Large Object Space 也分页,但页的大小会比 1MB 大一些。

每一个 Space 里的内存页开头都是一个 header,里面包括

  • 各种元数据和 flag(比如本页属于哪个空间),GC 需要使用的各种统计数据,GC 各个阶段在本页的进展状况等
  • 一个 slots buffer,记录了所有指向本页内对象的指针,以节省回收时的一些扫描操作。
  • 一个 skip list,将本页划分为多个区(region)并维护各个区的边界,用于快速搜索页上的对象

紧跟着 header 的是一个 bitmap,上面的每个 bit 对应页上的一个字,用于后面会介绍到的 marking。前面的部分按 32 个字对齐后,剩余的空间才是用于存储对象的。

堆内空间:New Space(新生代)

正如前面的弱分代假设所说,大部分的对象都死得早,因此大部分的对象都属于新生代,诞生在这里。放在其他地方分配的例外主要包括:

  • 对象的布局结构信息在 Map Space 分配
  • 编译出来的代码在 Code Space 分配
  • 太大不能直接放进来的对象在 Large Object Space 分配
  • 创建的对象常常被晋升到 Old Space 的函数,在这些对象达到一定的生存率(survival rate)之后它再创建的对象会被自动在 Old Space 分配

出于垃圾回收算法(Scavenge)的需要,New Space 被平分成两半(两个 semispace),任一时刻只有一半被使用。在垃圾回收日志中看到的 new 和 semispace 相关的字段就与 New Space 有关。

堆内空间:Old Space(老生代)

Old Space 保存的是老生代里的普通对象(在 V8 中指的是 Old Object Space,与保存对象结构的 Map Space 和保存编译出的代码的 Code Space 相对),这些对象大部分是从新生代(即 New Space)晋升而来。

V8 4.x 引入了一个新的机制 pretenuring,来应对弱分代假设不成立的情况。当 V8 探测到某些函数创建的对象有很高的存活率率(survival rate),经常晋升到老生代(存活超过2次)的时候,下次这些函数再创建的对象将会直接在 Old Space 分配。这样就省略了这些对象在 New Space 第一次 GC 的时候大量复制到另一个 semispace,第二次 GC 又大量复制到 Old Space 的开销。即使猜错了,反正下一次老生代 GC 的时候这些对象也会被回收走,影响不大。

在垃圾回收日志中看到的 old 相关的字段就与 Old Space 有关,而 survival 和 promoted 相关的字段则与对象在新老生代之间的迁移有关。

堆内空间:Large Object Space(老生代)

当 V8 需要分配一个 1MB 的页(减去 header)无法直接容纳的对象时,就会直接在 Large Object Space 而不是 New Space 分配。在垃圾回收时,Large Object Space 里的对象不会被移动或者复制(因为成本太高)。Large Object Space 属于老生代,使用 Mark-Sweep-Compact 回收内存。

堆内空间:Map Space(老生代)

所有在堆上分配的对象都带有指向它的“隐藏类”的指针,这些“隐藏类”是 V8 根据运行时的状态记录下的对象布局结构,用于快速访问对象成员,而这些“隐藏类”(Map)就保存在 Map Space。Map Space 也属于老生代,所以也使用 Mark-Sweep-Compact 回收内存。当一个 Map 不再被任何对象引用的时候(即不再有相应结构的对象存在的时候),它也会被回收掉。

堆内空间:Code Space(老生代)

在最近针对小内存设备的 ignition 解释器推出之前,V8 长期以来都只有编译器(JavaScript 是脚本语言不代表它一定会被解释执行 :)),包括一个无优化的基线编译器(baseline compiler)和一个优化编译器(optimizing compiler,目前默认是 CrankShaft,还有一个升级版的 TurboFan)。这些编译器针对运行平台架构编译出的机器码(存储在可执行内存中)本身也是数据,连同一些其它的元数据(比如由哪个编译器编译,源代码的位置等),放置在 Code Space 中。在 Node.js 开发中比较常见的是模板引擎编译渲染函数后,V8 为这些函数编译出的机器码会出现在这里。注意 JavaScript 代码中的函数一开始只会被解析成抽象语法树,只有在它第一次执行的时候才会被真正编译成机器码,并且在程序的执行过程中会根据统计数据不断进行优化和修改。

Code Space 属于老生代,垃圾回收的算法也是 Mark-Sweep-Compact(实际在 V8 的源代码里 Code Space 跟 Old Space 用的是同一个类)。这些代码同样会被引用,当引用消失后(即没有办法再调用这段代码的时候)也会被回收。

堆空间页管理抽象:Memory Allocator

前面说到,V8 中的堆划分为空间,而空间又划分为页,这些内存页里的内存是怎么来的呢?V8 为此抽象出了 Memory Allocator,专门用于与操作系统交互,当空间需要新的页的时候,它从操作系统手上分配(使用mmap)内存再交给空间,而当有内存页不再使用的时侯,它从空间手上接过这些内存,还给操作系统(使用munmap)。因此堆上的内存都要经过 Memory Allocator 的手,在垃圾回收日志中也能看到它经手过的内存的使用情况。

堆外内存:External memory

除了堆上的内存以外,V8 还允许用户自行管理对象的内存,比如 Node.js 中的 Buffer 就是自己管理内存的。这些叫做外部内存(external memory),在垃圾回收的时候会被 V8 跳过,但是外部的代码可以通过向 V8 注册 GC 回调,跟随 JS 代码中暴露的引用的回收而自行回收内存,相关信息也会显示在垃圾回收日志中。外部内存也会影响 V8 的 GC,比如当外部内存占用过大时,V8 可能会选择 Full GC(包含老生代)而不是仅仅回收新生代,尝试触发用户的 GC 回调以空出更多的内存来使用。

由于外部代码需要将自己使用的内存通过 Isolate::AdjustAmountOfExternalAllocatedMemory告知 V8 才能记录下来,假如外部代码没有做好上报,就可能出现进程 RSS(Resident Set Size,实际占用的内存大小)很高,但减去垃圾回收日志中 Memory Allocator 分配的堆内存和 V8 记录下的外部内存之后,有很大一部分“神秘消失”的现象,这个时候就可以定位到 C++ addon 或者是 Node.js 自己管理的内存里去排查问题了。

GC 算法

新生代:Scavenge

前面说到,V8 中的新生代对象使用的是 Scavenge 算法进行垃圾回收的。这是一种典型的以空间换时间的垃圾回收算法,基本思路就是将内存分成两半,任一时刻只有一半(semispace)被使用,使用中的叫做 to space,不被使用的叫做 from space。当程序需要创建新对象,而 New Space 的空间不够用时,Scavenge 就会启动,先调换两个 semispace,这样充满待清理对象的就是 from space 了。然后将根对象以及一些从特定集合可达的对象先复制到 to space,接下来从这些对象开始做宽度优先扫描,找到所有存活(可达)的对象,之前已经存活过一次的晋升到 Old Space,其他的复制到 to space 去,并在 from space 原来的位置留下一个转发地址(forwarding address)。这样当复制结束后,to space 就充满了存活的对象,而 from space 就可以当成被清空了,下次再 GC 的时候可以直接拿来重新使用。由于对象存活两次就会晋升,所以下次 GC 的时候就不需要放在这次残存在 from space 的转发地址了,对于还没有死的对象直接扫描外来引用,并更新为对象迁移到 Old Space 之后的地址即可。

这种方法的好处是实现起来简单,不需要考虑空间中死亡对象留下的空洞,每次 GC 后存活的对象自然被整理成连续的一块,而且因为做的是宽度优先搜索,临近的对象大多有一定的联系,提高了 cache locality。由弱分代假设可知,新生代的对象分配和 GC 会十分频繁,并且每次 GC 后存活下来的对象应该只有比较少的一部分。在分配时,我们只需要挪动一下记录 semispace 顶部的指针(allocation pointer),空出适当的空间来用即可(称为 bump-pointer allocation),只要 New Space 还有充足的空间不需要引发 GC,这个过程就相当廉价。在 GC 时,由于活下来的对象一般只有一小部分,需要扫描和复制的量也应该是不大的。由于 New Space 是连续的,这种 GC 方法保证了 New Space 中的对象总是按照分配时间排序,只要记录 GC 后 allocation pointer 的位置(age mark),在下一次 GC 时就可以直接确定该位置以下的存活对象活过了两次 GC,可以晋升。因此,这种算法很好地满足了新生代的需要。由于 New Space 很小(一般在 1~20 MB 左右,V8 会根据程序的运行状态自动调整),V8 可以在新生代 GC 的时候 stop-the-world,而不对程序的运行造成太大的影响(新生代 GC 一般在 0~3ms 内,V8 设计的目标在 1ms 以下,超过 1ms 的一般是 bug 或者用户代码发生了内存问题)。

Scavenge 的局限性

Scavenge 只适用于新生代这种对象生死频繁,并且整体内存使用量不大,可以忍受浪费多一倍空间的情况。对于对象多长驻,而且空间会越来越大的老生代来说,这种算法显然就划不来了,毕竟复制对象这种操作(V8 用的是 memcpy)在量大的时候本身也是比较昂贵的,扫描大量的引用也会对性能产生影响。于是对于老生代,我们需要其他的垃圾回收策略,V8 选择的是后文所说的 Mark-Sweep/Mark-Compact。

写屏障(write barrier)

在介绍老生代的 GC 算法之前,我们还需要补充一个问题。由于我们想回收的是新生代的对象,只需要检查指向新生代的引用,那么在跟随根对象->新生代或者新生代->新生代的引用时,我们可以放心走下去。而对于新生代->老生代或者根对象->老生代的引用,如果选择跟随,要是中间九转十八弯,扫遍了整个堆,花费的时间就划不来了。但是如果不沿着扫描,万一新生代里有对象只有从老生代过来的引用怎么办呢?如果不能确定老生代没有对象指向它,我们就不能放心地回收掉这个对象了,这样一推,新生代的 GC 就没法做了。

对于这个问题,V8 选择的解决方案是使用写屏障(write barrier),即每次往一个对象写入一个指针(添加引用)的时候,都执行一段代码,这段代码会检查这个被写入的指针是否是由老生代对象指向新生代对象的,这样我们就能明确地记录下所有从老生代指向新生代的指针了。这个用于记录的数据结构叫做 store buffer,每个堆维护一个,为了防止它无限增长下去,会定期地进行清理、去重和更新。这样,我们可以通过扫描,得知根对象->新生代和新生代->新生代的引用,通过检查 store buffer,得知老生代->新生代的引用,就没有漏网之鱼,可以安心地对新生代进行回收了。

这样往所有的写入指针的操作注入额外的记录动作的方法,看上去似乎会严重影响性能,但看前文介绍弱分代假设的时候读者应该也注意到了,在不考虑统计概率的情况下讨论性能是很耍流氓的。

  • 在程序运行的过程中,写一般比读发生得少得多
  • 老生代->新生代的指针写入并不常见,我们可以先检查指针的两头是不是在同一代,是的话直接跳过写屏障即可。由于 V8 里的页都是按至少 1MB(20 bit) 对齐的,任意一个内存地址从第一位到倒数第 21 位之间的数字,都可以唯一定位到一个内存页上。这样我们可以快速得到指针两端的页,而每个内存页的 header 又自带 flag 标明自己属于哪个空间,于是我们只要做一下位运算和简单的检查就可以跳过占多数的新生代->新生代和老生代->老生代引用了。
  • V8 的优化编译器可以通过静态分析证明一个对象不会出现在老生代,或者证明这个对象不会被(内联后的)函数作用域外的对象引用而直接将它放在栈上,这时我们也就没必要对这类对象执行写屏障了。

因此,大部分场景下写屏障的性能开销相比起扫描整个堆的开销,还是划算得多的。

优化:分配合并(Allocation Folding)

由于在 JavaScript 代码中经常出现连续的分配(比如很多人习惯在函数的开头把所有需要的变量都尽早分配好),V8 引入了分配合并(Allocation Folding)的机制。在优化过的函数里(此时假设这些对象结构遵循一定的模式且稳定不变,如果变了立刻退出改用普通的分配机制),将这些连续分配的对象组合起来,先为它们分配一块能容纳所有对象的内存,然后再逐个初始化(注意如果有 pretenuring 可能会一起分配在 Old Space)。这样,当这些对象在初始化时互相之间出现引用的时候,由于它们是一起分配的,我们可以确定它们一定在同一个空间里,连检查指针两端都不用,直接就能跳过写屏障。并且由于只需要一次性找一块足够大的空间,而不需要为每个对象找一次合适的内存,这项优化也提高了分配内存的速度。

老生代:Mark-Sweep/Mark-Compact

对于老生代,V8 使用的是 Mark-Sweep/Mark-Compact 来回收垃圾。这两种方法其实是紧密联系的。

三色 marking

V8 为每一个内存页维护了一个 marking bitmap,页内的每一个可用于分配的字在其中都有一个对应的 bit,由于 V8 中的对象起码是 2 个字的长,所以对象们起码能对应 2 个 bit,于是这个标记最多能有 4 种类型。V8 使用的是一种三色 marking(tricolor marking)的算法,白色代表这个对象可以被回收;黑色代表这个对象不能回收,而且它产生的所有引用都已经扫描完毕;灰色代表这个对象不能被回收,但它产生的引用还没有被扫描完。

当老生代 GC 启动的时候,V8 会扫描老生代的对象,沿着引用做标记(mark),将这些标记保留在对应的 marking bitmap 里。最开始的时候所有的非根对象带有的标记都是白的,接着 V8 将根对象直接引用的对象放进一个显式的栈,并标记它们为灰色。接下来,V8 从这些对象开始做深度优先搜索,每访问一个对象,就将它 pop 出来,标记为黑色,然后将它引用的所有白色对象标记为灰色,push 到栈上,如此循环往复,直到栈上的所有对象都 pop 掉了为止。这样,最后老生代的对象就只有黑色(不可回收)和白色(可以回收)两种了。

需要注意的是,当对象太大无法 push 进空间有限的栈的时候,V8 会先把这个对象保留灰色放弃掉,然后将整个栈标记为溢出状态(overflowed)。在溢出状态下,V8 会继续从栈上 pop 对象,标记为黑色,再将引用的白色对象标记为灰色和溢出,但不会将这些灰色的对象 push 到栈上去。这样没多久,栈上的所有对象都被标黑清空了。此时 V8 开始遍历整个堆,把那些同时标记为灰色和溢出对象按照老方法标记完。由于溢出后需要额外扫描一遍堆(如果发生多次溢出还可能扫描多遍),当程序创建了太多大对象的时候,就会显著影响 GC 的效率。

标记完死亡对象(白色)之后,V8 就可以回收这些死亡对象占用的内存了。回收的方法有两种:sweeping 或者 compacting。

Sweeping

Sweeping 就是扫描每一页的 marking bitmap,找到死亡对象占用的连续区块,将这些块添加到随该页维护的一个 freelist 里。这个数据结构保存了页上可用于下次分配的内存位置,可以用于 compacting、新生代晋升与老生代直接分配对象等需要在老生代中分配内存的场景。V8 中按照可用内存块大小的区间分出了多个 freelist,这样能更快找到合适的可用内存。

Compacting

Compacting 则是将页中的所有存活的对象都转移到另一页里(evacuation),这样存活对象都被移走了的那一页就可以直接还给操作系统了。这种方法主要发生在某一页中死亡对象留下来的空洞(hole)比较多的时候,但也会有例外,比如这一页中的对象被太多其他页的对象引用的时候就不会 compact,不然移动对象后更新所有指过来的指针将会是不小的开销。

优化:增量式 marking(incremental marking)

前面说过,V8 的垃圾回收器已经开始往增量式、并发式改进了,目前主要的改进发生在老生代的 GC 上,因为新生代的 GC 一般很短暂,优化的空间不大。

在 marking 方面,V8 引入了增量式 marking(incremental marking),原来 V8 需要停止程序的运行,扫描完整个堆,回收完内存后,才能重新运行程序,每次暂停时间可以到几百甚至几千毫秒,严重影响了程序的性能。现在 V8 将 marking 拆分开来,当堆大小涨到一定程度的时候,开始增量式 GC,在每次分配了一定量的内存后/触发了足够多次写屏障后,就暂停一下程序,做几毫秒到几十毫秒的 marking,然后恢复程序的运行。当老生代需要 GC 的时候,由于之前断断续续地标记过了大部分的堆内存,不需要从头扫描整个堆,工作量便大大减少了(除去每个 GC 周期的最后一次 marking,V8 对增量式 marking 设计的运行时间不超过 5ms,超过了一般有问题)。

增量式的 marking 主要步骤和原来的类似,但是因为在完成整个堆的 marking 之前程序会断断续续地运行,改变对象的生存状态,所以 V8 在前面所说的写屏障之上,额外加多了一个需要记录的情形:每次产生从黑色对象指向白色对象的引用的时候,将被指的对象重新标记为灰色,放回 marking 的队列里,这样便不会误将存活的对象标记为死亡了。

优化:black allocation

V8 5.x 还引入了 black allocation,将所有新出现在 Old Space 的对象(包括pretentured 的分配或者晋升)直接标记为黑色,放在特殊的内存页(black page)中,这个内存页里只有黑色的对象,因此一定能活过下一次 GC。新出现在 Old Space 的对象一般存活过下一轮 GC 的几率非常高,这样做可以一定程度上减轻 marking 的负担,即使猜错了,下下轮 marking 前这些对象又会先刷白,只逃过一次 GC 所以造成的影响也不大。

优化:lazy sweeping, concurrent sweeping, parallel sweeping

在 sweeping 方面,V8 引入了 lazy sweeping,当我们已经标记完哪些对象的内存可以被回收之后,并没有必要马上回收完这些内存,然后再开始运行。我们可以先恢复程序的运行,再一点点对各页的空间做 sweeping。当然,只有当所有页的内存都被回收完之后,我们才能重新开始 marking。

另外,由于这些死亡对象占据的空间不会在被运行中的程序使用,V8 还引入了 concurrent sweeping,让其他线程同时来做 sweeping,而不用担心和执行程序的主线程冲突,这样在 sweeping 的时候,就不需要暂停程序的执行了。

同样地,因为 sweeping 作用的对象们已经确定而且不会被主线程访问,可以比较容易地并行化,V8 引入了 parallel sweeping,让多个 sweeping 线程同时工作,提升 sweeping 的吞吐量,缩短整个 GC 的周期。

小结

V8 中大部分 JavaScript 对象都分配在堆上,堆内空间分为频繁诞生与死亡的对象所属的 New Space(<20MB),倾向于长时间存活的对象所属的 Old Space,可执行代码所属的 Code Space,用于描述对象隐藏类的数据所属的 Map Space,以及大对象所属的 Large Object Space。这些空间都分页,页在 V8 和操作系统之间的转移由 Memory Allocator 进行管理。堆外空间(External Memoery)由外部代码维护,通过 API 上报大小给 V8,通过注册 GC 回调随 V8 内对象的回收而自行回收。

V8 对新生代使用 Scavenge 算法,将 New Space 分成两半,每次只使用一半,回收时相当于从根对象开始做 BFS,将存活的对象移到另一半中,死亡对象原来占据的空间就相当于被释放了,这一过程一般不超过 1ms。老生代使用 tricolor marking 增量式地标记存活对象,相当于从根对象开始做 DFS,每次 marking 一般不超过 5ms。标记完后,使用 sweeping 扫描并回收死亡对象占据的空间,用 compacting 移动存活对象整理碎片严重的内存页。Sweeping 按需执行(lazy),一般不阻碍主线程执行程序(concurrent),并且由多个 sweeper 线程并行完成(parallel)。为了降低新生代 GC 扫描的开销,以及保证增量式 marking 的结果不会随着程序运行失效,V8 引入了写屏障(write barrier),在发生引用变化的时候执行一段代码,用于记录相关信息以供 GC 快速查询。

 

猜你喜欢

转载自blog.csdn.net/u010365819/article/details/84618075