高效管理 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. 属性 - 对象中引用值的名称。

对象图

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

对象图

何时会出现垃圾值?

如果没有从根到值的路径,则值会变为垃圾值。换句话说,从根开始,对堆栈帧中有效的所有对象属性和变量进行彻底搜索,如果无法找到某个值,则表示该值已成为垃圾。

垃圾图

什么是 JavaScript 中的内存泄漏?

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

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

稍后,您可以从显示列表中移除该元素:

displayList.removeAllChildren();

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

什么是膨胀?

如果您的网页为达到最佳速度而使用的内存比本应使用的内存多,则表示您的网页膨胀了。间接而言,内存泄漏也会导致膨胀,但这并非设计之意。没有任何大小限制的应用缓存是导致内存膨胀的常见原因。此外,您的网页可能会因主机数据(例如从图片加载的像素数据)而变得臃肿。

什么是垃圾回收?

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

V8 垃圾回收器详解

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

代收集器

V8 使用两代收集器。值的年龄定义为自分配以来分配的字节数。在实践中,值的年龄通常近似于其经历过的新生代收集的次数。值足够久后,便会进入老代。

在实践中,新分配的值的生命周期并不长。对 Smalltalk 程序的一项研究表明,只有 7% 的值会在年轻一代收集后保留下来。对各个运行时进行的类似研究发现,平均而言,90% 到 70% 的新分配值从未进入老一代。

新一代

V8 中的新生代堆分为两个空间,分别称为“from”和“to”。内存从目标空间分配。分配速度非常快,直到可用空间用尽,此时系统会触发新生代收集。新生代收集首先会交换“from”空间和“to”空间,然后扫描旧的“to”空间(现在是“from”空间),并将所有有效值复制到“to”空间或保留到旧生代。典型的新生代集合将需要大约 10 毫秒 (ms)。

直观地讲,您应该了解,应用进行的每次分配都会使您离耗尽空间和发生垃圾回收暂停更近。游戏开发者请注意:为了确保帧时间为 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. usedJSHeapSize - 当前使用的内存量(以字节为单位)。

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

大规模衡量内存

Gmail 插桩了其 JavaScript,以便使用 performance.memory API 大约每 30 分钟收集一次内存信息。由于许多 Gmail 用户会让应用长时间保持开启状态,因此该团队能够跟踪一段时间内的内存增长情况,以及总体内存占用情况统计信息。在对 Gmail 进行插桩以从随机抽样的用户那里收集内存信息后的几天内,该团队就获得了足够的数据,能够了解普通用户中内存问题的普遍程度。他们设置了一个基准,并使用传入数据流来跟踪减少内存用量目标的进度。最终,这些数据还将用于捕获任何内存回归问题。

除了跟踪用途之外,现场测量结果还可以深入了解内存占用量与应用性能之间的相关性。与普遍观点“内存越大,性能越好”相反,Gmail 团队发现,内存占用量越大,常见 Gmail 操作的延迟时间就越长。有了这个发现,他们比以往更加有动力控制内存用量。

大规模衡量内存

使用 DevTools 时间轴识别内存问题

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

如需证明存在问题,DevTools 时间轴面板非常适用。它可全面概述用户在加载和与您的 Web 应用或网页互动时所花费的时间。从加载资源到解析 JavaScript、计算样式、垃圾回收暂停和重新绘制,所有事件都会绘制在时间轴上。为了调查内存问题,时间轴面板还提供了“内存”模式,用于跟踪已分配的内存总量、DOM 节点数、窗口对象数和分配的事件监听器数。

证明存在问题

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

锯齿形图表

确认存在问题后,您可以使用 DevTools 堆性能分析器来帮助您找出问题的根源。

使用 DevTools 堆分析器查找内存泄漏

性能分析器面板同时提供 CPU 性能分析器和堆性能分析器。堆性能分析的工作原理是拍摄对象图的快照。在创建快照之前,系统会对新生代和旧生代进行垃圾回收。换句话说,您只会看到在快照创建时有效的值。

堆性能分析器的功能太多,本文无法详尽介绍,但您可以在 Chrome 开发者网站上找到详细文档。我们将重点介绍 Heap Allocation 性能分析器。

使用堆分配分析器

堆分配性能分析器将堆性能分析器的详细快照信息与时间轴面板的增量更新和跟踪功能相结合。打开“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 应用执行相同的操作!