高效管理 Gmail 的内存

John McCutchan
John McCutchan
Loreena Lee
Loreena Lee

简介

虽然 JavaScript 利用垃圾回收进行自动内存管理,但它无法替代应用中有效的内存管理。JavaScript 应用也面临与本机应用相同的内存相关问题,例如内存泄漏和膨胀,但它们也必须处理垃圾回收暂停。像 Gmail 这样的大型应用也会遇到小型应用面临的相同问题。请继续阅读下文,了解 Gmail 团队如何使用 Chrome 开发者工具找出、隔离和修复他们的内存问题。

2013 年 Google I/O 大会

我们在 2013 年 Google I/O 大会上介绍了这些资料。请观看下面的视频:

Gmail,出问题了...

Gmail 小组遇到了一个严重问题。人们越来越多地听到在资源有限的笔记本电脑和台式机上,Gmail 标签页会占用数 GB 内存的趣闻,他们经常会发现,最终结果是关闭整个浏览器。关于 CPU 被固定到 100%、无响应的应用和 Chrome 错误标签页(“老兄,网页崩溃了!”)的故事。该团队不知道该如何着手诊断,更不用说是解决问题了。他们不知道该问题有多普遍,并且可用的工具也无法扩展到大型应用。该团队与 Chrome 团队通力合作,共同开发了新技术来对内存问题进行分类、改进现有工具,并支持从现场收集内存数据。不过,在开始介绍这些工具之前,我们先介绍一下 JavaScript 内存管理的基础知识。

内存管理基础知识

您必须先了解基础知识,然后才能使用 JavaScript 有效管理内存。本部分将介绍基元类型、对象图,并针对 JavaScript 中的内存膨胀和内存泄漏问题给出一般定义。JavaScript 中的内存可以概念化为一个图表,因此图表理论在 JavaScript 内存管理和堆性能分析器中发挥着重要作用。

原初类型

JavaScript 具有三种基元类型:

  1. 数字(例如 4、3.14159)
  2. Boolean(true 或 false)
  3. 字符串(“Hello World”)

这些基元类型无法引用任何其他值。在对象图中,这些值始终是叶或终止节点,这意味着它们永远没有传出边缘。

只有一种容器类型:对象。在 JavaScript 中,该对象是一个关联数组。非空对象是内部节点,具有与其他值(节点)的传出边缘。

数组是怎么回事?

JavaScript 中的数组实际上是具有数字键的对象。这是一种简化,因为 JavaScript 运行时将优化类数组对象,并在后台将它们表示为数组。

术语

  1. 值 - 基元类型、对象、数组等的实例。
  2. 变量 - 引用值的名称。
  3. Property - 对象中引用某个值的名称。

对象图表

JavaScript 中的所有值都是对象图的一部分。图表以根(例如窗口对象)开头。GC 根的生命周期由浏览器创建,会在页面卸载时销毁,因此并不由您控制。全局变量实际上是窗口上的属性。

对象图

价值何时会变成垃圾?

如果不存在从根到值的路径,值将变成垃圾值。换言之,从根目录开始详尽搜索堆栈帧中处于活动状态的所有对象属性和变量,结果无法找到某个值,它变成了垃圾。

垃圾回收图

什么是 JavaScript 中的内存泄漏?

在 JavaScript 中,如果某些 DOM 节点无法从网页的 DOM 树访问,但仍被 JavaScript 对象引用,就会出现内存泄漏问题。虽然现代浏览器使得无意间发生泄漏变得越来越困难,但这仍然比人们想象的要容易。假设您按如下所示将一个元素附加到 DOM 树:

email.message = document.createElement("div");
displayList.appendChild(email.message);

之后,从显示列表中移除该元素:

displayList.removeAllChildren();

只要 email 存在,就不会移除消息引用的 DOM 元素,即使它现在已与页面的 DOM 树分离也是如此。

什么是膨胀?

当您为实现最佳网页速度而使用的内存超出所需内存时,网页便会变得臃肿。内存泄漏也会间接导致膨胀,但这并不是设计原因。没有大小限制的应用缓存是内存膨胀的常见原因。此外,您的网页也可能因托管数据(例如从图片中加载的像素数据)而变得臃肿。

什么是垃圾回收?

垃圾回收是在 JavaScript 中回收内存的方式。浏览器会决定何时执行此操作。在收集过程中,当对对象图执行从 GC 根开始的遍历遍历发现实时值时,系统会暂停页面上的所有脚本执行。所有无法到达的值都会被归类为垃圾回收。内存管理器回收垃圾回收值的内存。

V8 垃圾回收器详解

