首页 黑客接单正文

Node.js 中内存泄漏分析

内存泄漏(Memory Leak)指程序因疏忽或错误而未能释放不再使用的内存的情况。如果内存泄漏的位置更关键,那么随着处理可能持有越来越多的无用内存,这些无用的内存会导致服务器响应速度减慢,严重的内存达到一定的极限(可能是过程的上限,如 v8 上限;也可能是系统提供的内存上限)会使应用程序崩溃。

传统的 C/C 中有野生指针,对象使用后未释放。在使用虚拟机执行的语言中,如 Java、JavaScript 使用 GC (Garbage Collection,垃圾回收)机制自动释放内存,极大地解放了程序员的精力,不再像传统语言那样害怕内存的释放。

但即使有 GC 机制可以自动释放,但这并不意味着内存泄漏的问题不存在。内存泄漏仍然是开发者无法避免的问题。今天,让我们学习如何分析 Node.js 内存泄漏。

GC in Node.js

Node.js 使用 V8 作为 JavaScript 执行引擎,所以讨论 Node.js 的 GC 的情况等于讨论 V8 的 GC。在 V8 中一个对象的内存是否被释放,是看程序中是否还有地方持有改对象的引用。

在 V8 中,每次 GC ,是基于 root 对象 (在浏览器环境中 window,Node.js 环境中的 global ) 如果能依次梳理对象的引用,可以从 root 引用链到达访问,V8 将其标记为可到达对象,而不是不可到达对象。被标记为不可到达对象(即无引用对象)后, V8 回收。详见 alinode 解读 V8 GC。

了解以上点后,你就会知道 Node.js 内存泄漏的原因是应该被清除的对象在被到达对象引用后没有被正确清除。

内存泄漏的几种情况

