高效管理 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 标签页崩溃(“他已经死了,Jim。”)的故事。该团队甚至不知道如何开始诊断问题,更不用说修复问题了。他们不知道问题的普遍程度,而且现有工具无法扩展到大型应用。该团队与 Chrome 团队携手合作,共同开发了用于对内存问题进行分类的新技术,改进了现有工具,并实现了从现场收集内存数据。不过,在介绍这些工具之前,我们先来了解一下 JavaScript 内存管理的基础知识。

内存管理基础知识

在 JavaScript 中有效管理内存之前,您必须了解基本原理。本部分将介绍基元类型、对象图,并提供内存膨胀和 JavaScript 内存泄漏的定义。JavaScript 中的内存可以概念化为图,因此图论在 JavaScript 内存管理和堆性能分析器中发挥着作用。

原始类型

JavaScript 有三种基元类型:

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

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

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

数组呢?

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

术语

  1. 值 - 原始类型、对象、数组等的实例。
  2. 变量 - 引用值的名称。
  3. 属性 - 对象中引用值的名称。

对象图

JavaScript 中的所有值都是对象图的一部分。该图从根(例如 window 对象)开始。您无法管理 GC 根的生命周期,因为它们由浏览器创建,并在页面卸载时销毁。全局变量实际上是 window 上的属性。

对象图

值何时会变成垃圾?

当从根到值没有路径时,值就会变成垃圾。换句话说,从根开始,详尽搜索堆栈帧中处于活动状态的所有对象属性和变量,如果无法访问某个值,则该值已成为垃圾。

垃圾图表

什么是 JavaScript 中的内存泄漏?

JavaScript 中的内存泄漏最常发生在以下情况:存在无法从页面的 DOM 树访问但仍被 JavaScript 对象引用的 DOM 节点。虽然现代浏览器越来越难以无意中造成泄露,但泄露比想象的要容易。假设您向 DOM 树附加了一个元素,如下所示:

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

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

displayList.removeAllChildren();

只要 email 存在,message 引用的 DOM 元素就不会被移除,即使它现在已从页面的 DOM 树中分离。

什么是膨胀?

如果您的网页使用的内存超出了实现最佳网页速度所需的内存,则说明您的网页臃肿。内存泄漏也会间接导致膨胀,但这并非设计使然。没有任何大小限制的应用缓存是内存膨胀的常见来源。此外,您的网页可能会因主机数据(例如从图片加载的像素数据)而变得臃肿。

什么是垃圾回收?

垃圾回收是 JavaScript 回收内存的方式。浏览器会决定何时执行此操作。在回收期间,页面上的所有脚本执行都会暂停,同时系统会从 GC 根开始遍历对象图,以发现活跃值。所有不可访问的值都会被归类为垃圾。内存管理器会回收垃圾值所占用的内存。

V8 垃圾回收器详解

为了进一步了解垃圾回收的运作方式,我们来详细了解一下 V8 垃圾回收器。V8 使用分代收集器。内存分为两代:年轻代和老年代。新生代区域内的分配和回收速度快且频率高。老年代中的分配和回收速度较慢,频率较低。

分代回收器

V8 使用两代收集器。值的年龄定义为自其分配以来分配的字节数。在实践中,值的年龄通常通过其经历的年轻代回收次数来近似表示。当某个值足够旧时,它会被保留到老年代。

在实践中,新分配的值不会存活太久。一项针对 Smalltalk 程序的研究表明,在年轻代回收后,只有 7% 的值会存活下来。对各种运行时进行的类似研究发现,平均而言,新分配的值中有 70% 到 90% 永远不会进入老年代。

年轻一代

V8 中的年轻代堆分为两个空间,分别命名为 from 和 to。内存是从 to 空间分配的。分配速度非常快,直到 to 空间已满,此时会触发新生代回收。年轻代回收首先会交换 from 空间和 to 空间,扫描旧的 to 空间(现在是 from 空间),并将所有活跃值复制到 to 空间或保留到老年代。典型的年轻代回收大约需要 10 毫秒 (ms)。

直观地讲,您应该明白,应用的每次分配都会使您更接近耗尽 to 空间并导致 GC 暂停。游戏开发者请注意:为确保 16 毫秒的帧时间(达到每秒帧数 60 帧所需的时间),您的应用必须不进行任何分配,因为单次年轻代回收就会耗尽大部分帧时间。

