怎样修复 Web 程序中的内存泄漏

646 阅读13分钟

翻译:疯狂的技术宅

作者:Nolan Lawson

来源:nolanlawson

正文共:4737 字

预计阅读时间:10 分钟


从服务器端渲染的网站切换到客户端渲染的 SPA 时,我们突然不得不更加注意用户设备上的资源,必须做很多工作:不要阻塞 UI 线程,不要使笔记本电脑的风扇疯狂旋转,不要耗尽手机的电池等。我们将交互性和“类应用程序”行为转换成了更好的新型问题,这些问题实际上并不存在在服务端渲染的世界中。

这些问题中最主要的一个是内存泄漏。编码不正确的 SPA 可能很容易耗尽 MB 甚至 GB 的内存,从而继续吞噬越来越多的资源,即使它无辜地存在于后台标签中也是如此。这时页面可能开始变成龟速,或者浏览器终止了标签页,你将会看到熟悉的 “Aw, snap!” 页面。

Chrome page saying "Aw snap! Something went wrong while displaying this web page."

(当然,服务端渲染的网站也可能会泄漏服务器端的内存。但是客户端泄漏内存的可能性很小,因为每次你在页面之间导航时浏览器都会清除内存。)

Web 开发文献中没有很好地解决内存泄漏问题的方法。但是,我非常确定大多数不凡的 SPA 都会泄漏内存,除非它们背后的团队拥有强大的基础结构来捕获和修复内存泄漏。用 JavaScript 太容易了,以至于不小心分配了一些内存而忘了清理它。

那么,为什么关于内存泄漏的文章这么少呢?我的猜测是:

  • 缺乏抱怨:大多数用户在上网时并未认真观察 Task Manager。通常,除非泄漏严重到导致选项卡崩溃或程序运行缓慢,否则你不会从用户那里听到有关它的消息。

  • 缺乏数据:Chrome 小组不提供有关网站在使用大量内存的数据。网站也不是经常自己测量的。

  • 缺少工具:用现有工具识别或修复内存泄漏仍然不容易。

  • 缺乏关怀:浏览器非常擅长于杀死占用过多内存的标签页。另外人们似乎喜欢指责浏览器 而不是网站。

在本文中,我想分享一些我在解决 Web 程序中的内存泄漏方面的经验,并提供一些示例来说明如何有效地跟踪它们。

内存泄漏的剖析

像 React、Vue 和 Svelte 这样的现代 Web 框架都使用基于组件的模型。在此模型中,产生内存泄漏的最常见方法是这样的:

1window.addEventListener('message', this.onMessage.bind(this));

就这样,引入了一个内存泄漏。如果你在某些全局对象(window<body> 等)上调用 addEventListener 然后在卸载组件时忘记用 removeEventListener 进行清理,就会产生一个内存泄漏。

更糟糕的是,你刚刚泄漏了整个组件。由于 this.onMessage 绑定到 this,所以组件已泄漏,包括其所有子组件。而且很可能所有与组件相关联的 DOM 节点也是如此。这会很快会变得非常糟糕。

解决方法是:

1// Mount phase2this.onMessage = this.onMessage.bind(this);3window.addEventListener('message', this.onMessage);45// Unmount phase6window.removeEventListener('message', this.onMessage);

注意,我们保存了对绑定的 onMessage 函数的引用。你必须把前面传给 addEventListener 的函数再原封不动的传给 removeEventListener,否则它将无法正常工作。

导致内存泄漏的情况