为帮助进一步了解垃圾回收是如何发生的,我们来详细了解一下 V8 垃圾回收器。V8 使用分代收集器。记忆分为两代:年轻人和老人。在年轻代中快速且频繁地分配和收集。旧世代中的分配和回收速度会更慢,频率也会更低。

分代收集器

V8 使用两代收集器。值的存在时间定义为自其分配以来分配的字节数。实际上,值的存在时间通常用其存留的年轻一代集合数量来近似。值在经过充分留存后,即会留存到旧世代中。

实际上,新分配的值的存留时间不会很长。针对 Smalltalk 计划的一项研究表明,在年轻一代收集后,只有 7% 的值仍然存在。针对不同运行时的类似研究发现,平均而言,平均有 90% 到 70% 的新分配值从未应用到旧世代。

年轻一代

V8 中的新生代堆被拆分成两个空间,分别命名为 from 和 to。内存从 分配到空间。分配非常快,直到空间已满,然后触发新生代收集。“年轻代”集合首先交换“from”和“to-space”,然后扫描从旧到空格(现在为“from Space”),并将所有活跃值复制到“聊天室”或“老化”到老世代。典型的“新生代”收集大约需要 10 毫秒 (ms)。

直观地说,您应该知道应用进行的每次分配都会进一步耗尽空间并导致 GC 暂停。游戏开发者请注意:为确保帧时间达到 16 毫秒(需要实现每秒 60 帧),您的应用不得进行任何分配,因为单个新生代集合会占用大部分帧时间。

新生代堆

老一代

V8 中的老一代堆使用标记紧凑算法进行收集。当某个值从年轻代计算到老代时,就会发生旧代分配。每当发生老一代收集时,也会同时执行新一代收集。您的应用将会按秒暂停。实际上,这是可以接受的,因为老一代收集不经常发生。

V8 GC 摘要

使用垃圾回收进行自动内存管理可以提高开发者的工作效率,但是,您每次分配值时,都离垃圾回收暂停更近一步。垃圾回收暂停可能会导致卡顿,从而破坏应用的使用体验。现在,您已了解 JavaScript 如何管理内存,接下来可以为您的应用做出正确的选择。

解决 Gmail 问题

在过去的一年里,Chrome 开发者工具中引入了大量功能和 bug 修复,使它们比以往任何时候都更强大。此外,浏览器本身对 performance.memory API 进行了关键更改,使 Gmail 和任何其他应用程序可以从现场收集内存统计信息。有了这些超棒的工具,曾经看起来不可能完成的任务,很快就成为了追查问题的关键游戏。

工具和技术

字段数据和 performance.memory API

从 Chrome 22 开始,performance.memory API 会默认处于启用状态。对于 Gmail 等长期运行的应用程序,真实用户的数据极为宝贵。此信息使我们能够区分出高级用户,即那些每天在 Gmail 上花费 8-16 小时,每天接收数百封邮件的用户,而这些用户是每天在 Gmail 中花费几分钟几分钟、每周接收十几封邮件的普通用户。

此 API 会返回三部分数据:

  1. jsHeapSizeLimit - JavaScript 堆限制为的内存量(以字节为单位)。
  2. totalJSHeapSize - JavaScript 堆分配的内存量(以字节为单位),包括可用空间。
  3. useJSHeapSize - 当前正在使用的内存量(以字节为单位)。

需要注意的一点是,该 API 返回整个 Chrome 进程的内存值。尽管 Chrome 不是默认模式,但在某些情况下,它可能会在同一渲染程序进程中打开多个标签页。这意味着, performance.memory 返回的值除了包含您应用的标签页之外,还可能包含其他浏览器标签页的内存占用量。

大规模测量内存

Gmail 检测其 JavaScript,以使用 performance.memory API 大约每 30 分钟收集一次内存信息。由于许多 Gmail 用户都会连续数天离开应用,因此该团队能够跟踪内存增长情况以及总体内存占用统计信息。在对 Gmail 进行插桩测试以从随机抽样用户中收集内存信息后的几天内,该团队就有了足够的数据来了解内存问题在普通用户中的普遍程度。他们设定了基准,并使用传入数据流来跟踪减少内存消耗的目标的进度。最终,这些数据还将用于捕获内存回归问题。

除了跟踪目的,实测还有助于深入了解内存占用量与应用性能之间的关系。Gmail 团队发现内存占用量越大,常见 Gmail 操作的延迟时间就越长,这与“内存越多,性能越好”这种普遍的观点相反。有了这一揭示,他们比以往更有动力控制内存消耗。

大规模测量内存

使用开发者工具时间轴确定内存问题

解决任何性能问题的第一步都是证明问题存在,创建一个可重现的测试,并对问题的基准测量。如果没有可重现的程序,您就无法可靠地衡量问题。如果没有基准衡量,就无法了解效果提升了多少。

