当前位置: 首页 > 科技观察

Node.js内存泄漏分析

时间:2023-03-12 03:30:43 科技观察

内存泄漏(MemoryLeak)是指程序由于疏忽或错误而未能释放不再使用的内存的情况。如果内存泄漏的位置很关键,那么随着处理的进行,可能会持有越来越多的无用内存,这会导致服务器响应变慢,严重时内存会达到一定的极限(可能是进程的上限,如v8的上限;也可能是系统可用内存的上限),会使应用程序崩溃。传统C/C++存在野指针,对象用完不释放导致的内存泄漏。在Java、JavaScript等使用虚拟机执行的语言中,由于使用了GC(GarbageCollection,垃圾回收)机制来自动释放内存,极大地解放了程序员的精力,无需再像传统语言一样不断地处理记忆。被他的释放吓坏了。但是,即使有可以自动释放的GC机制,也不代表不存在内存泄露的问题。内存泄漏仍然是开发者无法绕过的问题。今天让我们学习如何分析Node.js中的内存泄漏。Node.js中的GCNode.js使用V8作为JavaScript执行引擎,所以讨论Node.js的GC就等同于讨论V8的GC。V8中一个对象的内存是否释放取决于程序中是否还有对该对象的引用。在V8中,每次GC时,对象的引用会按照根对象(浏览器环境下是window,Node.js环境下是global)依次排序。如果可以从根的引用链访问,V8会标记为可达对象,反之则标记为不可达对象。被标记为不可达对象(即没有引用的对象)后,会被V8回收。更详细的可以看alinode对V8GC的解读。了解了以上几点,你就会知道Node.js内存泄漏的原因是本该清除的对象被可达对象引用了,却没有正确清除,留在内存中。内存泄漏的几种情况1.全局变量a=10;//Undeclaredobject。global.b=11;//全局变量指的这个比较简单的道理,全局变量直接挂在根对象上,不会被清除。2.闭包functionout(){constbigData=newBuffer(100);inner=function(){voidbigData;}}闭包会引用父函数中的变量。如果不释放闭包,就会造成内存泄漏。上面的例子中,inner直接挂在了root上,所以out函数每次执行产生的bigData不会被释放,导致内存泄漏。需要注意的是,这里给出的例子只是将引用挂在了全局对象上。实际业务情况可能是挂在一个可以追根溯源的对象上造成的。3、事件监听Node.js事件监听也可能会导致内存泄露。比如重复监听同一个事件而忘记移除(removeListener),就会造成内存泄漏。这种情况在给重用的对象添加事件时很容易出现,所以重复事件监听可能会收到如下警告:(node:2752)Warning:PossibleEventEmittermemoryleakdetected。11哈哈听众补充。使用斜指。setMaxListeners()toincreaselimit比如Node.js中Agent的keepAlive为true时,可能会造成内存泄漏。当AgentkeepAlive为true时,之前使用过的socket会被重新使用。如果给socket添加了事件监听而忘记清除,socket的多路复用会导致重复事件监听和内存泄漏。原理上和添加事件监听时忘记清除是一样的。在使用Node.js的http模块时,不用keepAlive复用是没有问题的。重用后,可能会发生内存泄漏。因此,需要了解被添加事件监听器的对象的生命周期,自行移除时要小心。这个问题的例子可以看Github上的issues(nodeAgentkeepAlivememoryleak)4.其他原因还有其他一些情况可能会导致内存泄漏,比如cache。使用缓存时,必须知道缓存了多少对象。如果缓存对象过多,则必须限制缓存对象的数量。另外,大量消耗CPU的代码也会造成内存泄漏。服务器运行时,如果有高CPU同步代码,由于Node.js是单线程,无法处理请求,请求堆积导致内存占用过高。.定位内存泄漏1.重现内存泄漏如果要定位内存泄漏,通常有两种情况:对于只要正常使用就可以重现的内存泄漏,这是一种很简单的情况,只要模拟一下就可以了在测试环境中,可以检查它们。.对于偶尔的内存泄漏,通常与特殊输入有关。这种输入的稳定再现是一个耗时的过程。如果无法通过代码日志定位到这个特殊输入,建议在生产环境打印内存快照。需要注意的是,打印内存快照属于CPU密集型操作,可能会影响线上业务。快照工具推荐使用heapdump保存内存快照,使用devtool查看内存快照。使用heapdump保存内存快照时,只会有Node.js环境中的对象没有干扰(如果使用node-inspector,快照中会有前端变量干扰)。PS:在某些Node.js版本上安装heapdump可能会出错,建议使用npminstallheapdump-target=Node.js版本安装。2.打印内存快照在代码中引入heapdump,使用heapdump.writeSnapshot打印内存快照。为了减少普通变量的干扰,可以在打印内存快照前调用主动释放内存的gc()函数(启动时添加–expose-gc参数启用)。constheapdump=require('heapdump');constsave=function(){gc();heapdump.writeSnapshot('./'+Date.now()+'.heapsnapshot');}打印代码的时候就行了,它建议根据内存增长打印快照。heapdump可以使用kill向程序发送信号以打印内存快照(仅在*nix系统上可用)。kill-USR2推荐打印3张内存快照,一张是内存泄漏前的内存快照,一张是少量测试后的内存快照,一张是多次测试后的内存快照。第一个内存快照用作比较,以查看测试后哪些对象增长了。在内存泄漏不明显的情况下,可以通过大量测试后的内存快照进行对比,更容易定位。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-gcindex.js运行代码,会得到两个内存快照,然后打开devtool,点击profile,加载内存快照。打开比较,Delta会显示对象的变化,如果对象Delta一直在增长,很可能是内存泄漏。可以看到对象有明显增长的三个地方,闭包、上下文和Buffer对象增长。点击查看对象引用:其实三个对象增长都是一个问题导致的。测试对象中错误监听事件的关闭引用了innerData对象,导致缓冲区没有被清除,从而导致内存泄漏。其实这里的错误监控事件中并没有引用innerData。为什么闭包引用innerData对象?这个问题很迷惑。后来发现是V8优化问题。对比快照发现问题,就看你对代码的熟悉程度和眼力了。如何避免内存泄漏本文中的例子基本上可以把内存泄漏表现的很清楚,但是在工作中,代码和业务混在一起后,内存泄漏可能就看不出来了,还是得靠工具来解决定位内存泄漏。下面还有一些避免内存泄漏的方法。ESLint检测代码检查意外的全局变量。使用闭包时,需要知道封闭了哪些对象,以及引用闭包的对象何时清除闭包。***你可以避免编写复杂的闭包,因为如果不打印内存快照,复杂闭包导致的内存泄漏很难被发现。绑定事件时,必须在适当的时候清除事件。在编写类时,建议使用init函数绑定类的事件监听器并申请资源,然后使用destroy函数释放事件和占用的资源。附加说明在做了很多测试之后,我得到了以下关于闭包的总结。classTest{};global.test=newTest()functionrun5(bigData){constinnerData=newBuffer(100);//被闭包引用,创建context:context1。//context1指的是bigData,innerData。//closureisfunctionrun5()//run5函数没有context,所以context1没有previous。//run5中新建的函数会绑定到context1。test.outClosure5=function(){//这个函数的闭包上下文指向context1。voidbigData;constclosureData=newBuffer(100);//闭包使用,createcontext:context2。//outClosure5函数有context1,previous指向context1。//outClosure5中新创建的函数将绑定到context2。test.innerClosure5=function(){//这个函数的闭包上下文指向context2。voidinnerData;}test.innerClosure5_1=function(){//这个函数闭包上下文指向context2。voidclosureData;}};test.outClosure5_1=function(){}test.outClosure5();}run5(newBuffer(1000));V8会生成一个上下文内部对象来实现闭包。以下是V8生成上下文的规则。V8将在闭包引用的变量声明处创建一个context2。如果关闭变量的函数有context1,则创建的上下文t2的前一个指向函数context1。闭包引用的函数内部新创建的函数将绑定到context2。由于与V8版本有关,这里只测试了v6.2.2、v6.10.1和v7.7.1,都是一样的。如果你想练习测试,你可以在这个repo上了解更多。