当前位置: 首页 > 后端技术 > Node.js

Node.js内存管理与V8垃圾回收机制

时间:2023-04-03 23:34:51 Node.js

作者|吴跃军Node.js技术栈|https://www.nodejs.red慕课认证作者|https://imooc.com/u/2667395对于开发Node.js服务端的同学,关于垃圾回收和内存释放,不需要手动创建delete/free等操作来进行GC(垃圾回收)像C/C++同学创建对象后。),Node.js和Java一样,由虚拟机自动管理内存。但这并不意味着您可以高枕无忧。在开发过程中由于疏忽或程序错误可能导致的内存泄漏也是一个非常严重的问题。因此,作为一名合格的服务器端研发工程师,必须了解虚拟机是如何使用内存的,才能在遇到问题时冷静处理。快速导航GCNodjsNodejs中的垃圾收集内存管理实践内存泄漏识别内存泄漏示例手动执行垃圾收集内存释放V8垃圾收集机制V8堆内存限制新生代和老年代新生代空间&清除算法老年代空间&标记-清除标记-紧凑算法V8垃圾收集总结内存泄漏全局变量闭包谨慎使用内存作为缓存模块私有变量内存持久化事件重复监控其他注意事项内存检测工具Nodejs中的GCNode.js是一个基于ChromeV8引擎的JavaScript运行环境,这是Node.js官网的一段话,所以V8是Node.js中使用的虚拟机,后面讲解的Node.js中的GC其实就是V8的GC。Node.js和V8的关系就像Java和JVM的关系。另外,当Node.js之父RyanDahl选择V??8作为Node.js的虚拟机时,当时V8的性能已经领先于所有其他JavaScript虚拟机,仍然是目前最好的性能,所以我们在优化Node.js的时候,只要升级版本,性能也会有所提升。Nodejs垃圾回收内存管理实践我们先用一个demo看看Node.js中垃圾回收的过程是怎样的?内存泄漏识别在Node.js环境下提供process.memoryUsage方法查看当前进程的内存使用情况。单位为byterss(residentsetsize):RAM中保存的进程占用的内存,包括代码本身,栈,堆。heapTotal:堆中请求的内存总量。heapUsed:堆中当前使用的内存量。我们主要用这个字段来判断内存泄漏。external:V8引擎内部C++对象占用的内存。/***单位为bytes,格式为MBoutput*/constformat=function(bytes){return(bytes/1024/1024).toFixed(2)+'MB';};/***封装print方法的输出内存使用信息*/constprint=function(){constmemoryUsage=process.memoryUsage();console.log(JSON.stringify({rss:格式(memoryUsage.rss),heapTotal:格式(memoryUsage.heapTotal),heapUsed:格式(memoryUsage.heapUsed),外部:格式(memoryUsage.external),}));}内存泄漏示例堆用于存储对象引用类型,例如字符串和对象。在下面的代码中创建一个Fruit并将其存储在堆中。//example.jsfunctionQuantity(num){if(num){returnnewArray(num*1024*1024);}returnnum;}functionFruit(name,quantity){this.name=namethis.quantity=newQuantity(quantity)}letapple=newFruit('apple');print();letbanana=newFruit('香蕉',20);打印();执行上面的代码,内存如下图,apple对象heapUsed对banana的使用只有4.21MB,而banana我们为其quantity属性创建了一个很大的数组空间,导致heapUsed飙升至164.24MB。$nodeexample.js{"rss":"19.94MB","heapTotal":"6.83MB","heapUsed":"4.21MB","external":"0.01MB"}{"rss":"180.04MB","heapTotal":"166.84MB","heapUsed":"164.24MB","external":"0.01MB"}我们来看看内存使用情况。根节点包含对每个对象的引用。然后什么都不能释放,GC也不能释放。如下图,手动垃圾回收内存释放假设我们不再使用banana对象,重新给它赋一些新的值,比如banana=null。让我们看看此刻发生了什么?结果如上图所示,Banana对象无法从根对象到达,那么Banana会在下一次垃圾收集器运行时被释放。让我们模拟一下垃圾回收,看看它在实践中是什么样子的?//example.jsletapple=newFruit('apple');print();letbanana=newFruit('banana',20);print();banana=null;global.gc();print();以下代码中,--expose-gc参数表示允许手动执行垃圾回收机制。将banana对象赋值为null后,进行GC。在第三次打印的结果中,可以看到heapUsed的使用从164.24MB下降到了3.97MB$node--expose-gcexample.js{"rss":"19.95MB","heapTotal":“6.83MB”,“heapUsed”:“4.21MB”,“external”:“0.01MB”}{“rss”:“180.05MB”,“heapTotal”:“166.84MB”,“heapUsed”:“164.24MB”"external":"0.01MB"}{"rss":"52.48MB","heapTotal":"9.33MB","heapUsed":"3.97MB","external":"0.01MB"}如图下图,右边的banana节点没有内容,GC占用的内存已经释放。V8垃圾回收机制垃圾回收是指对应用程序中不再引用的对象进行回收。当无法从根节点访问对象时,它将成为垃圾收集的候选者。这里的根对象可以是全局对象或局部变量,不能从根节点访问意味着它不会被任何其他活动对象引用。V8堆内存限制在服务器端是一个非常昂贵的事情。在V8中,64位机被限制为1.4GB左右,32位机为0.7GB左右。所以对于一些大内存的操作需要谨慎,否则超过V8内存限制,进程就会退出。超出边界限制的内存溢出示例//overflow.jsconstformat=function(bytes){return(bytes/1024/1024).toFixed(2)+'MB';};constprint=function(){constmemoryUsage=process.memoryUsage();console.log(`heapTotal:${format(memoryUsage.heapTotal)},heapUsed:${format(memoryUsage.heapUsed)}`);}consttotal=[];setInterval(function(){total.push(newArray)(20*1024*1024));//内存占用大print();},1000)上例中total是一个全局变量,每次增加160MB左右,不会被回收。无法在V8边界分配内存会导致进程内存溢出。$nodeoverflow.jsheapTotal:166.84MB,heapUsed:164.23MBheapTotal:326.85MB,heapUsed:324.26MBheapTotal:487.36MB,heapUsed:484.27MBheapTotal:649.38MB,heapUsed:643.98MBheapTotal:809.39MB,heapUsed:803.98MBheapTotal:969.40MB,heapUsed:963.98MBheapTotal:1129.41MB,heapUsed:1123.96MBheapTotal:1289.42MB,heapUsed:1283.96MB<---最后几次GC--->[87581:0x103800000]11257ms:Mark-sweep1283.9.->912.99(1292.93))MB,512.1/0.0ms旧空间中的分配失败GC请求[87581:0x103800000]11768ms:Mark-sweep1283.9(1290.9)->1283.9(1287.9)MB,510.7/0.0mslast5oldresort8GCin7:0x103800000]12263ms:Mark-sweep1283.9(1287.9)->1283.9(1287.9)MB,495.3/0.0mslastresortGCinoldspacerequested<---JSstacktrace--->在V8中也提供了两个参数只调整启动阶段的内存限制大小,分别调整老年代和新生代的空间。后面会介绍老一代和新一代。--max-old-space-size=2048--max-new-space-size=2048当然内存越大越好。一方面,服务器资源昂贵。另一方面,据说V8运行时有1.5GB的堆内存。一次小的垃圾回收大约需要50毫秒或更多,这会导致JavaScript线程暂停,这也是最重要的一个方面。年轻代和老年代绝对大部分的应用对象都会有很短的生命周期,而少数对象会有很长的生命周期。为了利用这种情况,V8将堆分为新生代和老年代两种。里面的对象很小,大概1-8MB,这里的垃圾回收也很快。在年轻空间中幸存下来的对象被提升到旧空间。由于新生代空间在新空间的垃圾回收非常频繁,其处理方式必须非常快,采用了Scavenge算法,该算法由C.J.Cheney在1970年的论文Anonrecursivelistcompactingalgorithm中提出。Scavenge是一个副本算法。新生代空间会被分成两个大小相等的from-space和to-space。它的工作原理是从from空间复制幸存的对象,然后将它们移动到to空间或被提升到老年代空间。在from空间中不存在的对象将被释放。在这些副本之后从空间到空间的交换。Scavenge算法速度非常快,适用于小内存的垃圾回收,但空间开销较大,新生代内存小的情况下可以接受。在老年代空间,当垃圾回收满足一定条件(是否经历过Scavenge回收,to空间的内存比例)时,新生代空间会被提升到老年代空间,老年代空间中的对象有至少经历过一次或多次回收,所以他们存活的概率会更大。使用Scavenge算法时有两个主要缺点。一是存活的对象会被重复复制,效率低下,二是空间资源的浪费。因此老年代空间中使用了Mark-Sweep(标记去除)和Mark-Compact(标记整理)算法。Mark-SweepMark-Sweep分为标记和清除两个步骤。与Scavenge算法只复制活对象相反,在老年代空间,由于活对象占多数,Mark-Sweep在标记阶段遍历堆中的所有对象,只标记活对象。对象清除未标记的死对象,此时,已经完成了一次标记清除。看起来一切都很完美,但是还是有问题。被清除的对象散布在内存地址各处,造成大量的内存碎片。为了解决Mark-Sweep算法在老年代空间的内存碎片问题,Mark-Compact引入了Mark-Compact(标记排序算法),在工作过程中将存活的对象移动到一端,内存空间此时比较紧凑,移动完成后,直接清理边界外的内存。V8垃圾回收总结为什么垃圾回收很昂贵?V8使用不同的垃圾收集算法Scavenge、Mark-Sweep和Mark-Compact。这三种垃圾回收算法都无法避免在垃圾回收过程中需要暂停应用程序,并在垃圾回收完成后恢复应用程序逻辑。对于新生代空间来说,速度很快,所以影响不大,但是对于老年代空间,由于存活对象多,暂停还是会有影响的。因此,V8新加入了增量标记的方式来减少停顿时间。作者关于V8垃圾回收的讲的很浅,只是在学习过程中做的一个总结。如果你想了解更多原理,简单来说Node.js是个不错的选择。也可以参考这两篇文章V8之旅:垃圾收集,内存管理参考。内存泄漏内存泄漏(MemoryLeak)是指程序中已经动态分配的堆内存没有被释放或由于某种原因无法释放,造成系统内存的浪费,造成运行速度变慢等严重后果程序速度甚至系统崩溃。非全局变量声明的变量或挂在全局global下的变量不会被自动回收,会一直保留在内存中,直到进程退出后才被释放,除非通过delete或重新赋值为undefined/null解决了它们之间的引用关系。将被回收。上面几个关于全局变量的例子也有说明。闭包也是一种常见的内存泄漏情况。闭包将引用父函数中的变量。如果不能释放闭包,则闭包引用的父变量将不会被释放,从而导致内存泄漏。真实案例——Meteor案例研究,2013年,Meteor的创建者宣布了他们遇到的内存泄漏的发现。有问题的代码片段如下(1000000).join('*'),someMethod:function(){console.log(someMessage)}};};setInterval(replaceThing,1000)上面代码运行时,每次都会生成一个新对象replaceThing方法执行了,但是之前的一个对象没有被释放导致内存泄漏。这块涉及到闭包的概念“同一个作用域产生的闭包对象被该作用域内的所有下一级作用域所持有”。因为定义的unused使用了作用域的originalThing变量,replaceThing这一层函数作用域的闭包(someMethod)对象也持有originalThing变量(关键点:someMethod的闭包作用域和unused的作用域是共享的),而它们之间的引用关系是theThing引用了longStr和someMethod,someMethod引用了originalThing,originalThing又引用了上次的theThing,这样就形成了链式引用。上面的代码来自Meteor的博客AninterestingkindofJavaScriptmemoryleak。更多理解请参考Node-Interviewissues#7DiscussionCaution使用内存作为缓存,通过内存进行缓存这可能是我们能想到的最快的实现方式了。另外Caching在业务中还是很常用的,但是了解了Node.js中的内存模型和垃圾回收机制后,还是要谨慎使用。为什么?缓存中存储的键越多,存在的对象就越长,垃圾回收会对这些对象做无用的工作。下面是获取用户Token的例子。memoryStore对象会随着用户数量的增加而不断增长。下面的代码还有一个问题。当你启动多个进程或者在多台机器上部署时,每个进程都会保存一份,这显然是一种资源浪费,最好通过Redis来共享。constmemoryStore=newMap();出口。getUserToken=function(key){consttoken=memoryStore.得到(钥匙);if(token&&Date.now()-token.now>2*60){返回令牌;}constdbToken=db.get(key);memoryStore.set(key,{now:Date.now(),val:dbToken,});returntoken;}模块私有变量内存在加载模块代码之前总是常驻的,Node.js会使用如下函数包装器对其进行封装,确保顶层变量(var,const,let)在模块,而不是全局对象。这时候会形成一个闭包,require的时候会加载一次,exports对象会保存在内存中,直到进程退出才会被回收。这样会造成常驻内存,所以建议引用一个模块只在header引用中Cache一次,而不是每次用到都加载,否则也会造成内存增加。(function(exports,require,module,__filename,__dirname){//模块的代码其实在这里});重复监听事件如果在Node.js中重复监听一个事件会报如下错误,EventEmitter实际使用的是Class,这个类包含一个listeners数组,默认10个listeners超过这个数量就会报警如下图,用来找内存泄漏,也可以通过emitter.setMaxListeners()方法修改指定EventEmitter实例的限制。(节点:23992)MaxListenersExceededWarning:检测到可能的EventEmitter内存泄漏。添加了11个连接侦听器。使用emitter.setMaxListeners()增加limitCnode专栏里有一篇文章分析了Socket重连导致的内存泄漏。参考原生Socket重连策略不合适导致的泄漏,以及Node.jsHTTP模块Keep-Alive导致的内存泄漏,其他注意事项参考GithubNodeIssues#714。在使用定时器setInterval的时候,记得使用对应的clearInterval来清除,因为setInterval执行完后会返回一个值,并不会自动释放。此外,还有map、filter等对数组进行操作。每次操作后都会创建一个新的数组,这个数组会占用内存。如果单纯的遍历,比如map,可以用forEach代替。这些都是开发中的一些细节,但往往细节决定成败,而每一次内存泄漏都是一次又一次的不经意造成的。因此,这几点也需要我们注意。console.log(setInterval(function(){},1000))//返回一个id值[1,2,3]。filter(item=>item%2===0)//[2][1,2,3].map(item=>item%2===0)//[false,true,false]内存检测工具node-heapdumpheapdump是dumpV8堆信息工具,node-heapdumpnode-profilernode-profiler是alinode团队出品的一款抓取内存堆快照的工具,类似于node-heapdump,node-profilerEasy-Monitor轻量级Node.js项目内核性能监控+分析工具,https://github.com/hyj1991/easy-monitorNode.js-Troubleshooting-GuideNode.js应用在线/离线故障、压测问题及性能调优指南,Node.js-Troubleshooting-Guidealinode.js性能平台(Node.jsPerformancePlatform)是为中大型Node.js应用提供性能监控、安全提醒、故障排除、性能优化等服务的整体解决方案。alinode阅读推荐Node.jsGarbageCollectionExplainedV8Tour:GarbageCollection中文版V8Tour:GarbageCollectorMemoryManagementReference。深入浅出讲解Node.js是如何分析Node.js内存泄漏的