解读 V8 GC Log(一): Node.js 应用背景与 GC 基础知识

前言

本文基于我在 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,以便尽早无遗漏地回收内存。