一、全局变量

  • a=10;
  • ///未声明对象。
  • global.b=11;
  • //引用全局变量
  • 这个相对简单的原因直接挂在 root 物体不会被清除。

    二、闭包

  • functionout(){
  • constbigData=newBuffer(100);
  • inner=function(){
  • voidbigData;
  • }
  • }
  • 闭包会引用父级函数中的变量,如果闭包不释放,就会导致内存泄漏。上面的例子是 inner 直接挂在 上root 上,那么每次执行 out 函数产生的 bigData 不会释放,导致内存泄漏。

    需要注意的是,这里举的例子只是简单的把引用挂在全局对象上,实际的业务情况可能挂在某个可以从 挂起root 追溯对象。

    三、事件监听

    Node.js 事件监控也可能发生内存泄漏。例如,重复监控同一事件,忘记删除它(removeListener),会导致内存泄漏。这种情况在重用对象上添加事件时很容易发生,因此可能会收到以下警告:

  • (node:2752)Warning:PossibleEventEmittermemoryleakdetected。11hahalistenersadded。Useemitter。setMaxListeners()toincreaselimit
  • 例如,Node.js 中 Agent 的 keepAlive 为 true 可能导致内存泄漏。Agent keepAlive 为 true 时,将重用以前使用过的 socket,如果在 socket 添加事件监控,忘记清除,因为 socket 的再利用会导致重复监控事件,导致内存泄漏。

    原则上和前一个添加事件监控时忘记清理是一样的。Node.js 的 http 模块不通过 keepAlive 重复使用没有问题。重复使用后可能会发生内存泄漏。因此,您需要了解添加事件监控对象的生命周期,并注意自行删除。

    这个问题的例子可以看 Github 上的 issues(node Agent keepAlive 内存泄漏)

    四、其他原因

    还有其他情况可能导致内存泄漏,如缓存。使用缓存时,必须知道缓存对象的数量。如果缓存对象很多,必须限制***缓存数量处理。还有就是非常占用 CPU 代码也会导致内存泄漏。如果服务器运行时有高 CPU 同步代码,因为Node.js 是单线程,因此处理请求无法处理累导致内存占用过高。

    定位内存泄漏

    一、重现内存泄漏情况

    通常有两种情况可以定位内存泄漏:

                   
  • 对于只要正常使用就可以重现的内存泄漏,这是一个非常简单的情况,只要测试环境模拟可以调查。
  •                
  • 对于意外的内存泄漏,它通常与特殊的输入有关。稳定重现这种输入是一个耗时的过程。如果您不能通过代码日志定位此特殊输入,建议在生产环境中打印内存快照。需要注意的是,打印内存快照非常耗时 CPU 操作可能会影响在线业务。
  • 建议使用快照工具 heapdump 用于保存内存快照devtool 查看内存快照。heapdump 保存内存快照时,只会有 Node.js 如果使用 node-inspector 快照中会有前端变量干扰)。

    PS:安装 heapdump 在某些 Node.js 版本可能出错,建议使用 npm install heapdump -target=Node.js 安装版本。

    二、打印内存快照

    将 heapdump 引入代码,使用 heapdump.writeSnapshot 可以打印内存快照。为了减少正常变量的干扰,在打印内存快照之前,可以调用主动释放内存的 gc() 函数(启动时添加 –expose-gc 参数可以打开)。

  • constheapdump=require('heapdump');
  • constsave=function(){
  • gc();
  • heapdump.writeSnapshot('./'    Date.now() '.heapsnapshot');
  • }
  • 在打印在线代码时,建议根据内存增长打印快照。heapdump 可使用 kill向程序发送打印内存快照的信号*nix 在系统上提供)。

  • kill-USR2
  • 建议打印 3 内存快照,一个是内存泄漏前的内存快照,一个是少量测试后的内存快照,另一个是多次测试后的内存快照。

    ***比较一下内存快照,看看测试后有哪些对象生长。在内存泄漏不明显的情况下,可以比较大量测试后的内存快照,更容易定位。

    三、比较内存快照找出泄漏位置

    通过内存快照找到不断增加的对象,谁引用增加的对象,找到问题代码,纠正,具体问题具体分析,这里通过我们在工作中遇到的情况来解释。

  • const{EventEmitter}=require('events');
  • constheapdump=require('heapdump');
  • global.test=newEventEmitter();
  • heapdump.writeSnapshot('./'    Date.now() '.heapsnapshot');
  • functionrun3(){
  • constinnerData=newBuffer(100);
  • constoutClosure3=function(){
  • voidinnerData;
  • };
  • test.on('error',()=>{
  • console.log('error');
  • });
  • outClosure3();
  • }
  • for(leti=0;i<10;i ){
  • run3();
  • }
  • gc();
  • heapdump.writeSnapshot('./'    Date.now() '.heapsnapshot');
  • 这是对错误代码的最小重现代码。

    首先使用 node –expose-gc index.js 运行代码将获得两张内存快照,然后打开 devtool,点击 profile,记录快照。打开对比,Delta 如果物体 Delta 一直在增长,很可能是内存泄漏。

    可以看到三个对象明显增长的地方,封包、上下文、 Buffer 对象增长。点击查看对象的引用:

    事实上,这三个对象的增长都是由问题引起的。test 物体中的 error 在监听事件中,闭包引用 innerData 对象,导致 buffer 未清除,导致内存泄漏。

    其实这里的 error 在监听事件中没有引用 innerData 为什么闭包引用 innerData 对象,这个问题很疑惑,后来发现是 V8 的优化问题将在文章的最后进行额外的解释。比较快照来发现问题取决于你对代码的熟悉程度和视力。

    如何避免内存泄漏?

    文章中的例子基本上可以清楚地看到内存泄漏,但在工作中,代码混合业务后可能无法清楚地看到内存泄漏,或者必须依靠工具来定位内存泄漏。以下是一些避免内存泄漏的 *** 。

    ESLint 检测代码检查非预期的全局变量。

    使用闭包时,要知道什么对象是闭包的,什么时候引用闭包的对象是闭包的。***如果没有打印内存快照,就很难看到复杂的封闭包,因为复杂的封闭包造成的内存泄漏。

    绑定事件时,必须在适当的时候清除事件。编写类别时,建议使用 init 绑定函数对类事件监控和资源申请,然后 destroy 函数对事件和占用资源进行释放。

    额外说明

    经过多次测试,得到以下关于闭包的总结。

  • classTest{};
  • global.test=newTest()
  • functionrun5(bigData){
  • constinnerData=newBuffer(100);
  • ///被封包引用,创建一个context:context1。
  • //context1引用bigData,innerData。
  • //closure为functionrun5()
  • //run5函数没有context,所以context1没有previous。
  • //在run5绑定中新建函数context1。
  • test.outClosure5=function(){
  • ///此函数闭包context指向context1。
  • voidbigData;
  • constclosureData=newBuffer(100);
  • ///被封闭使用,创建context:context2。
  • //outClosure5函数有context1,previous指向context1。
  • //在outClosure5绑定中新建函数context2。
  • test.innerClosure5=function(){
  • ///此函数闭包context指向context2。
  • voidinnerData;}
  • test.innerClosure5_1=function(){
  • ///此函数闭包context指向context2。
  • voidclosureData;
  • }
  • };
  • test.outClosure5_1=function(){
  • }
  • test.outClosure5();
  • }
  • run5(newBuffer(1000));
  • V8 会产生一个 context 内部内部对象。以下是 V8 生成 context 的规则。

                     
    • V8 将在变量声明中创建 context2,如果封闭的变量所在函数具有 context1 ,创建的 contex
    •                
    • t2 的 previous指向函数 context1。新建函数在被闭包引用的变量函数中绑定 context2。

    因此和 V8版本相关,这里只测试 v6.2.2 和 v6.10.1 还有 v7.7.1,都是一样的情况。如果你想进行实践测试,你可以在这个 repo了解更多。

       
    版权声明

    本文仅代表作者观点,不代表本站立场。
    本文系作者授权发表,未经许可,不得转载。