Node.js 调试之内存泄漏篇
前言
JavaScript 是一种垃圾回收(Garbage Collection,GC)语言,Node 进程使用的内存一般都是通过 JavaScript 引擎来分配和回收的,比如 V8。
V8 怎么知道内存在什么时候需要回收呢?从根节点开始,V8 使用图来描述程序中的变量,在浏览器中这个根节点是 window 对象,在 Node 中这个根节点是 global 对象。之后 V8 会定期遍历这个图,识别出那些从根节点无法访问到的数据,这些数据不会再使用,内存需要被释放掉,这个过程被叫做垃圾回收(GC)。
内存泄漏
什么是内存泄漏呢?很简单,一些不再需要的数据仍然可以从根节点访问,本应被回收的内存没被回收,这就是内存泄漏。为了调试内存泄漏,我们需要定位哪些数据被错误的保留,然后修改代码使得 V8 能够正确的回收这些数据的内存。需要注意的是,垃圾回收并不会一直运行,而是在需要的时候运行,比如周期性的运行,或者当可用内存降低到一定程度的时候运行。
那么什么时候可能出现内存泄漏呢?当您应用的性能随着时间的推移逐渐变差,重启后性能又变好,这种情况很可能出现了内存泄漏。我们又该如何定位呢?下面我们使用 heapdump 和 Chrome 开发者工具(DevTools)来分析一个经典的内存泄漏案例。
案例分析
要分析的代码很简单,如下图所示(index.js):
前端高手可能一眼就看出这段代码的问题,如果你看出来了,请假装没看出来,这样我们才有继续分析下去的必要。
接下来让我们首先运行一下这个程序 —— node index.js,然后执行 kill -USR2 pgrep -n node
命令生成 dump 文件,"pgrep -n node"是用来获取 node 进程 pid 的,kill -USR2
这里我执行了 3 次 kill -USR2 pgrep -n node
,生成了 3 个 dump 文件,具体如下:
内存分析
现在我们获得了 3 个 dump 文件,由于 Node 是依赖 V8 引擎执行 JavaScript 的,Chrome 浏览器也是,所以我们可以借助 Chrome 开发者工具中的 Memory 模块来分析这些 dump 文件。首先打开 Chrome 的开发者工具,切换到 Memory,并依次加载 dump 文件。
加载完 dump 文件后,图中框起来的部分会有 4 个选项,我们会用到 Summary 和 Comparison 两个选项,所以只会解释着两个部分,如果您对其他两个选项有兴趣的话,可以参考:
https://developers.google.com/web/tools/chrome-devtools/memory-problems。
Comparison
首先看一下 Comparison 这个选项,从图中可以看到有 Constructor、New、Deleted、Delta、Alloc Size、Freed Size、Size Delta 共 7 列。Constructor 这列是用类名对变量进行分组;New 表示新创建的实例个数;Deleted 表示回收的实例个数;Delta 表示增长的实例个数;Alloc Size 表示分配的内存;Freed Size 表示释放的内存;Size Delta 表示增长的内存。
对比一下 heapdump-2 和 heapdump-1、heapdump-3 和 heapdump-2:
创建的变量实例不断增加,并且没有释放的内存,从这里我们有理由怀疑代码是存在内存泄漏的(确实存在,不然这篇文章就不用写了)。
Summary
接下来看一下 Summary 这个选项,从图中可以看出它有 Constructor、Distance、Shallow Size、Retained Size 共 4 项。Constructor 不再赘述;Distance 表示和根对象的距离,越小表示和根对象越近;Shallow Size 表示变量自身的大小,不包含它引用的变量的大小;Retained Size 不仅包含自身的大小,还包含了引用的变量的大小。
从图中可以看出,(string)那一行的 Shallow Size 和 Retained Size 都占据了 100%的内存,所以有必要从这里看起,点击 Distance 最大的那一行(205),可以看到如下信息:
从图中可以看出该 string 类型的变量是 leakStr,这个变量被 originLeakObject 引用了,originLeakObject 又被 leakMethod 引用了,如此反复。看 Distance 为 202 行的那行,也会得出类似的结果。再对应代码,可以看出代码确实是存在内存泄漏的。
原因分析
通过上面的分析,发现代码确实是存在内存泄漏的,为什么会有内存泄漏呢,让我们仔细分析一下这段代码。
unused 在 testMemoryLeak 中创建,形成一个闭包,该闭包引用了 originLeakObject。leakMethod 也是在 testMemoryLeak 中创建的,也形成了闭包,这个闭包和 unused 共享,所以也引用了 originLeakObject。leakMethod 被赋给了 leakObject,leakObject 是一个全局变量,所以 testMemoryLeak 函数执行完毕之后,leakObject 的内存不会被回收,那么 originLeakObject 的内存也不会被回收,而 originLeakObject 本质上是上一个 leakObject,从而导致 leakStr 的内存不能被回收。由于 setInterval 不断调用 testMemoryLeak,导致大量 leakStr 生成,从而造成了内存泄漏。这个分析的结果,其实在开发者工具中也可以对应起来。