Javascript程序是否会泄漏内存?答案是肯定的。我们很容易忘记删除事件订阅而导致保留对象。这在大多数情况下,这没什么大不了的,因为几KB的内存似乎并不碍事?但是,这样的几kb累积多了,可能增加到几MB甚至几百MB,结果就会出现臭名昭著的“ Aw,snap!”。所以解决Javascript内存泄漏问题是很有必要的,但是在开始调试内存泄漏之前,我们应该首先了解下什么是内存泄漏。
内存通常如何工作
在编程中,当您分配内存以将数据存储在堆中时,需要再次释放内存,然后OS才能将其用于其他用途。在诸如javascript之类的垃圾收集语言中,内存分配和释放通常是在后台进行的。每当创建新对象时就会分配内存,而当垃圾收集器检测到对该对象的所有引用均已消失时,则将其释放。对象引用是允许访问该对象的变量(指针)。Javascript的内存是按对象网络来管理的,每个对象对应一个网络节点。
当您认为会被垃圾回收的对象没有被收集时,就会发生内存泄漏。
请注意,内存泄漏和正常行为之间的区别是意图!所有类型的内存泄漏仅在您不希望对象保留在内存中的情况下才算。
内存泄漏的一些例子
全局变量
由于对象是否可访问是决定垃圾收集的因素,因此从定义上讲,所有全局对象都不会被收集。这不仅指窗口(nodejs上的全局窗口),还指javascript模块导出的所有变量。
javascript模块示例:
class SomeClass {…}
export const permanentInstance = new SomeClass();
永久实例永远不会被收集,因为它是一个全局对象,因为它可以在任何地方导入。特别要注意的是,在大多数情况下,当发生大量内存泄漏时,问题一般不是单个全局实例,而是全局实例保留其他内容的后果,因为全局实例引用的所有内容也不会被垃圾回收。
观察者模式(事件)
观察者模式在javascript中非常流行,但是它也容易导致内存泄漏:每个事件订阅都是潜在的内存泄漏。每个事件订阅API都具有取消订阅功能是有原因的:创建订阅时,回调通过引用传递给事件源,并存储在事件源中,直到调用取消订阅或事件源本身被垃圾回收为止。
这意味着:每当您订阅时,如果没有匹配的取消订阅,则只要事件源存在,回调的闭包就会一直存在。此闭包泄漏了回调函数内部使用的,来自函数外部的所有变量
例:
const someData = “hello world”;
someButton = document.getElementById(“button”)
someButton.addEventListener(‘click’, () => console.log(someData))
只要按钮存在于页面中并且未删除事件侦听器,由于传递给click事件的箭头函数创建的闭包操作,字符串hello world仍将保留在内存中。
如何调试内存泄漏
内存泄漏很难调试,通常您不知道要查找什么,有时泄漏是静态不变的,因此检测非常困难,因为通常来说调试内存泄漏的最好办法,是观察随着时间的推移内存消耗是否会持续变大。
发现泄漏
您可以在Web应用程序中四处单击(或者有时甚至只是等待),然后定期检查内存并查看趋势。所有创建大量对象的应用程序都会看到内存随时间增加。这不一定是泄漏!垃圾回收在对CPU来说是费时的操作,因此垃圾回收器不会一直对内存进行完全扫描。您可以使用chrome的检查器中的“性能”标签来查看一段时间内的内存使用情况图表。启动监控,执行一些操作然后停止记录
这看起来像一个典型的图,内存增加,垃圾收集器进行一次扫描,内存减少到其大致起始位置。
问题是,如果它在执行页面上的操作时没有下降到大致开始的位置,而是越来越高,这可能表明有内存泄漏的情况。
读取内存快照
本节以 chrome 检查器(Chrome Inspector)为例。内存快照也可以由其他调试器以不同的方式表示。
在chrome的memory标签中,您可以选择进行堆快照。这将创建整个内存网络的大型json报告,并将其显示为节点列表。
该列表按节点类型分组,展开后会看到给定类型的内存网络中的每个节点。其中距离代表在内存网络中您必须从活动域至少遍历多少个节点才能到达的对象。Shallow大小是对象本身使用的内存量,保留大小( retained size)是对象避免垃圾回收所保留的内存量。
您会发现一些节点是javascript引擎内部的,例如编译后的代码,系统等,您无需担心这些节点,因为您可以假设潜在的泄漏源于您的代码而不是浏览器。
当您单击一个节点时,会得到一个“retainers”列表,该列表是网络中的一个路径,可防止垃圾收集器收集对象(注:在对象网络中,有路径指向的不收集)。但是,如果您有一个高度互连的数据结构(例如带有父引用的树),那么会有很多路径,而chrome会选择一个路径显示给您,它可能会向您显示一个路径,该路径具有父子对象来回引用的无限递归,从而无法显示导致保留对象的根节点对象。
因此,当您找到可疑对象时,您的目标是查看保留了它的对象,然后根据您不希望引用的位置来确定。为了解决这个问题,您可以将鼠标悬停在某个节点上或单击该节点,然后在控制台中键入$0以获得对内存中该节点的引用,这可以帮助您了解保留对象的对象是什么。
调试泄漏
您不太可能找到所有最近的泄漏,尤其是小的泄漏,但是像在其他的优化中一样,我们从最严重的泄漏开始,然后逐步解决,直到对内存使用感到满意为止。
如果您直觉感觉哪些对象可能泄漏,并且这些对象是类实例,那么您很幸运,这操作起来很简单:转到Chrome检查器的“内存”选项卡,选择“堆快照”,然后单击“开始”,完成后在搜索栏中输入您的类名称。如果找到匹配项,则可以查看匹配项的数量,并将其与页面所在状态所期望的实例数进行比较。例如,如果您有10个“Widget”实例,但只需要2个widgets,那么问题基本就出在这了。
如果您不知道要寻找什么,一种策略是考虑应用程序中随着时间的推移而创建和销毁的主要类实例。像小widget,编辑器,ui组件等。然后进入和退出某些状态,在该状态下会重复创建这些对象,然后进行堆快照,并将内存中的实例数量与预期数量进行比较。
如果仍然无法获得良好的结果,则另一种不涉及类实例的策略是3个快照技术:
- 将页面置于所需状态,然后按F5键获得干净状态。页面加载后,进行堆快照。
- 执行一些操作(不刷新!),并使页面大致回到步骤1结束时的状态。做另一个堆快照
- 尝试尽可能更改页面的状态,但请确保没有刷新发生,因为刷新会清除内存,浪费我们的精力。页面状态完全不同后,获取第3个堆快照
现在,您可以单击第3个快照,然后在类过滤器旁边选择在快照1和快照2之间分配的对象。
这将显示快照1和2之间创建的所有对象,它们仍然存在于快照3中!这就是为什么在步骤3中尽可能多地更改状态如此重要:以确保收集到尽可能多的对象的原因。这样可以发现一小部分的棘手的内存问题,可以根据具体情况决定是否这样做。
总之,在涉及内存泄漏时,类实例是很好的着手的点,而匿名对象混杂在对象网络中,很难发现这些对象的意外堆积(内存泄漏)。