介绍几月前,我不得不调试Node.js程序中的内存泄漏,对此找到了很多此类的文章,但细读了一些后,还是不知所措。 这篇文章的初衷是定位Node.js中内存泄漏的一个简单的教程。我将介绍一简单的方法入门,(以我来看)这应该是任何内存泄漏调试的起点。对于一些情况,这方法或许并不详尽。我将附上一些你想要了解的资源链接。 最小化理论JavaScript是一门具有垃圾回收机制的语言。因此,所有Node.js进程使用的内存将由V8 JavaScript引擎自动分配、释放。 V8如何知道何时释放内存?V8维护着一个的图,这个图始于根节点,记录程序中所有变量。 JavaScript有4种数据类型:Boolean, String, Number及Object。前3个是简单类型,仅由分配的变量保存(如string文本)。Object及其其它任何数据在JavaScript中皆为 Object(如数组是Object),能记录其它对象的引用。
V8会周期性的遍历内存图,试图确认不再被根节点抵达的一组数据。假如无法从根节点抵达,V8会认为此数据不再被使用,随即释放内存。这个过程称为垃圾回收。 何时发生内存泄漏?当不再需要的数据依然可从根节点抵达时,JavaScript中就出现了内存泄漏。V8将认为此类数据依然在使用当中,并且不会进行垃圾回收。为了调试内存泄漏,我们需要定位被错误保留的数据的位置,确保V8能够清理。 值得注意的是垃圾回收并不会时刻进行,通常当V8认为时机合适时,才会进行触发垃圾回收。如周期性的触发,或可用内存很紧张时会特殊性调用。Node.js的每个进程的内存使用有限,所以V8必须谨慎使用。
另一特殊性调用的情况,是造成性能急速下降的地方。 试想有一个app有诸多内存泄漏, 不久,Node进程会用尽内存,由此V8会触发特殊性内存回收。但自大多数数据依然可从根节点访问,只有非常少的数据会被清理。 不久,Node进程将再次用尽内存,触发另一次垃圾回收。在你察觉前,app会不断的进行垃圾回收,只是尝试让进程运行。尽管V8花了大多精力在垃圾回收,但只有非常少的资源留给实际的程序。 步骤 1、重现并确认问题正如我前面所指出的,JavaScript的V8引擎使用了一套复杂的逻辑来觉id那个什么时候垃圾收集应该运行。明白了这个,就会知道尽管我们可以看到用于一个Node现成的内存在持续地增涨,我们还是不能确定自己是否目击了一次内存泄露, 直到我们知晓了垃圾收集已经运行起来,让不再被使用的内存可以被清理出来。 值得庆幸的是,Node允许我们手动触发垃圾回收,而这是在尝试确认一个内存泄露问题时我们应该要做的第一件事情。这件事情可以借助在运行 Node 时带上 --expose-gc 标识(例如 node --expose-gc index.js)来完成。一旦node以那个模式运行,你就可以用编程的方式通过从你的程序调用 global.gc() 来在任何时刻触发一次垃圾回收。 你也可以借助于调用 process.memoryUsage().heapUsed 来检测进程使用的内存数量。 通过手动触发垃圾回收并检测堆的使用情况,你就能够判别出自己是否实际地观察到了程序中的一次内存泄露。 示例程序我已经创建了一个简单的内存泄露程序,你可以看看这儿:https://github.com/akras14/memory-leak-example 你可以把它clone下来,然后运行 node --expose-gc index.js 将它跑起来。
这个程序将会:
如果你使用 node --expose-gc index.js (或者是 npm start)来运行这个程序, 它就会开始输出内存的统计信息。我们让它跑一两分钟然后使用 Ctr + c 快捷键杀掉它(进程)。 你会看到内存使用在快速地上涨,尽管每两分钟我们都是触发了垃圾回收的,就在我们获得这份统计数据之前:
With the stats output looking something like the following:
如果你以图表展现数据,那么内存的增长态势会表现得更加明显。 注意:如果你比较好奇我是如何做到以图表展现数据得,请继续都下去。如果不感兴趣的话请跳到 下一节。 我是将输出的统计数据保存到了一个 JSON 文件中,然后用几行Python代码读入它并以图表展示出来。为避免混乱,我已经将其保存造一个独立的分支中,而你可以在这儿check出来: https://github.com/akras14/memory-leak-example/tree/plot 相关的部分内容如下:
还有:
你可以check出 plot 分支,然后跟往常一样运行程序。一旦你运行完 plot.py ,就会有图表生成出来。你会需要在机器上安装好 Matplotlib 库,才能让程序跑起来。 或者你也可以在Excel中以图表展现出来。 步骤 2、做至少3次堆的转储好了,我们已经重现了问题,接下来该如何呢? 现在我们需要搞清楚问题出在哪儿,然后解决它。 你也许已经注意到在我上面的示例中如下的几行代码:
我是使用了一个 node-heapdump 模块,你可以在这儿找到:https://github.com/bnoordhuis/node-heapdump 为了能使用 node-heapdump, 你只需要这样做:
如果你从前没有遇到过 kill 这部分的话,其实它是 Unix 中的一个命令,你可以用它来(在其它东西中) 发送自定义信号 (也就是用户信号(User Signal))给任何正在运行的进程。Node-heapdump 被配置为当它收到一个用户信号二,也就是 -USR2, 后面带上进程id,就要做一次进程的堆转储。 在我的示例程序中,通过运行 process.kill(process.pid, 'SIGUSR2'); ,我对 kill -USR2 {{pid}} 命令进行了自动化,这里 process.kill 是针对 kill 命令的一个封装,SIGUSR2 是 -USR2 的Node表示方式, 而 process.pid 会获取到当前 Node 进程的 id。我会在每次垃圾回收之后运行这个命令来获得一个干净的堆转储。 我想 process.kill(process.pid, 'SIGUSR2'); 是不会在 Windows 上面运行的, 不过你还是可以运行 heapdump.writeSnapshot() 来实现同样的事情。 如果第一时间就使用 heapdump.writeSnapshot() 的话,这个示例也许会稍微简单一点,不过我想提一提的就是,你还是可以在类 Unix 平台上使用 kill -USR2 {{pid}} 信号来触发一次堆转储,而这可能会拍上用场。 下一节会讲到我们如何使用生成的堆转储来堆内存泄露进行隔离。 步骤3、定位问题在第二步中,我们做了堆转储,但是我们将至少需要3块,你不久就会明白为什么要这样。 你一旦有了堆转储。马上去谷歌浏览器,打开浏览器开发者工具(windows系统快捷键是F12,Mac上是Commands+Options+i)。 一旦在开发者工具里导航到“Profiles”的标签,在屏幕的底部选择“加载”按键,导航到你导入的第一块对转储并且选中它。对转储将会加载进浏览器里,如下图所示: 继续把另外2块堆转储加载到view视图中。例如,你可以使用你导入的最后2块堆转储。最重要的事情是,堆转储必须依照顺序来加载。你的文件夹导航大概如下图所示: 你可以从图中获取的信息是,堆继续随着时间的推移而增长。 3个堆转储方法堆转储一旦加载好,你将会在文件夹导航栏看见许多子视图,并且它们很容易丢失。但是,我发现有一个视图特别有用。 点击你导入的最后一个堆转储,它将会马上呈现“概要”视图给你。到左边的概要导航栏下拉,你可以看见另一个全部的下拉菜单。点击它并且选择“对象被分配在你第一块堆转储与第二和最后一块堆转储之间”,如下图所示: 它将会展示我们在第一块与最后一块堆转储所分配的所有对象。事实是,这些对象依然存在于你的堆中,引起关注和值得研究,由于它们已经被垃圾回收器回收。 事实很令人吃惊,但是并不是靠着直觉来查找,并且容易被忽略。 忽略括号里任何事物,至少要做的如下(字符串)完成示例程序的概述步骤后,我以如下视图作为总结。 注意到阴影大小代表对象本身,而剩下的数量代表对象与所有子对象。 似乎还有5个条目保存在我的快照里(数组)、(编译的代码)、(字符串)、(系统)以及简单类。 它们看起来只有简单类似曾相识,由于它来自如下示例程序中的代码。
它可能是诱人的开始,通过(数组)或者(字符串)。在概要视图中所有对象由它们的的构造函数名称来分组。对于数组或者字符串来说,那些构造函数内嵌到JavaScript引擎里。当你的程序非常确定:通过构造函数创建来传递一些数据,你也会在那里获取一些脏数据,使得我们更难找出内存泄露的根源。 这就是为什么我们一开始跳过那些步骤,看看这样你是否能发现可疑的内存,比如在示例程序的简单类构造函数里。 在简单类的构造函数点击下拉菜单,从结果列表中选择任意被创建的对象,将会在窗口下部填充剩下的路径(请看上图)。从那里很容跟踪我们数据中的内存泄露部分。 如果在你的app中你不够幸运,像我在app里遇到问题一样,你应该查找内部构造方法(比如字符串),从那里尝试找出内存泄露的来源。在这种情况下,关键是要尝试其他组别的值,经常出现在一些内部构造函数里,尝试使用提示指向一个内存泄漏的可疑之处。 比如,在示例程序中,你可以观察到许多字符串,看起来像是随机数转换成字符串的。假如你检测他们的原始路径,Chrome浏览器开发者工具将会指出内存泄露的数组。 第四步.确认问题已解决确认并解决了一个可疑的内存泄漏之后,你就应该在你的堆使用中看到很大的不同。 如果我们在示例应用程序中取消下面这一行的注解:
然后像第一步描述的那样重新运行应用,你将会观察到下面这样的输出:
如果我们绘制数据图表,就会得到类似于这张图: 万岁!!内存泄漏消失了。 注意在内存使用初始时的尖峰时刻仍然存在,当程序等到稳定时就会正常。注意那个尖峰时刻,在你分析的时候不要把它当做内存泄漏 其他相关资源 谷歌浏览器开发者工具中的内存分析在这篇文章中,你读到的大多数东西可以从上面的视频中得到。这篇文章存在的唯一理由是因为我在这两周的学习中为了发现(我所相信的)关键点,我不得不观看3次视频,我希望让这个发现过程对其他人来说比较容易些。 我强烈建议你看这个视频来补充这个帖子。 另一个有用的工具——memwatch-next这是另一个我认为值得一提的工具。你从这里可以阅读更多推荐它的理由(篇幅不长,值得阅读)。 或直接去github库: https://github.com/marcominetti/node-memwatch 不用点击下载,你也可以通过这样的方式安装:npm install memwatch-next 然后通过两个事件使用它:
最后的后台日志将会输出像下面这样的东西,为你显示什么类型的对象在内存中增长了。
很酷. 来自developer.chrome.com的JavaScript内存分析。https://developer.chrome.com/devtools/docs/javascript-memory-profiling 强烈推荐必读。它涵盖了所有比我接触到的还要多的主题,并且更加详细更加准确。 不要忽视 Addy Osmani在下面谈到的,他提到了一些调试的提示和资源。 总结
|