内存泄漏(Memory Leak)指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况
Node.js 使用 V8 作为 JavaScript 的执行引擎,所以讨论 Node.js 的 GC 情况就等于在讨论 V8 的 GC。在 V8 中一个对象的内存是否被释放,是看程序中是否还有地方持有改对象的引用。
在 V8 中,每次 GC 时,是根据 root 对象 (浏览器环境下的 window,Node.js 环境下的 global ) 依次梳理对象的引用,如果能从root的引用链到达访问,V8就会将其标记为可到达对象,反之为不可到达对象。被标记为不可到达对象(即无引用的对象)后就会被 V8 回收。(http://alinode.aliyun.com/blog/37)
# V8内存限制
node基于V8构建,通过V8的方式进行分配跟管理js对象。V8对内存的使用有限制(老生代内存64位系统下约为1.4G,32位系统下约为0.7G,新生代内存64位系统下约为32MB,32系统下约为16MB)。在这样的限制下,将导致无法操作大内存对象。如果不小心触碰这个界限,就会造成进程退出。
原因: V8在执行垃圾回收时会阻塞JavaScript应用逻辑,直到垃圾回收结束再重新执行JavaScript应用逻辑,这种行为被称为“全停顿”(stop-the-world)。若V8的堆内存为1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。
通过
|
|
设置新生代内存以及老生代内存来破解默认的内存限制。
# V8的堆构成
V8的堆其实并不只是由老生代和新生代两部分构成,可以将堆分为几个不同的区域:
- 新生代内存区:大多数的对象被分配在这里,这个区域很小但是垃圾回收特别频繁
- 老生代指针区:属于老生代,这里包含了大多数可能存在指向其他对象的指针的对象,大多数从新生代晋升的对象会被移动到这里
- 老生代数据区:属于老生代,这里只保存原始数据对象,这些对象没有指向其他对象的指针
- 大对象区:这里存放体积超越其他区大小的对象,每个对象有自己的内存,垃圾回收其不会移动大对象
- 代码区:代码对象,也就是包含JIT之后指令的对象,会被分配在这里。唯一拥有执行权限的内存区
- Cell区、属性Cell区、Map区:存放Cell、属性Cell和Map,每个区域都是存放相同大小的元素,结构简单
# GC回收类型
-
增量式GC 表示垃圾回收器在扫描内存空间时是否收集(增加)垃圾并在扫描周期结束时清空垃圾。
-
非增量式GC 使用非增量式垃圾收集器时,一收集到垃圾即将其清空。
垃圾回收器只会针对新生代内存区、老生代指针区以及老生代数据区进行垃圾回收。对象首先进入占用空间较少的新生代内存。大部分对象会很快失效,非增量GC直接回收这些少量内存。假如有些对象一段时间内不能被回收,则进去老生代内存区。这个区域则执行不频繁的增量GC,且耗时较长。
# 内存泄漏的几种情况
# 一、全局变量
|
|
这种比较简单的原因,全局变量直接挂在 root 对象上,不会被清除掉。
# 二、闭包
|
|
闭包会引用到父级函数中的变量,如果闭包未释放,就会导致内存泄漏。上面例子是 inner 直接挂在了 root 上,从而导致内存泄漏(bigData 不会释放)。
需要注意的是,这里举得例子只是简单的将引用挂在全局对象上,实际的业务情况可能是挂在某个可以从 root 追溯到的对象上导致的。
# 三、事件监听
Node.js 的事件监听也可能出现的内存泄漏。例如对同一个事件重复监听,忘记移除(removeListener),将造成内存泄漏。这种情况很容易在复用对象上添加事件时出现,所以事件重复监听可能收到如下警告:
|
|
例如,Node.js 中 Agent 的 keepAlive 为 true 时,可能造成的内存泄漏。当 Agent keepAlive 为 true 的时候,将会复用之前使用过的 socket,如果在 socket 上添加事件监听,忘记清除的话,因为 socket 的复用,将导致事件重复监听从而产生内存泄漏。
原理上与前一个添加事件监听的时候忘了清除是一样的。在使用 Node.js 的 http 模块时,不通过 keepAlive 复用是没有问题的,复用了以后就会可能产生内存泄漏。所以,你需要了解添加事件监听的对象的生命周期,并注意自行移除。
关于这个问题的实例,可以看 Github 上的 issues(node Agent keepAlive 内存泄漏)(https://github.com/nodejs/node/issues/9268)
# 四、其他原因
还有一些其他的情况可能会导致内存泄漏,比如缓存,队列消费不及时等。在使用缓存的时候,得清楚缓存的对象的多少,如果缓存对象非常多,得做限制最大缓存数量处理。还有就是非常占用CPU的代码也会导致内存泄漏,服务器在运行的时候,如果有高 CPU 的同步代码,因为Node.js 是单线程的,所以不能处理处理请求,请求堆积导致内存占用过高。
# 如何避免内存泄漏
文中的例子基本都可以很清楚的看出内存泄漏,但是在工作中,代码混合上业务以后就不一定能很清楚的看出内存泄漏了,还是得依靠工具来定位内存泄漏。另外下面是一些避免内存泄漏的方法。
-
ESLint 检测代码检查非期望的全局变量。
-
使用闭包的时候,得知道闭包了什么对象,还有引用闭包的对象何时清除闭包。最好可以避免写出复杂的闭包,因为复杂的闭包引起的内存泄漏,如果没有打印内存快照的话,是很难看出来的。
-
绑定事件的时候,一定得在恰当的时候清除事件。在编写一个类的时候,推荐使用 init 函数对类的事件监听进行绑定和资源申请,然后 destroy 函数对事件和占用资源进行释放。
# 内存泄漏分析
# 查看V8内存使用情况(单位byte)
|
|
# 查看系统内存使用情况(单位byte)
|
|
# 查看垃圾回收日志
|
|
# 分析监控工具
- v8-profiler 对v8堆内存抓取快照和对cpu进行分析
- node-heapdump 对v8堆内存抓取快照
- node-mtrace 分析堆栈使用
- node-memwatch 监听垃圾回收情况
# node-memwatch
|
|
stats事件:每次进行全堆垃圾回收时,将触发一次stats事件。这个事件将会传递内存统计信息。
|
|
leak事件:如果经过连续5次垃圾回收后,内存仍然没有被释放,意味着内存泄漏的发生。这个时候会触发一个leak事件。
|
|
Heap Diffing 堆内存比较 排查内存溢出代码。
# 实例演示
下面,我们通过一个例子来演示如何排查定位内存泄漏: 首先我们创建一个导致内存泄漏的例子:
|
|
这里我们通过设置一个不断增加且不回被回收的数组,来模拟内存泄漏。
通过使用heap-dump模块来定时纪录内存快照,并通过chrome开发者工具profiles来导入快照,对比分析。
我们可以看到,在浏览器访问 localhost:3000, 并多次刷新后,快照的大小一直在增长,且即使不请求,也没有减小,说明已经发生了泄漏。
接着我们通过过 chrome 开发者工具 profiles, 导入快照。通过设置 comparison,对比初始快照,发送请求,平稳,再发送请求这3个阶段的内存快照。可以发现右侧new中 LeakClass 一直增加。在delta中始终为正数,说明并没有被回收。
小结
针对内存泄漏可以采用植入 memwatch,或者定时上报 process.memoryUsage 内存使用率到 monitor,并设置告警阀值进行监控。 当发现内存泄漏问题时,若允许情况下,可以在本地运行 node-heapdump,使用定时生成内存快照。并把快照通过 chrome Profiles 分析泄漏原因。若无法本地调试,在测试服务器上使用 v8-profiler 输出内存快照比较分析json(需要代码侵入)。 需要考虑在什么情况下开启 memwatch/heapdump。考虑 heapdump 的频度以免耗尽了 CPU。 也可以考虑其他的方式来检测内存的增长,比如直接监控 process.memoryUsage()。 当心误判,短暂的内存使用峰值表现得很像是内存泄漏。如果你的app突然要占用大量的CPU和内存,处理时间可能会跨越数个垃圾回收周期,那样的话 memwatch 很有可能将之误判为内存泄漏。但是,这种情况下,一旦你的app使用完这些资源,内存消耗就会降回正常的水平。所以需要注意的是持续报告的内存泄漏,而可以忽略一两次突发的警报。