一、为什么需要GC 程序应用运行需要使用内存,其中内存的两个分区是我们常常会讨论的概念:栈区和堆区。 栈区是线性的队列,随着函数运行结束自动释放的,而堆区是自由的动态内存空间、堆内存是手动分配释放或者垃圾回收程序(GarbageCollection,后文都简称GC)自动分配释放的。 软件发展早期或者一些语言对于堆内存都是手动操作分配和释放,比如C、C。虽然能精准操作内存,达到尽可能的最优内存使用,但是开发效率却非常低,也容易出现内存操作不当。 随着技术发展,高级语言(例如JavaNode)都不需要开发者手动操作内存,程序语言自动会分配和释放空间。同时也诞生了GC(GarbageCollection)垃圾回收器,帮助释放和整理内存。开发者大部分情况不需要关心内存本身,可以专注业务开发。后文主要是讨论堆内存和GC。二、GC发展 GC运行会消耗CPU资源,GC运行的过程会触发STW(stoptheworld)暂停业务代码线程,为什么会STW呢?是为了保证在GC的过程中,不会和新创建的对象起冲突。 GC主要是伴随内存大小增加而发展演化。大致分为3个大的代表性阶段:阶段一单线程GC(代表:serial) 单线程GC,在它进行垃圾收集时,必须完全暂停其他所有的工作线程,它是最初阶段的GC,性能也是最差的阶段二并行多线程GC(代表:ParallelScavenge,ParNew) 在多CPU环境中利用多条GC线程同时并行运行,从而垃圾回收的时间减少、用户线程停顿的时间也减少,这个算法也会STW,完全暂停其他所有的工作线程阶段三多线程并发concurrentGC(代表:CMS(ConcurrentMarkSweep)G1)这里的并发是指:GC多线程执行可以和业务代码并发运行。 在前面的两个发展阶段的GC算法都会完全STW,而在concurrentGC中,有部分阶段GC线程可以和业务代码并发运行,保证了更短的STW时间。但是这个模式就会存在标记错误,因为GC过程中可能有新对象进来,当然算法本身会修正和解决这个问题 上面的三个阶段并不代表GC一定是上面描述三种的其中一种。不同程序语言的GC根据不同需求采用多种算法组合实现。三、v8内存分区与GC 堆内存设计与GC设计是紧密相关的。V8把堆内存分为几大区域,采用分代策略。 盗图: 新生代(newspace或younggeneration):空间小,分为了两个半空间(semispace),其中的数据存活期短。老生代(oldspace或oldgeneration):空间大,可增量,其中的数据存活期长大对象空间(largeobjectspace):默认超过256K的对象会在此空间下,下文解释代码空间(codespace):即时编译器(JIT)在这里存储已编译的代码元空间(cellspace):这个空间用于存储小的、固定大小的JavaScript对象,比如数字和布尔值。属性元空间(propertycellspace):这个空间用于存储特殊的JavaScript对象,比如访问器属性和某些内部对象。MapSpace:这个空间用于存储用于JavaScript对象的元信息和其他内部数据结构,比如Map和Set对象。3。1分代策略:新生代和老生代 在Node。js中,GC采用分代策略,分为新、老生代区,内存数据大都在这两个区域。3。1。1新生代 新生代是一个小的、存储年龄小的对象、快速的内存池,分为了两个半空间(semispace),一半的空间是空闲的(称为to空间),另一半的空间是存储了数据(称为from空间)。 当对象首次创建时,它们被分配到新生代from半空间中,它的年龄为1。当from空间不足或者超过一定大小数量之后,会触发MinorGC(采用复制算法Scavenge),此时,GC会暂停应用程序的执行(STW,stoptheworld),标记(from空间)中所有活动对象,然后将它们整理连续移动到新生代的另一个空闲空间(to空间)中。最后原本的from空间的内存会被全部释放而变成空闲空间,两个空间就完成from和to的对换,复制算法是牺牲了空间换取时间的算法。 新生代的空间更小,所以此空间会更频繁的触发GC。同时扫描的空间更小,GC性能消耗也更小、它的GC执行时间也更短。 每当一次MinorGC完成存活的对象年龄就1,经历过多次MinorGC还存活的对象(年龄大于N),它们将被移动到老生代内存池中。3。1。2老生代 老生代是一个大的内存池,用于存储较长寿命的对象。老生代内存采用标记清除(MarkSweep)、标记压缩算法(MarkCompact)。它的一次执行叫做MayorGC。当老生代中的对象占满一定比例时,即存活对象与总对象的比例超过一定的阈值,就会触发一次标记清除或标记压缩。 因为它的空间更大,它的GC执行时间也更长,频率相对新生代更低。如果老生代完成GC回收之后空间还是不足,V8就会从系统中申请更多内存。 可以手动执行global。gc()方法,设置不同参数,主动触发GC。但是需要注意的是,默认情况下,Node。js是禁用了此方法。如果要启用,可以通过启动Node。js应用程序时添加exposegc参数来开启,例如:nodeexposegcapp。js复制代码 V8在老生代中主要采用了MarkSweep和MarkCompact相结合的方式进行垃圾回收。 MarkSweep是标记清除的意思,它分为两个阶段,标记和清除。MarkSweep在标记阶段遍历堆中的所有对象,并标记活着的对象,在随后的清除阶段中,只清除未被标记的对象。 MarkSweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。 为了解决MarkSweep的内存碎片问题,MarkCompact被提出来。MarkCompact是标记整理的意思,是在MarkSweep的基础上演进而来的。它们的差别在于对象在标记为死亡后,在整理过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。V8也会根据一定逻辑,释放一定空闲的内存还给系统。3。2大对象空间largeobjectspace 大对象会直接在大对象空间创建,并且不会移动到其它空间。那么到底多大的对象会直接在大对象空间创建,而不是在新生代from区中创建呢?查阅资料和源代码终于找到了答案。默认情况下是256K,V8似乎并没有暴露修改命令,源码中的v8enablehugepage配置应该是打包的时候设定的。 chromium。googlesource。comv8v8。gitThereisaseparatelargeobjectspaceforobjectslargerthanPage::kMaxRegularHeapObjectSize,sothattheydonothavetomoveduringcollection。Thelargeobjectspaceispaged。Pagesinlargeobjectspacemaybelargerthanthepagesize。复制代码 source。chromium。orgchromiumch (1(181))的结果256K(1(191))的结果256K(1(211))的结果1M(如果开启了hugPage)复制代码四、V8新老分区大小4。1老生代分区大小 在v12。x之前: 为了保证GC的执行时间保持在一定范围内,V8限制了最大内存空间,设置了一个默认老生代内存最大值,64位系统中为大约1。4G,32位为大约700M,超出会导致应用崩溃。 如果想加大内存,可以使用maxoldspacesize设置最大内存(单位:MB)nodemaxoldspacesize复制代码 在v12以后: V8将根据可用内存分配老生代大小,也可以说是堆内存大小,所以并没有限制堆内存大小。以前的限制逻辑,其实不合理,限制了V8的能力,总不能因为GC过程消耗的时间更长,就不让我继续运行程序吧,后续的版本也对GC做了更多优化,内存越来越大也是发展需要。 如果想要做限制,依然可以使用maxoldspacesize配置,v12以后它的默认值是0,代表不限制。 参考文档:nodejs。medium。comintroducing4。2新生代分区大小 新生代中的一个semispace大小64位系统的默认值是16M,32位系统是8M,因为有2个semispace,所以总大小是32M、16M。 maxsemispacesize maxsemispacesize设置新生代semispace最大值,单位为MB。 此空间不是越大越好,空间越大扫描的时间就越长。这个分区大部分情况下是不需要做修改的,除非针对具体的业务场景做优化,谨慎使用。 maxnewspacesize maxnewspacesize设置新生代空间最大值,单位为KB(不存在) 有很多文章说到此功能,我翻了下nodejs。org网页中v4v6v7v8v10的文档都没有看到有这个配置,使用nodev8options也没有查到,也许以前的某些老版本有,而现在都应该使用maxsemispacesize。五、内存分析相关API5。1v8。getHeapStatistics() 执行v8。getHeapStatistics(),查看v8堆内存信息,查询最大堆内存heapsizelimit,当然这里包含了新、老生代、大对象空间等。我的电脑硬件内存是8G,Node版本16x,查看到heapsizelimit是4G。{totalheapsize:6799360,totalheapsizeexecutable:524288,totalphysicalsize:5523584,totalavailablesize:4340165392,usedheapsize:4877928,heapsizelimit:4345298944,mallocedmemory:254120,peakmallocedmemory:585824,doeszapgarbage:0,numberofnativecontexts:2,numberofdetachedcontexts:0}复制代码 到k8s容器中查询NodeJs应用,分别查看了v12v14v16版本,如下表。看起来是本身系统当前的最大内存的一半。128M的时候,为啥是256M,因为容器中还有交换内存,容器内存实际最大内存限制是内存限制值x2,有同等的交换内存。 所以结论是大部分情况下heapsizelimit的默认值是系统内存的一半。但是如果超过这个值且系统空间足够,V8还是会申请更多空间。当然这个结论也不是一个最准确的结论。而且随着内存使用的增多,如果系统内存还足够,这里的最大内存还会增长。 容器最大内存 heapsizelimit 4G 2G 2G 1G 1G 0。5G 1。5G 0。7G 256M 256M 128M 256M5。2process。memoryUsageprocess。memoryUsage(){rss:35438592,heapTotal:6799360,heapUsed:4892976,external:939130,arrayBuffers:11170}复制代码 通过它可以查看当前进程的内存占用和使用情况heapTotal、heapUsed,可以定时获取此接口,然后绘画出折线图帮助分析内存占用情况。以下是EasyMonitor提供的功能: 建议本地开发环境使用,开启后,尝试大量请求,会看到内存曲线增长,到请求结束之后,GC触发后会看到内存曲线下降,然后再尝试多次发送大量请求,这样往复下来,如果发现内存一直在增长低谷值越来越高,就可能是发生了内存泄漏。5。3开启打印GC事件 使用方法nodetracegcapp。js或者v8。setFlagsFromString(tracegc);复制代码tracegc〔40807:0x148008000〕235490ms:Scavenge247。5(259。5)244。7(260。0)MB,0。80。0ms(averagemu0。971,currentmu0。908)task〔40807:0x148008000〕235521ms:Scavenge248。2(260。0)245。2(268。0)MB,1。20。0ms(averagemu0。971,currentmu0。908)allocationfailure〔40807:0x148008000〕235616ms:Scavenge251。5(268。0)245。9(268。8)MB,1。90。0ms(averagemu0。971,currentmu0。908)task〔40807:0x148008000〕235681ms:Marksweep249。7(268。8)232。4(268。0)MB,7。10。0ms(46。7msin170stepssincestartofmarking,biggeststep4。2ms,walltimesincestartofmarking159ms)(averagemu1。000,currentmu1。000)finalizeincrementalmarkingviataskGCinoldspacerequested复制代码GCTypeheapUsedbefore(heapTotalbefore)heapUsedafter(heapTotalafter)MB复制代码 上面的Scavenge和Marksweep代表GC类型,Scavenge是新生代中的清除事件,Marksweep是老生代中的标记清除事件。箭头符号前是事件发生前的实际使用内存大小,箭头符号后是事件结束后的实际使用内存大小,括号内是内存空间总值。可以看到新生代中事件发生的频率很高,而后触发的老生代事件会释放总内存空间。tracegcverbose 展示堆空间的详细情况v8。setFlagsFromString(tracegcverbose);〔44729:0x130008000〕Fastpromotionmode:falsesurvivalrate:19〔44729:0x130008000〕97120ms:〔HeapController〕factor1。1basedonmu0。970,speedratio1000(gc433889,mutator434)〔44729:0x130008000〕97120ms:〔HeapController〕Limit:oldsize:296701KB,newlimit:342482KB(1。1)〔44729:0x130008000〕97120ms:〔GlobalMemoryController〕Limit:oldsize:296701KB,newlimit:342482KB(1。1)〔44729:0x130008000〕97120ms:Scavenge302。3(329。9)290。2(330。4)MB,8。40。0ms(averagemu0。998,currentmu0。999)task〔44729:0x130008000〕Memoryallocator,used:338288KB,available:3905168KB〔44729:0x130008000〕Readonlyspace,used:166KB,available:0KB,committed:176KB〔44729:0x130008000〕Newspace,used:444KB,available:15666KB,committed:32768KB〔44729:0x130008000〕Newlargeobjectspace,used:0KB,available:16110KB,committed:0KB〔44729:0x130008000〕Oldspace,used:253556KB,available:1129KB,committed:259232KB〔44729:0x130008000〕Codespace,used:10376KB,available:119KB,committed:12944KB〔44729:0x130008000〕Mapspace,used:2780KB,available:0KB,committed:2832KB〔44729:0x130008000〕Largeobjectspace,used:29987KB,available:0KB,committed:30336KB〔44729:0x130008000〕Codelargeobjectspace,used:0KB,available:0KB,committed:0KB〔44729:0x130008000〕Allspaces,used:297312KB,available:3938193KB,committed:338288KB〔44729:0x130008000〕Unmapperbuffering0chunksofcommitted:0KB〔44729:0x130008000〕Externalmemoryreported:20440KB〔44729:0x130008000〕Backingstorememory:22084KB〔44729:0x130008000〕Externalmemoryglobal0KB〔44729:0x130008000〕TotaltimespentinGC:199。1ms复制代码tracegcnvp 每次GC事件的详细信息,GC类型,各种时间消耗,内存变化等v8。setFlagsFromString(tracegcnvp);〔45469:0x150008000〕8918123ms:pause0。4mutator83。3gcsreducememory0timetosafepoint0。00heap。prologue0。00heap。epilogue0。00heap。epilogue。reducenewspace0。00heap。external。prologue0。00heap。external。epilogue0。00heap。externalweakglobalhandles0。00fastpromote0。00complete。sweeparraybuffers0。00scavenge0。38scavenge。freerememberedset0。00scavenge。roots0。00scavenge。weak0。00scavenge。weakglobalhandles。identify0。00scavenge。weakglobalhandles。process0。00scavenge。parallel0。08scavenge。updaterefs0。00scavenge。sweeparraybuffers0。00background。scavenge。parallel0。00background。unmapper0。04unmapper0。00incremental。stepscount0incremental。stepstook0。0scavengethroughput1752382totalsizebefore261011920totalsizeafter260180920holessizebefore838480holessizeafter838480allocated831000promoted0semispacecopied4136nodesdiedinnew0nodescopiedinnew0nodespromoted0promotionratio0。0averagesurvivalratio0。5promotionrate0。0semispacecopyrate0。5newspaceallocationthroughput887。4unmapperchunks124〔45469:0x150008000〕8918234ms:pause0。6mutator110。9gcsreducememory0timetosafepoint0。00heap。prologue0。00heap。epilogue0。00heap。epilogue。reducenewspace0。04heap。external。prologue0。00heap。external。epilogue0。00heap。externalweakglobalhandles0。00fastpromote0。00complete。sweeparraybuffers0。00scavenge0。50scavenge。freerememberedset0。00scavenge。roots0。08scavenge。weak0。00scavenge。weakglobalhandles。identify0。00scavenge。weakglobalhandles。process0。00scavenge。parallel0。08scavenge。updaterefs0。00scavenge。sweeparraybuffers0。00background。scavenge。parallel0。00background。unmapper0。04unmapper0。00incremental。stepscount0incremental。stepstook0。0scavengethroughput1766409totalsizebefore261207856totalsizeafter260209776holessizebefore838480holessizeafter838480allocated1026936promoted0semispacecopied3008nodesdiedinnew0nodescopiedinnew0nodespromoted0promotionratio0。0averagesurvivalratio0。5promotionrate0。0semispacecopyrate0。3newspaceallocationthroughput888。1unmapperchunks124复制代码5。4内存快照const{writeHeapSnapshot}require(node:v8);v8。writeHeapSnapshot()复制代码 打印快照,将会STW,服务停止响应,内存占用越大,时间越长。此方法本身就比较费时间,所以生成的过程预期不要太高,耐心等待。 注意:生成内存快照的过程,会STW(程序将暂停)几乎无任何响应,如果容器使用了健康检测,这时无法响应的话,容器可能被重启,导致无法获取快照,如果需要生成快照、建议先关闭健康检测。 兼容性问题:此APIarm64架构不支持,执行就会卡住进程生成空快照文件再无响应,如果使用库heapdump,会直接报错: (machofile,butisanincompatiblearchitecture(have(arm64),need(x8664)) 此API会生成一个。heapsnapshot后缀快照文件,可以使用Chrome调试器的内存功能,导入快照文件,查看堆内存具体的对象数和大小,以及到GC根结点的距离等。也可以对比两个不同时间快照文件的区别,可以看到它们之间的数据量变化。六、利用内存快照分析内存泄漏 一个Node应用因为内存超过容器限制经常发生重启,通过容器监控后台看到应用内存的曲线是一直上升的,那应该是发生了内存泄漏。 使用Chrome调试器对比了不同时间的快照。发现对象增量最多的是闭包函数,继而展开查看整个列表,发现数据量较多的是mongo文档对象,其实就是闭包函数内的数据没有被释放,再通过查看Object列表,发现同样很多对象,最外层的详情显示的是Mongoose的Connection对象。 到此为止,已经大概定位到一个类的mongo数据存储逻辑附近有内存泄漏。 再看到Timeout对象也比较多,从GC根节点距离来看,这些对象距离非常深。点开详情,看到这一层层的嵌套就定位到了代码中准确的位置。因为那个类中有个定时任务使用setInterval定时器去分批处理一些不紧急任务,当一个setInterval把事情做完之后就会被clearInterval清除。 泄漏解决和优化 通过代码逻辑分析,最终找到了问题所在,是clearInterval的触发条件有问题,导致定时器没有被清除一直循环下去。定时器一直执行,这段代码和其中的数据还在闭包之中,无法被GC回收,所以内存会越来越大直至达到上限崩溃。 这里使用setInterval的方式并不合理,顺便改成了利用forawait队列顺序执行,从而达到避免同时间大量并发的效果,代码也要清晰许多。由于这块代码比较久远,就不考虑为啥当初使用setInterval了。 发布新版本之后,观察了十多天,内存平均保持在100M出头,GC正常回收临时增长的内存,呈现为波浪曲线,没有再出现泄漏。 至此利用内存快照,分析并解决了内存泄漏。当然实际分析的时候要曲折一点,这个内存快照的内容并不好理解、并不那么直接。快照数据的展示是类型聚合的,需要通过看不同的构造函数,以及内部的数据详情,结合自己的代码综合分析,才能找到一些线索。比如从当时我得到的内存快照看,有大量数据是闭包、string、mongomodel类、Timeout、Object等,其实这些增量的数据都是来自于那段有问题的代码,并且无法被GC回收。六、最后 不同的语言GC实现都不一样,比如Java和Go: Java:了解JVM(对应NodeV8)的知道,Java也采用分代策略,它的新生代中还存在一个eden区,新生的对象都在这个区域创建。而V8新生代没有eden区。 Go:采用标记清除,三色标记算法 不同的语言的GC实现不同,但是本质上都是采用不同算法组合实现。在性能上,不同的组合,带来的各方面性能效率不一样,但都是此消彼长,只是偏向不同的应用场景而已。