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

更新日期: 2020-01-31阅读: 2.3k标签: 内存

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

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


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

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

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

  • 缺乏抱怨:大多数用户在上网时并未认真观察 Task Manager。通常,除非泄漏严重到导致选项卡崩溃或程序运行缓慢,否则你不会从用户那里听到有关它的消息。
  • 缺乏数据:Chrome 小组不提供有关网站在使用大量内存的数据。网站也不是经常自己测量的。
  • 缺少工具:用现有工具识别或修复内存泄漏仍然不容易。
  • 缺乏关怀:浏览器非常擅长于杀死占用过多内存的标签页。另外人们似乎喜欢指责浏览器而不是网站。

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


内存泄漏的剖析

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

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

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

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

解决方法是:

// Mount phase
this.onMessage = this.onMessage.bind(this);
window.addEventListener('message', this.onMessage);

// Unmount phase
window.removeEventListener('message', this.onMessage);

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


导致内存泄漏的情况

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

  1. addEventListener。这是最常见的一种,调用 removeEventListener 进行清理。
  2. setTimeout / setInterval。如果你创建一个循环计时器(例如每 30 秒运行一次),则需要使用 clearTimeout 或 clearInterval 进行清理。(如果像 setInterval 那样使用 setTimeout 可能会泄漏,即在 setTimeout 回调内部安排新的 setTimeout。)
  3. IntersectionObserver、 ResizeObserver、 MutationObserver 等。这些新颖的 API 非常方便,但它们也可能泄漏。如果你在组件内部创建一个组件并将其附加到全局可用元素,则需要调用 disconnect() 进行清理。 (请注意,垃圾收集的 DOM 节点也将会对它的垃圾监听器和观察者进行垃圾收集。因此,通常你只需要担心全局元素,例如文档、无所不在的页眉和页脚元素等)
  4. PromiseObservableEventEmitter,等。如果你设置了侦听器,但忘记了停止侦听,则任何用于设置侦听器的编程模型都可能会造成内存泄漏。 (如果 Promise 从未得到解决或拒绝,则可能会泄漏,在这种情况下,附加到它的任何 .then() 回调都会泄漏。)
  5. 全局对象存储。Redux 之类的状态是全局的,如果你不小心,可以持续为其添加内存,并且永远都不会被清除。
  6. 无限的 DOM 增长。如果在没有虚拟化的情况下实现无限滚动列表,则 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 链将向你显示哪个对象正在引用泄漏的对象。读取它的方式是每个对象都由其下面的对象引用。

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

class SomeObject () { /* ... */ }

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

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

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

  1. 如果保存并重新加载快照文件,则所有文件引用都将会丢失到分配对象的位置。例如你不会看到在 foo.js 第 22 行的事件监听器的关闭。由于这是非常关键的信息,因此保存和发送堆快照文件几乎没有用。
  2. 如果涉及 WeakMap,那么 Chrome 会向你显示这些引用,即使它们没关系——清除其他引用后,将立即取消分配这些对象。所以它们只是噪音。
  3. Chrome 根据对象的原型来对对象进行分类。所以使用实际类或函数的次数越多,使用匿名对象的次数越少,则更容易看到泄漏的确切内容。例如排查泄漏是否由于 object 而不是 EventListener 引起的。因为 object 非常通用,所以我们不太可能看到其中有 7 个存在泄漏。

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

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


内存泄漏自动分析

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

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

由于事件监听器是最常见的内存泄漏源,因此我使用的另一种技术是对 monkey-patch 的 addEventListener 和 removeEventListener API进行计数,从而进行计数引用并确保它们返回零。这里是如何执行此操作的示例

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


总结

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

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

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

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

作者:Nolan Lawson
翻译:疯狂的技术宅
原文https://nolanlawson.com/2020/02/19/fixing-memory-leaks-in-web-applications/


链接: https://fly63.com/article/detial/8021

JavaScript 内存管理和垃圾回收

JavaScript 的内存管理和垃圾回收,是个略生僻的话题,因为在JavaScript 中不显式执行内存操作,不过最好了解它如何工作。

js常见的内存泄漏及解决方法总汇

js具有自动回收垃圾的机制,即执行环境会负责管理程序执行中使用的内存。在C和C++等其他语言中,开发者的需要手动跟踪管理内存的使用情况。在编写js代码时候,开发人员不用再关心内存使用的问题,所需内存的分配 以及无用的回收完全实现了自动管理。

浅谈javaScript内存机制

javaScript内存空间并不是一个经常被提及的概念,想要对JS的理解更加深刻,就必须对内存空间有一个清晰的认:栈与堆、复杂数据类型与基本数据类型、引用数据类型与堆内存

js 把一个对象赋值给另一个对象会指向同一个内存地址

实际上并不是新建一个和原对象(数组也是对象)完全一样的对象,而是把原对象的内存地址直接复制给了另一个对象,也就是说两个对象都是指向同一个内存地址,所以实际上它们就是同一个对象。

php底层原理之垃圾回收机制

php垃圾回收机制,对于PHPer来说是一个不陌生但是又不是很熟悉的内容。那么php是怎么实现对不需要的内存进行回收的呢?首先还是需要了解下基础知识,便于垃圾回收原理内容的理解。

js变量、作用域和内存问题

JavaScript变量可以用来保存两种类型的值:基本类性值和引用类性值。所有变量(包括基本类型和引用类型)都存在于一个执行环境(也称为作用域)当中,具有自动垃圾收集机制的编程语言,开发人员不必关心内存分配和回收问题

php中的内存管理

计算机的内存由操作系统进行管理,所以普通应用程序是无法直接对内存进行访问的。应用程序只能向操作系统申请内存,通常的应用也是这么做的,在需要的时候通过类似malloc之类的库函数 向操作系统申请内存。

原生JS与Jquery删除iframe并释放内存-IE

当项目以tab页签方式打开多个iframe窗口时,关闭tab页签同时也需要关闭iframe并释放内存资源,主要是针对IE浏览器。

闭包真的会导致内存泄漏?

今天遇到一个很有争议的问题,在这里分享一下,我相信对于即将面试前端的小伙伴会有帮助的。主要内容是围绕下边的问题展开的,文章涉及到的其他方面的知识点不展开叙述。

Web 应用的内存优化

随着 Web 应用复杂程度越来越高,以及 NodeJS 大规模投入生产环境,许多 Web 应用都会长时间运行, JavaScript 的内存管理显得更为重要。JavaScript 具备自动回收垃圾的机制

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!