使用开发者工具时间轴面板是证明问题存在的理想人选。它可让您全面了解在加载 Web 应用或网页并与之互动时,时间都花在了何处。所有事件(从加载资源到解析 JavaScript、计算样式、垃圾回收暂停和重绘)都会绘制在时间轴上。为了调查内存问题,“Timeline”面板还有一个 Memory 模式,用于跟踪分配的总内存、DOM 节点的数量、窗口对象的数量以及分配的事件监听器的数量。

证明存在问题

首先,确定你怀疑一系列在泄漏内存的操作。开始记录时间轴,并执行一系列操作。使用底部的垃圾桶按钮强制完全回收垃圾。如果经过几次迭代,您看到锯齿形图表,则说明您在分配了大量短期内存在的对象。但是,如果操作序列预计不会产生任何保留内存,并且 DOM 节点数量没有下降到您开始的基准,那么您有充分的理由怀疑存在泄漏。

锯齿状图表

确认问题存在后,您可以通过开发者工具堆性能分析器帮助找出问题来源。

使用开发者工具堆分析器查找内存泄漏问题

Profiler 面板提供 CPU 性能分析器和堆性能分析器。堆剖析的工作原理是截取对象图的快照。在拍摄快照之前,新生代和老代都会进行垃圾回收。换言之,您只能看到截取快照时处于活动状态的值。

堆性能分析器的功能过多,本文无法对此进行全面介绍,但您可以在 Chrome 开发者网站上找到详细文档。我们将重点介绍堆分配分析器。

使用堆分配分析器

堆分配分析器将堆分析器的详细快照信息与 Timeline 面板的增量更新和跟踪相结合。打开“Profiles”面板,启动 Record Heap Allocations 分析,执行一系列操作,然后停止记录以进行分析。分配性能分析器在整个记录过程中定期(频率高达每 50 毫秒一次!)拍摄堆快照,并在记录结束时最后一次截取快照。

堆分配性能分析器

顶部的竖条表示在堆中发现新对象的时间。每个条形的高度对应于最近分配的对象的大小,且条形的颜色指示这些对象是否仍在最终堆快照中:蓝色条形表示在时间轴末尾仍然处于活动状态的对象,灰色条形表示在时间轴中分配但后来进行了垃圾回收的对象。

在上面的示例中,操作执行了 10 次。示例程序缓存了 5 个对象,因此最后 5 个蓝色竖条属于正常现象。但最左边的蓝色指示条表示可能存在问题。然后,您可以使用上方时间轴中的滑块放大该特定快照,并查看最近在该点分配的对象。点击堆中的特定对象会在堆快照的底部显示其保留树。通过检查对象的保留路径,您应该能够获得足够的信息来了解对象未收集的原因,并且可以进行必要的代码更改以移除不必要的引用。

解决 Gmail 的内存危机

通过使用上述工具和技术,Gmail 团队得以识别几类错误:无界限缓存、无限增长的回调阵列等待某个从未实际发生的事件,以及事件监听器无意中保留其目标。通过修复这些问题,Gmail 的总体内存使用量大幅降低。达到 99% 的用户使用的内存比以前少了 80%,中位数用户的内存消耗量减少了近 50%。

Gmail 内存用量

由于 Gmail 使用的内存更少,因此 GC 暂停延迟时间也缩短了,从而增加了整体用户体验。

另请注意,随着 Gmail 团队收集内存使用情况的统计信息,他们能够发现 Chrome 中的垃圾回收回归问题。具体而言,当 Gmail 的内存数据开始显示分配的总内存与实时内存之间的差距显著增加时,发现了两个碎片化错误。

号召性用语

请思考以下问题:

  1. 我的应用使用了多少内存? 您可能使用过多的内存,违背了普遍认为,这对整体应用性能造成了负面影响。我们很难确切地知道哪个数值才是正确的,但一定要验证网页使用的任何额外缓存是否都会对性能产生可观的影响。
  2. 我的网页是否免费? 如果您的网页出现内存泄漏问题,这不仅会影响网页的性能,还会影响其他标签页。使用物品追踪器有助于缩小范围,找到任何泄漏情况。
  3. 我的页面垃圾回收的频率如何? 您可以使用 Chrome 开发者工具中的时间轴面板查看任何垃圾回收暂停。如果您的页面经常被垃圾回收 (GC),则可能是因为您分配的网页过于频繁,从而浪费了年轻一代的记忆。

总结

我们从危机开始。并特别介绍了 JavaScript 和 V8 中内存管理的核心基础知识。您学习了如何使用相关工具,包括最新版 Chrome 中提供的全新对象跟踪器功能。Gmail 团队利用这些信息解决了内存使用问题,并取得了更好的性能。在 Web 应用中,您也可以采用同样的方法!