年轻代堆

老年代

V8 中的老年代堆使用标记-压缩算法进行垃圾回收。每当值从年轻代保留到老年代时,就会发生老年代分配。每当发生老年代回收时,也会执行新生代回收。您的应用将暂停几秒钟。在实践中,这是可以接受的,因为老年代回收并不频繁。

V8 GC 摘要

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

修复 Gmail

过去一年,Chrome 开发者工具中新增了许多功能和问题修复,使其比以往更加强大。此外,浏览器本身对 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. usedJSHeapSize - 当前使用的内存量(以字节为单位)。

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

大规模测量内存

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

除了跟踪目的之外,现场测量还可让您深入了解内存占用与应用性能之间的相关性。人们普遍认为“内存越大,性能越好”,但 Gmail 团队发现,内存占用越大,Gmail 常见操作的延迟时间就越长。有了这一发现,他们比以往任何时候都更有动力去控制内存消耗。

大规模测量内存

使用开发者工具时间轴识别内存问题

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

DevTools 时间轴面板是证明问题存在的理想工具。它会全面概述在加载 Web 应用或网页以及与之互动时,时间都花在了哪里。从加载资源到解析 JavaScript、计算样式、垃圾回收暂停和重新绘制,所有事件都会绘制在时间轴上。为了调查内存问题,Timeline 面板还提供了一个 Memory 模式,该模式可跟踪总分配内存、DOM 节点数、窗口对象数以及分配的事件监听器数。

证明问题存在

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

锯齿形图表

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

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

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

堆性能分析器的功能非常丰富,本文无法一一详述,但您可以在 Chrome 开发者网站上找到详细文档。我们将重点介绍堆分配量性能分析器。

使用堆分配性能分析器

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

堆分配性能分析器

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

在上面的示例中,某项操作执行了 10 次。示例程序会缓存五个对象,因此最后五个蓝色条形是预期的。但最左侧的蓝色条表示存在潜在问题。然后,您可以使用上方时间轴中的滑块放大该特定快照,并查看当时最近分配的对象。点击堆中的特定对象,即可在堆快照的底部显示其保留树。检查对象的保留路径应该能为您提供足够的信息,让您了解对象未被回收的原因,然后您就可以进行必要的代码更改,以移除不必要的引用。

解决 Gmail 的存储空间危机

通过使用上述工具和技术,Gmail 团队能够识别出几类 bug:无界缓存、无限增长的回调数组(等待永远不会发生的事件)以及无意中保留其目标的事件监听器。通过修复这些问题,Gmail 的总体内存用量大幅减少。99% 的用户使用的内存比以前减少了 80%,中位数用户的内存消耗量下降了近 50%。

Gmail 内存用量

由于 Gmail 占用的内存减少,GC 暂停延迟时间也随之缩短,从而提升了整体用户体验。

另外值得注意的是,Gmail 团队在收集内存用量统计信息时,发现了 Chrome 内部的垃圾回收回归问题。具体而言,当 Gmail 的内存数据显示已分配的总内存与活动内存之间的差距急剧增加时,我们发现了两个碎片化 bug。

号召性用语

问问自己以下问题:

  1. 我的应用使用了多少内存? 您可能使用了过多内存,这与普遍看法相反,会对整体应用性能产生负面影响。很难确切知道正确的数字是多少,但请务必验证网页使用的任何额外缓存是否会对性能产生可衡量的影响。
  2. 我的网页是否存在泄露? 如果您的网页存在内存泄漏,不仅会影响该网页的性能,还会影响其他标签页。使用对象跟踪器可帮助缩小任何泄漏的范围。
  3. 我的网页执行 GC 的频率如何? 您可以使用 Chrome 开发者工具中的时间轴面板查看任何 GC 暂停。如果您的网页频繁进行 GC,则可能是因为您分配内存过于频繁,导致年轻代内存不断被回收和重新分配。

总结

我们是在危机中起步的。介绍了 JavaScript 和 V8 中内存管理的核心基础知识。您学习了如何使用这些工具,包括最新版 Chrome 中提供的新对象跟踪器功能。Gmail 团队利用这些知识解决了内存用量问题,并提升了性能。您也可以对 Web 应用执行相同的操作!