以我的经验,最常见的内存泄漏源与以下 API 相关:

  1. addEventListener。这是最常见的一种,调用 removeEventListener 进行清理。

  2. setTimeout/setInterval。如果你创建一个循环计时器(例如每 30 秒运行一次),则需要使用 clearTimeoutclearInterval 进行清理。(如果像 setInterval 那样使用 setTimeout 可能会泄漏,即在 setTimeout 回调内部安排新的 setTimeout。)

  3. IntersectionObserverResizeObserverMutationObserver 等。这些新颖的 API 非常方便,但它们也可能泄漏。如果你在组件内部创建一个组件并将其附加到全局可用元素,则需要调用 disconnect() 进行清理。(请注意,垃圾收集的 DOM 节点也将会对它的垃圾监听器和观察者进行垃圾收集。因此,通常你只需要担心全局元素,例如文档、无所不在的页眉和页脚元素等)

  4. Promise, Observable, EventEmitter等。如果你设置了侦听器,但忘记了停止侦听,则任何用于设置侦听器的编程模型都可能会造成内存泄漏。(如果 Promise 从未得到解决或拒绝,则可能会泄漏,在这种情况下,附加到它的任何 .then() 回调都会泄漏。)

  5. 全局对象存储。Redux 之类的状态是全局的,如果你不小心,可以持续为其添加内存,并且永远都不会被清除。

  6. 无限的 DOM 增长。如果在没有虚拟化(https://github.com/WICG/virtual-scroller#readme)的情况下实现无限滚动列表,则 DOM 节点的数量将会无限增长。

当然,还有许多其他导致泄漏内存的情况,但这些是最常见的。

识别内存泄漏

这是困难的部分。首先我要说的是,我认为那里的任何工具都不是很好。我尝试使用 Firefox 的内存工具,Edge 和 IE 内存工具,甚至 Windows Performance Analyzer。同类最佳的仍然是 Chrome Dev Tools,但是它有很多杂乱的细节值得我们了解。

在 Chrome Dev Tools中,我们选择的主要工具是“内存(Memory)”标签中的“堆快照(heap snapshot)”。Chrome 中还有其他存储工具,但我发现它们对识别泄漏不是很有帮助。

带有堆快照工具的Chrome DevTools内存选项卡

堆快照工具使你可以捕获主线程、Web Worker 或 iframe 的内存。

当你点击“获取快照(take snapshot)”按钮时,你已经捕获了该网页上特定 JavaScript VM 中的所有活动对象。这包括 window 所引用的对象,setInterval 回调所引用的对象等。可将其视为时间暂停后,代表该网页使用的所有内存。

下一步是重现你认为可能正在泄漏的某些场景,例如,打开和关闭模态对话框。对话框关闭后,你希望内存恢复到上一级。因此,你获取了另一个快照,然后将其与上一个快照进行比较。这种差异确实是该工具的杀手级特性。

显示第一个堆快照的示意图,然后是一个泄漏的场景,然后是第二个堆快照,该快照应该等于第一个

但是,你应该注意该工具的一些限制:

  1. 即使单击“收集垃圾(collect garbage)”小按钮,你可能也需要为 Chrome 连续产生多个快照才能真正清除未引用的内存。以我的经验,三个就足够了。(检查每个快照的总内存大小——它最终应稳定下来。)

  2. 如果你有 Web worker、service worker、iframe、shared worker 等,则该内存将不会在堆快照中表示,因为它位于另一个 JavaScript VM 中。你可以根据需要捕获此内存,但只需确保知道要测量的内存即可。

  3. 有时快照程序会卡住或崩溃。在这种情况下,只需关闭浏览器选项卡,然后重新开始即可。

此时,如果你的程序很复杂,那么可能会在两个快照之间看到大量的泄漏对象。这是棘手的地方,因为并非所有这些都是真正的泄漏。其中许多只是正常用法——某些对象被取消分配,而另一个对象被优先分配,某些对象以某种方式被缓存,以便稍后进行清理,等等。

消除噪音

我发现消除噪音的最好方法是多次重复泄漏情况。例如,你不仅可以执行一次打开和关闭模式对话框这种操作,还可以将其打开和关闭 7 次。(7 是一个质数。)然后你可以检查堆快照 diff,以查看是否有什么对象泄漏7次。(或14次或21次。)

Chrome开发者工具堆快照差异的截图显示了六个堆快照捕获,其中有多个对象泄漏了7次

堆快照差异。请注意,我们正在将 6 号快照与 3 号快照进行比较,因为我连续拍摄了三个快照,以便进行更多的垃圾收集。注意,有几个对象泄漏了 7 次。

(另一种有用的技术是在记录第一个快照之前对方案进行一次遍历。特别是如果你进行大量的代码拆分,则方案可能会花费一次内存来加载必要的 JavaScript 模块。)

你可能想知道为什么应该按对象数而不是总内存进行排序。直观地讲,我们正在努力减少内存泄漏的数量,所以我们不应该专注于总的内存使用情况吗?嗯,这不是很好,有一个很重要的原因。

当什么东西泄漏时,是因为你想要得到香蕉,但是最终得到的是香蕉、拿着香蕉的大猩猩以及整个丛林。如果你基于总字节数进行衡量,那么你所衡量的是丛林,而不是香蕉。

大猩猩吃香蕉

让我们回到上面的 addEventListener 的例子。泄漏的来源是事件侦听器,该事件侦听器引用一个函数,该函数引用一个组件,该组件可能引用大量的东西,例如数组、字符串和对象。

如果你按总内存对堆快照差异进行排序,那么它将向你显示一堆数组、字符串和对象——其中大多数可能与泄漏无关。你真正想要找到的是事件侦听器,但是与它所引用的内容相比,占用的内存很小。要修复泄漏,你要找到香蕉,而不是丛林。

所以,如果按泄漏对象的数量进行排序,则会看到 7 个事件监听器。可能是 7 个组件和 14 个子组件等等。“7” 应该像腰间盘一样突出,因为它是一个不寻常的数字。而且,无论你重复该场景多少次,都应该确切的看到泄漏的对象数量。这样可以快速找到泄漏源。

retainer 树

堆快照差异还将向你显示一个 “retainer” 链,该链显示哪些对象指向哪些其他对象,从而使内存保持活动状态。这样可以弄清楚泄漏对象的分配位置。

事件监听器引用的闭包所引用的 someObject 的 retainer 链

retainer 链将向你显示哪个对象正在引用泄漏的对象。读取它的方式是每个对象都由其下面的对象引用。

在上面的示例中,有一个名为 someObject 的变量,该变量由闭包(也称为“上下文”)引用,并由事件侦听器引用。如果单击源链接,它将带你到 JavaScript 声明,这很简单:

1class SomeObject () { /* ... */ }23const someObject = new SomeObject();4const onMessage = () => { /* ... */ };5window.addEventListener('message', onMessage);

在上面的示例中,“上下文”是 onMessage 闭包,它引用了 someObject 变量。(这是一个人为的例子(https://github.com/nolanlawson/pinafore/commit/de6ca2d85334ad5f657ddd0f335750b60afab895);实际的内存泄漏可能不那么明显!)

但是堆快照工具有几个限制:

  1. 如果保存并重新加载快照文件,则所有文件引用都将会丢失到分配对象的位置。例如你不会看到在 foo.js 第 22 行的事件监听器的关闭。由于这是非常关键的信息,因此保存和发送堆快照文件几乎没有用。

  2. 如果涉及 WeakMap(https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap),那么 Chrome 会向你显示这些引用,即使它们没关系——清除其他引用后,将立即取消分配这些对象。所以它们只是噪音。

  3. Chrome 根据对象的原型来对对象进行分类。所以使用实际类或函数的次数越多,使用匿名对象的次数越少,则更容易看到泄漏的确切内容。例如排查泄漏是否由于 object 而不是 EventListener 引起的。因为 object 非常通用,所以我们不太可能看到其中有 7 个存在泄漏。

这是识别内存泄漏的基本策略。我过去已经成功地用这种技术发现了许多内存泄漏。

但是,本指南只是一个开始——除此之外,你还必须随手设置断点、记录日志并测试你的修复程序,以查看它是否可以解决泄漏。不幸的是,这是一个非常耗时的过程。

内存泄漏自动分析

在此之前,我要说的是,我还没有找到一种自动检测内存泄漏的好方法。Chrome 有非标准的 performance.memory API,但出于隐私方面的考虑它没有非常精确的粒度(https://bugs.webkit.org/show_bug.cgi?id=80444),因此你不能真正在生产中用它来识别泄漏。W3C 网络性能工作组过去讨论了内存 工具,但尚未就取代该 API 的新标准达成共识。

在实验室或综合测试环境中,你可以用 Chrome 标志 --enable-precise-memory-info。还可以通过调用专有的 Chromedriver 命令 :takeHeapSnapshot 创建堆快照文件。但是这也具有上述相同的限制——你可能想要连续获取三个并丢弃前两个。

由于事件监听器是最常见的内存泄漏源,因此我使用的另一种技术是对 monkey-patch 的 addEventListenerremoveEventListener API进行计数,从而进行计数引用并确保它们返回零。这里是如何执行此操作的示例(https://github.com/nolanlawson/pinafore/blob/2edbd4746dfb5a7c894cb8861cf315c800a16393/tests/spyDomListeners.js)。

在 Chrome Dev Tools 中,你还可以使用专有的 getEventListeners() API 来查看事件监听器附加到特定元素。注意,这只能在 Dev Tools 中使用。

总结

在 Web 应用中查找和修复内存泄漏的状态仍然很初级。在本文中,我介绍了一些对我有用的技术,但是请记住,这仍然是一个困难且耗时的过程。

与大多数性能问题一样,少量预防胜过大量的治疗。你可能会发现进行综合测试是值得的,而不是在事实发生后尝试调试内存泄漏。尤其是如果页面上存在多个泄漏,则可能会变成洋葱剥皮练习——你先修复一个泄漏,然后查找另一个泄漏,然后重复(整个过程都在哭泣!)。如果你知道要查找的内容,代码审查还可以帮助捕获常见的内存泄漏模式。

JavaScript 是一种内存安全的语言,具有讽刺意味的是,在 Web 应用中泄漏内存有多么容易。不过部分原因只是 UI 设计所固有的——我们需要侦听鼠标事件、滚动事件、键盘事件等,而这些都是容易导致内存泄漏的模式。但是,通过尝试降低 Web 应用的内存使用量,可以提高运行时性能,避免崩溃,并尊重用户设备上的资源限制。

感谢 Jake Archibald 和 Yang Guo 对本文的草稿提供反馈。感谢 Dinko Bajric 发明了“choose a prime number”技术,我发现它在内存泄漏分析中非常有用。


欢迎关注前端公众号:前端先锋,免费领取 Vue、React 性能优化教程。