有效的管理 Web 大型应用的内存使用
我们在 2013 年 Google I/O 大会上展示了这些材料。
Gmail 遇到了问题
Gmail 团队面临着一个严重的问题。 关于 Gmail 选项卡在资源受限的笔记本电脑和台式机上消耗数 GB 内存的轶事越来越频繁地听到,结果往往导致整个浏览器崩溃。 有关 CPU 固定在 100%、应用程序无响应和 Chrome 令人伤心的标签页(“他死了,吉姆。”)的故事。 团队甚至不知道如何开始诊断问题,更不用说修复它了。 他们不知道问题有多普遍,可用的工具也无法扩展到大型应用程序。 该团队与 Chrome 团队合作,共同开发了新技术来分类内存问题,改进了现有工具,并支持从现场收集内存数据。 但是,在使用这些工具之前,让我们介绍一下 JavaScript 内存管理的基础知识。
内存管理基础
在使用 JavaScript 有效管理内存之前,您必须了解基础知识。 本节将介绍基本类型、对象图,并提供一般内存膨胀和 JavaScript 内存泄漏的定义。 JavaScript 中的内存可以概念化为图形,因此 图形理论 中发挥作用 在 JavaScript 内存管理和堆分析器 。
原始类型
JavaScript 具有三种原始类型:
- Number (e.g. 4, 3.14159)
- Boolean (true or false)
- String (“Hello World”)
这些基本类型不能引用任何其他值。 在对象图中,这些值始终是叶节点或终止节点,这意味着它们永远不会有传出边。
只有一种容器类型:对象。 在 JavaScript 中,对象是一个 关联数组 。 非空对象是具有到其他值(节点)的出边的内部节点。
数组呢?
JavaScript 中的数组实际上是一个具有数字键的对象。 这是一种简化,因为 JavaScript 运行时将优化类数组对象并将它们在底层表示为数组。
术语
- Value - 原始类型、对象、数组等的实例。
- Variable -引用值的名称。
- Property - 对象中引用值的名称。
对象图
JavaScript 中的所有值都是对象图的一部分。 该图从根开始,例如 window 对象 。 管理 GC 根的生命周期不受您的控制,因为它们由浏览器创建并在页面卸载时销毁。 全局变量实际上是窗口的属性。
什么时候值会变成垃圾?
当没有从根到值的路径时,该值将变成垃圾。 也就是说,从根开始,穷尽所有在 栈帧 中存活的Object属性和变量,一个值都达不到,就成了垃圾。
什么是 JavaScript 中的内存泄漏?
当存在无法从页面的 DOM 树访问但仍被 JavaScript 对象引用的 DOM 节点时,JavaScript 中的内存泄漏最常发生。 虽然现代浏览器使无意中造成泄漏变得越来越困难,但它仍然比人们想象的要容易。 假设您像这样将一个元素附加到 DOM 树:
email.message = document.createElement(“div”); displayList.appendChild(email.message);
稍后,您从显示列表中删除该元素:
displayList.removeAllChildren();
直到
存在时,消息引用的 DOM 元素将不会被删除,即使它现在已从页面的 DOM 树中分离出来。
什么是 Bloat?
内存时,您的页面就会 膨胀 当您使用的内存超过最佳页面速度所需的 。 间接地,内存泄漏也会导致膨胀,但这不是设计使然。 没有任何大小限制的应用程序缓存是内存膨胀的常见来源。 此外,您的页面可能会因主机数据而膨胀,例如,从图像加载的像素数据。
什么是垃圾收集?
垃圾收集是在 JavaScript 中回收内存的方式。 浏览器决定何时发生。 在收集期间,页面上的所有脚本执行都将暂停,同时通过从 GC 根开始遍历对象图来发现实时值。 值都 所有无法访问的 被归类为垃圾。 垃圾值的内存由内存管理器回收。
V8 垃圾收集器详解
为了帮助进一步了解垃圾收集是如何发生的,让我们详细了解一下 V8 垃圾收集器。 V8 使用分代收集器。 内存分为两代人:年轻的和年老的。 年轻一代的分配和收集是快速和频繁的。 老年代的分配和收集速度较慢且频率较低。
Generational Collector
V8 使用两代收集器。 值的年龄定义为自分配以来分配的字节数。 在实践中,一个值的年龄通常由它存活的年轻代集合的数量来近似。 在一个值足够老之后,它就会被保留到老一代中。
实际上,新分配的值不会长期存在。 对 Smalltalk 程序的一项研究表明,只有 7% 的值在新生代收集后存活下来。 跨运行时的类似研究发现,平均而言,90% 到 70% 的新分配值从未进入老年代。
Young Generation
V8 中的新生代堆分为两个空间,命名为 from 和 to。 内存是从to空间分配的。 分配非常快,直到空间已满,此时会触发年轻代收集。 新生代收集首先交换 from 和 to 空间,旧的 to 空间(现在是 from 空间)被扫描,所有存活的值被复制到 to 空间或进入老年代。 典型的年轻代收集将花费大约 10 毫秒 (ms)。
直觉上,您应该明白您的应用程序进行的每次分配都会使您更接近耗尽空间并引发 GC 暂停。 游戏开发人员请注意:要确保 16 毫秒的帧时间(需要达到每秒 60 帧),您的应用程序必须进行零分配,因为单个新生代收集会占用大部分帧时间。
Old Generation
V8 中的老年代堆使用 mark-compact 算法 进行收集。 每当一个值从年轻代到老年代使用时,就会发生老年代分配。 每当老年代收集发生时,年轻代收集也会完成。 您的应用程序将暂停几秒钟。 在实践中这是可以接受的,因为老年代收集很少见。
V8 GC Summary
带有垃圾收集的自动内存管理对开发人员的工作效率非常有用,但是,每次分配一个值时,您都离垃圾收集暂停更近了一步。 垃圾收集暂停会引入卡顿,从而破坏您的应用程序的感觉。 现在您了解了 JavaScript 如何管理内存,您可以为您的应用程序做出正确的选择。
修复 Gmail 的问题
在过去的一年里,许多功能和错误修复已进入 Chrome DevTools,使它们比以往任何时候都更强大。 此外,浏览器本身对 performance.memory API 进行了重要更改,使 Gmail 和任何其他应用程序可以从现场收集内存统计信息。 有了这些很棒的工具,曾经看似不可能完成的任务很快就变成了追捕罪犯的激动人心的游戏。
工具和技术
Field Data and performance.memory API
从 Chrome 22 开始, performance.memory API 默认启用。 对于像 Gmail 这样长时间运行的应用程序,来自真实用户的数据是无价的。 这些信息使我们能够区分高级用户(每天花 8-16 小时在 Gmail 上,每天收到数百封邮件)和更普通的用户,他们每天花几分钟在 Gmail 上,每天收到十几封邮件一个星期的消息。
该 API 返回三段数据:
- jsHeapSizeLimit - JavaScript 堆限制的内存量(以字节为单位)。
- totalJSHeapSize - JavaScript 堆已分配的内存量(以字节为单位),包括可用空间。
- usedJSHeapSize - 当前正在使用的内存量(以字节为单位)。
要记住的一件事是 API 返回整个 Chrome 进程的内存值。 虽然不是默认模式,但在某些情况下,Chrome 可能会在同一个渲染器进程中打开多个标签页。 这意味着 performance.memory 返回的值可能包含其他浏览器选项卡的内存占用量,以及包含您的应用程序的选项卡。
按比例测量内存
Gmail 对其 JavaScript 进行了检测,以使用 performance.memory API 大约每 30 分钟收集一次内存信息。 由于许多 Gmail 用户一次让该应用程序运行数天,因此该团队能够跟踪内存随时间的增长以及总体内存占用统计数据。 在对 Gmail 进行检测以从随机抽样的用户中收集记忆信息的几天内,该团队获得了足够的数据来了解记忆问题在普通用户中的普遍程度。 他们设置了一个基线并使用传入数据流来跟踪减少内存消耗目标的进度。 最终,该数据还将用于捕获任何内存回归。
除了跟踪目的之外,现场测量还提供了对内存占用和应用程序性能之间相关性的敏锐洞察。 与“内存越多性能越好”的普遍看法相反,Gmail 团队发现内存占用越大,常见 Gmail 操作的延迟越长。 有了这个启示,他们比以往任何时候都更有动力控制他们的记忆消耗。
使用 DevTools 时间轴识别内存问题
解决任何性能问题的第一步是证明问题存在,创建可重现的测试,并对问题进行基线测量。 没有可重现的程序,您就无法可靠地衡量问题。 如果没有基线测量,您就不知道性能提高了多少。
DevTools Timeline 面板是证明问题存在的理想选择。 它完整地概述了加载 Web 应用程序或页面并与之交互时所花费的时间。 所有事件,从加载资源到解析 JavaScript、计算样式、垃圾收集暂停和重新绘制都绘制在时间轴上。 为了调查内存问题,Timeline 面板还有一个 Memory 模式,它跟踪分配的内存总量、DOM 节点数、窗口对象数和分配的事件侦听器数。
证明问题存在
首先确定您怀疑会泄漏内存的一系列操作。 开始记录时间线,并执行一系列操作。 使用底部的垃圾桶按钮强制进行完整的垃圾收集。 如果在几次迭代后,您看到一个 锯齿 形的图形,那么您正在分配大量短暂存在的对象。 但是,如果预期操作序列不会产生任何保留内存,并且 DOM 节点计数没有下降到开始时的基线,则您有充分的理由怀疑存在泄漏。
确认问题存在后,您可以从 DevTools Heap Profiler 获得帮助来确定问题的根源。
使用 DevTools 堆分析器查找内存泄漏
Profiler 面板提供了 CPU 分析器和堆分析器。 堆分析通过拍摄对象图的快照来工作。 在拍摄快照之前,新生代和老年代都会被垃圾回收。 换句话说,您只会看到拍摄快照时有效的值。
堆分析器中的功能太多,无法在本文中充分介绍,但 详细的文档 可以在 Chrome 开发者网站上找到 。 我们将在这里关注堆分配分析器。
使用堆分配分析器
Heap Allocation profiler 将 Heap Profiler 的详细快照信息与 Timeline 面板的增量更新和跟踪相结合。 打开 Profiles 面板,启动 Record Heap Allocations 配置文件,执行一系列操作,然后停止记录以进行分析。 分配分析器在整个记录过程中定期拍摄堆快照(频率为每 50 毫秒),并在记录结束时拍摄最后一张快照。
顶部的条形指示何时在堆中找到新对象。 每个条形的高度对应于最近分配的对象的大小,条形的颜色表示这些对象是否仍然存在于最终的堆快照中:蓝色条表示在时间线结束时仍然存在的对象,灰色条表示在时间线期间分配的对象,但此后已被垃圾收集。
在上面的例子中,一个动作被执行了 10 次。 示例程序缓存了五个对象,所以最后五个蓝色条是预期的。 但最左边的蓝色条表示存在潜在问题。 然后,您可以使用上方时间轴中的滑块放大该特定快照并查看最近在该点分配的对象。 单击堆中的特定对象将在堆快照的底部显示其保留树。 检查对象的保留路径应该可以为您提供足够的信息来理解未收集对象的原因,并且您可以进行必要的代码更改以删除不必要的引用。
解决 Gmail 的内存危机
通过使用上面讨论的工具和技术,Gmail 团队能够识别几类错误:无限制的缓存、无限增长的回调数组等待发生但实际上从未发生过的事情,以及事件监听器无意中保留了它们的目标。 通过修复这些问题,Gmail 的整体内存使用量显着减少。 99% 的用户使用的内存比以前减少了 80%,中位数用户的内存消耗下降了近 50%。
由于 Gmail 使用的内存较少,因此 GC 暂停延迟减少,从而提高了整体用户体验。
另外值得注意的是,随着 Gmail 团队收集有关内存使用情况的统计数据,他们能够发现 Chrome 内部的垃圾收集回归。 具体来说,当 Gmail 的内存数据开始显示分配的总内存与实时内存之间的差距急剧增加时,发现了两个碎片错误。
采取行动
问自己这些问题:
我的应用程序使用了多少内存?
您可能使用了过多的内存,这与普遍的看法相反,对整体应用程序性能产生了净负面影响。 很难确切知道正确的数字是多少,但是,请务必验证您的页面使用的任何额外缓存是否具有可衡量的性能影响。
我的页面没有泄漏吗?
如果您的页面存在内存泄漏,它不仅会影响您页面的性能,还会影响其他选项卡。 使用对象跟踪器帮助缩小任何泄漏的范围。
我的页面 GC 的频率如何?
查看任何 GC 暂停 时间轴面板 中的 您可以使用Chrome 开发人员工具 。 如果您的页面频繁进行 GC,很可能是您分配得太频繁,搅动了年轻一代的内存。
结论
我们在危机中起步。 涵盖了 JavaScript 和 V8 中内存管理的核心基础知识。 您学习了如何使用这些工具,包括最新版本的 Chrome 中提供的新对象跟踪器功能。 掌握了这些知识的 Gmail 团队解决了他们的内存使用问题并提高了性能。 您可以对您的网络应用程序执行相同的操作!
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论