如何定位 Node.js 的内存泄漏
这里简单整理一个流程,欢迎一起探讨,补充。
基础知识
Node.js 进程的内存管理,都是有 V8 自动处理的,包括内存分配和释放。那么 V8 什么时候会将内存释放呢?
在 V8 内部,会为程序中的所有变量构建一个图,来表示变量间的关联关系,当变量从根节点无法触达时,就意味着这个变量不会再被使用了,就是可以回收的了。
而这个回收是一个过程性的,从快速 GC 到 最后的 Full GC,是需要一段时间的。
另外,Full GC 是有触发阈值的,所以可能会出现内存长期占用在一个高值,也可以算是一种内存泄漏,可以从《一次 Node.js 应用内存暴涨分析》中找到例子。还有一种就是引用不释放,导致无法进入 GC 环节,并且一直产生新的占用,这一般会发生在 Javascript 层面。
所以,定位内存泄漏问题,一般方案就是找那些不被使用又不会被释放的变量,处理了这些变量,问题一般就可以解决了。如果是 Node.js 底层变量不释放,除了提交 issue 等待解决外,只能通过优化启动参数来解决。
如何找出并解决问题
工具
工欲善其事必先利其器,在排查时,我们还是需要一些工具来帮忙的。
devTool
这个是今年初出的 Node.js 调试工具,基于 Electron 将 Node.js 和 Chromium 的功能融合在了一起。操作起来比 node-inspector 方便,开放的 Timeline 功能还是比较实用的,虽然不是实时显示。
仅需要 devtool xxx.js
,还可以通过 .devtoolrc 来进行参数定制,具体见 GitHub
heapdump + chrome devTool
这个是比较传统的定位内存泄漏的组合。heapdump 可以直接在代码中调用生成内存快照,然后将快照文件导入到 chrome devTool 进行分析,之后操作其实和前者就差不多了。不过,这个方案和前者有一点区别就是,前者实际还是在浏览器环境中,所以生成的内存快照会有一些 DOM 对象的存在,会有一定的干扰。而这个方案,是直接调用底层 V8 的方法,生成的快照只有 Node.js 环境中的对象。
memwatch
这个可以在代码里直接使用,实时检测内存动态,当发生内存泄漏的时候,会触发 ‘leak’ 事件,会传递当前的堆状态,配合 heapdump 有奇效。详见 memwatch。
流程
一、重现问题
对于垃圾回收,V8 引擎有很复杂的逻辑来决定什么时候进行回收。很多时候,当我们发现 Node.js 进程所使用的内存快速增长的时候,并不能确定是否是内存泄漏导致的,很有可能是程序设计问题,导致内存的不合理利用。只有当垃圾回收触发,未使用内存被释放后,内存增长还在持续,我们才能确定是发生了内存泄漏。
隐藏的内存泄漏问题,大多是有触发条件的,重现问题是需要这些条件的,所以我们在平时写代码的时候,可以将一些重要环节的参数细节打印在 log 中,这样我们在重现问题是就不会摸不着头脑,乱试一气。
有了参数可以用来重现问题,接下来要确定问题。我们要确定,这部分内存是否没有被 GC 正确释放。那么问题来了,我们如何知道程序进行了垃圾回收呢?很显然,等待并不是办法,我们要主动。
在 Node.js 的启动参数中,提供了暴露手动调用 GC 方法的参数,即 --expose-gc
。我们用这个参数来启动应用后,就可以在代码中调用 global.gc()
手动触发垃圾回收操作。同时,使用 process.memoryUsage().heapUsed
获取进程运行时所占用的内存。如果 GC 之后,内存依然没有下降,就可以确定是内存泄露了。
二、生成内存快照
既然内存是问题,我们就需要获取程序运行的内存快照来帮助定位问题。但内存快照并不是随便打得,是有一定技巧的。
我们至少要生成三次内存快照,才能更好的定位问题。这三次中又一次要在问题出现前生成,之后可以在问题持续的过程中生成两次或更多。
为什么要这样做呢?理解起来很简单。第一次是为了获取正常情况下的堆栈信息,而在问题出现后,堆栈信息一定会发生变化,有了第一次的信息,我们才好进行后面的比对,过滤一些无用的信息。而后两次的快照,用来比对某一对象的堆栈变化,来确定是否是有问题的对象。下面会详细应用到。
三、定位问题
用 devTool 的可以忽略下面的过程:
打开 Chrome Devtools ,进入到 Profiles 选项卡,点 Load 按钮,加载之前生成的快照。
对于内存快照,有四个视图,Summary,Comparison,Containment,Statistics,这里面常用的是前三个。
在 Summary 视图中,我们可以看到当前快照的全部信息,以及多个快照之间的信息。在列表里显示的都是对象的构造函数名字,可以先忽略被括号包裹的对象,优先观察其他的对象,最后再来看他们。后面的 shallow size
表示的是对象自身的大小,retained size
表示的是对象和它依赖对象的大小,一般是 GC 不可达的。
在 Comparison 视图中,我们可以进行多个快照之间的对比,这个用处比较大,如果我们将前两次快照进行对比,可能比较快速的定位出问题的对象。注意观察 New、Deleted、Delta,如果是内存泄漏的对象,可能是一直在 New,而没有 Deleted。
在 Containment 视图中,我们可以查看整个 GC 路径,当然一般不会用到。因为展开在 Summary 和 Comparison 列举的每一项,都可以看到从 GC roots 到这个对象的路径。通过这些路径,你可以看到这个对象的句柄被什么持有,从而定位问题产生的原因。值的注意的是,其中背景色黄色的,表示这个对象在 Javascript 中还存在引用,所以可能没有被清除。如果是红色的,表示的是这个对象在 Javascript 中不存在引用,但是依然存活在内存中,一般常见于 DOM 对象,它们存放的位置和 Javascript 中对象还是有不同的,在 Node.js 中很少遇见。
更多的操作方法,可以看这个视频 Memory Profiling with Chrome DevTools 和 Memory Management Masterclass。还有 Chrome 的文档 Memory Profiling(旧) 和 Memory Diagnosis(新)。讲的还是很详细的。(请自备梯子)
四、解决问题
一般在 Javascript 中存在引用而导致内存泄漏的情况,是比较好处理的,只需要在使用后及时的将引用释放掉即可。
但像 《一次 Node.js 应用内存暴涨分析》 所存在的那种内存问题,是属于底层机制的问题,如果等不了 bugfix,就只能先通过一些启动参数来优化内存管理。常用的参数:
--max-old-space-size
限制老生区大小,可以控制内存占用的最大值,即使发生泄漏,也不会让内存占用保持很高。可以根据开启进程数以及是否同机部署来优化。--gc_global
这其实是个 V8 的 debug flag,让 GC 永远都是 Full GC,使用上会有一定的性能损耗,根据应用复杂度不同,损耗不同。
当我们找到问题,进行修复后,重复上面的步骤,确认问题已经被解决。有时可能一次并不能解决问题,所以耐心还是很重要的。
实战
可以在这里下载使用到的代码, GitHub,进入 memory-leak 文件夹。
我们来举个例子,应用上面的步骤排查问题,使用 leak-memory 的例子,代码还有另外一个例子,可以自己实践。
这里我们为了方便,我们使用了 devTool。
devTool leak-memory.js
然后在打开的界面中进入内存快照界面,生成第一次快照。当控制台有输出后,间隔的生成两次快照,结果如下。
我们切换视图,对比下三次快照间的区别,可以看到 Foo
这个对象一直在创建而没有被删除。
我们展开 Foo
,选择下面的一个实例,查看它的 GC path,可以看到它一直被 neverRelease 持有引用(黄色),所以没有被释放,之后就可以进行问题的处理了。
去掉 // neverRelease.splice(index, 1);
前的注释,然后在重复上面的步骤,你会发现内存的变化已经正常了。
在使用 devTool 时,可以查看运行时的 memory timeline,如果图像呈现阶梯式增长,一般就是存在内存泄漏问题了。正常的应用曲线会类似于锯齿,如图:
总结
内存泄漏问题的定位,经验很重要,但有了良好工具的辅助,可以节省很多时间。如果懒得自己一步步的操作,可以接入 alinode,这个可以帮助你很方便的生成快照等运行时数据,并有一定的分析辅助,还是方便的。
你可能看到很多内存分析的文章会有一些图来表示内存的增长,可以使用 python 来快速生成相关的图片,使用
matplotlib.pyplot
这个包。