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

Node——内存管理与垃圾回收

时间:2023-04-03 13:39:17 Node.js

前言从前端的思考到后端,很重要的一点就是内存管理。以前因为前端只是运行在浏览器上,所以内存管理一般不需要太在意,但是在服务器端,就需要关心内存了。V8的内存限制和垃圾回收机制memorylimitmemorylimit在一般的后端语言开发中,基本的内存使用是没有限制的。但是由于Node是基于V8构建的,所以V8对内存的使用有一定的限制。默认情况下,64位机器大概可以使用1.4G,而32位机器可以使用0.7G。关于为什么要限制内存大小,有两个方面。一是V8一开始是为浏览器服务的,浏览器端这样的内存大小绰绰有余。另外一个就是后面会提到的垃圾回收机制。垃圾回收会暂停Js的运行。如果内存太大,会导致垃圾回收时间变长,导致Js停顿时间过长。当然我们可以在启动Node服务的时候手动设置内存大小如下:node--max-old-space-size=768//设置老年代,单位是MBnode--max-semi-space-size=64//设置新生代,单位MB查看内存在Node环境下,可以通过process.memoryUsage()查看内存分配rss(residentsetsize):所有内存使用,包括指令区和stackheapTotal:V8引擎可以分配的最大堆内存,包括下面的heapUsedheapUsed:V8引擎分配的堆内存external:V8管理C++对象绑定到JavaScript对象的内存其实大文件的操作通常使用Buffer,原因是因为Node中内存小的原因,而Buffer的使用不受这个限制,它是堆外内存,也就是上面说的external。v8的内存生成目前还没有适合所有场景的自动垃圾回收算法,所以v8内部实际上使用了两种垃圾回收算法。他们回收的对象是生命周期短的和生命周期长的两类对象。具体算法参考下文。这里先介绍下v8是如何进行内存生成的。新生代v8中的新生代主要存储生命周期较短的对象。它有两个半空间,即From和To。分配内存时,将内存分配给From空间。垃圾回收时,会检查From空间中存活的对象(广度优先算法)复制到To空间,然后清空From空间,然后交换From和To空间的位置,这样To空间变为From空间。这种算法的缺陷很明显是有一半的空间已经闲置,需要复制对象,但是由于新生代本身的内存比较小,分配的对象都是生命周期比较短的对象,浪费的空间和使用复制的开销会比较小。一个semisapce在64位系统是16MB,在32位系统是8MB,所以新一代的内存大小分别是32MB和16MB。老年代老年代主要存放生命周期比较长的对象。内存按1MB分页,所有内存按1MB对齐。新生代的内存页是连续的,而老年代的内存页是分散的,以链表的形式串联起来。里面有4种。OldSpaceOldSpace保存的是老年代的普通对象(在V8中是指OldObjectSpace,相对于保存对象结构的MapSpace和保存编译代码的CodeSpace),这些对象大部分来自新生代(即新空间)得到提升。LargeObjectSpace当V8需要分配一个1MBpage(减去header)无法直接容纳的对象时,它会直接在LargeObjectSpace中分配,而不是在NewSpace中分配。垃圾回收时,LargeObjectSpace中的对象不会被移动或复制(因为代价太高)。LargeObjectSpace属于老年代,使用Mark-Sweep-Compact回收内存。映射空间堆上分配的所有对象都有指向它们“隐藏类”的指针。这些“隐藏类”是V8根据运行时状态记录的对象布局结构,用于快速访问对象成员,而这些“隐藏类”(Map)保存在MapSpace中。CodeSpace编译器为运行平台架构编译出的机器码(存放在可执行内存中)本身也是数据,连同一些其他的元数据(比如被哪个编译器编译,源代码的位置等),放置在代码空间中间。关于MapSpace和CodeSpace,推荐大家阅读这两篇文章,因为与本文关系不大,这里不再赘述。第1条第2条V8的内存分配如下图所示。图源为:V8GarbageCollectionMechanismNewGeneration新一代采用Scavenge垃圾回收算法,算法实现主要采用Cheney算法。上面已经大致解释了算法的实现,但是新生代中的对象是如何提升到老年代的呢?默认情况下,V8的对象分配主要集中在From空间。当一个对象从From空间复制到To空间时,会检查它的内存地址,以确定该对象是否经历过Scavenge恢复。如果经历过,则将对象从From空间复制到老年代空间,如果没有,则复制到To空间。推广流程如下图所示。另一个判断标准是To空间的内存使用率。从From空间复制一个对象到To空间时,如果To空间已经使用超过25%,该对象将直接提升到老年代空间。本次推广的判断图如下图所示。Writebarrier关于新生代扫描的问题,既然我们要回收的是新生代中的对象,那么我们只需要检查新生代的引用,那么当按照根对象的引用->新生代或者newgeneration->newgeneration,那么扫描会很快。但是也可能出现老年代指向新生代或者指向根对象的情况。如果选择跟随并扫描整个堆,将花费太多时间。对于这个问题,V8选择的解决方案是使用writebarrier,即每次将指针写入对象(添加引用)时,执行一段代码,这段代码检查写入的指针是否是从老年代对象指向新生代对象,这样我们就可以清楚的记录老年代指向新生代的所有指针。用于记录的数据结构称为storebuffer,每个堆维护一个。为了防止它无限增长,它会定期清理、去重和更新。这样我们通过扫描可以知道根对象->新生代和新生代->新生代的引用,通过查看storebuffer可以知道老年代->新生代的引用。新一代被回收。新生代GC示意图:老年代老年代在64位和32位下分别有1400MB和700MB内存。如果还是使用新生代的Scavenge算法,不仅浪费了一半的空间,还需要拷贝一大块内存。因此,V8在老年代的垃圾回收策略采用了Mark-Sweep和Mark-Compact的组合。Mark-Sweep(Mark-Sweep)Mark-Sweep分为两个阶段:Mark-Sweep和Clear-Sweep。在标记阶段,需要遍历堆中的所有对象,标记那些存活的对象,然后进入清理阶段。在清理阶段,只清理未标记的对象。由于标记清除只清除死对象,老年代中死对象的比例很小,效率更高。标记清除的一个问题是标记清除一次后,内存空间往往是不连续的,大量的内存会出现碎片。如果后面需要分配一个需要更多内存空间的对象,如果所有的内存碎片都不够用,V8将无法完成这次分配,提前触发垃圾回收。图中黑色部分为标示的死物。标记紧凑型(Mark-Compact)标记紧凑型是为了解决标记清除引起的内存碎片问题。Mark-cleaning修改了mark-sweeping的基础,将它的清除阶段变成了一个严格的极端。在排序的过程中,将活对象移动到一段内存区域,移动完成后直接清理边界外的内存。compaction过程涉及移动对象,所以效率不是很好,但是可以保证不会产生内存碎片。由于标记整理需要移动对象,所以速度比较慢。V8主要使用标记清除算法,只有在没有足够空间分配新生代提升对象时才使用标记清除算法。白色格子是活体,深色格子是死体,浅色格子是活体移动后留下的洞。关于标记的具体算法,如果将对齐中的对象看成一个以指针为边的有向图,则标记算法的核心是深度优先搜索。V8使用每个对象两个标记位和一个标记工作栈来实现标记。两个标记位编码三种颜色:白色(00)、灰色(10)和黑色(11)。白色:表示该对象可回收黑色:表示该对象不可回收,其所有引用均已方便完成灰色:表示该对象不可回收,其引用对象未被扫描。当老年代GC启动时,V8会扫描老年代中的对象并进行标记。大体流程如下:将所有非根对象标记为白色。将根的直接引用对象全部入栈,标记为灰色(markingworklist)。从这些对象中进行深度优先搜索。每次访问一个对象,将其pop出来,标记为黑色,然后将其引用的所有白色对象标记为灰色,栈为空时push入栈,回收白色对象,但是这里需要付出代价注意,当对象太大无法压入空间有限的栈时,V8会先将对象保持灰色并丢弃,然后将整个栈标记为溢出。在溢出状态下,V8会继续从栈中弹出对象,标记为黑色,然后将引用的白色对象标记为灰色并溢出,但不会将这些灰色对象压入栈中。不久之后,堆栈中的所有对象都被标记为黑色并清空。这时V8开始遍历整个堆,按照老方法把那些既灰又溢出的对象标记出来。由于溢出后需要再次扫描堆(如果发生多次溢出,可能会扫描多次),当程序创建过多的大对象时,会显着影响GC的效率。引自文章增量标记和惰性清理实际上,为了减少全堆垃圾回收带来的停顿时间,v8使用了两种方式:增量标记和惰性清理。增量标记把应该一口气完成的动作变成增量标记(incrementalmarking),即分成很多小的“步”,每一个“步”执行少量的JavaScript应用逻辑完成了。有一段时间,垃圾收集与应用程序逻辑交替进行,直到标记阶段完成。因为在增量标记的过程中,标记为white的对象很有可能会被重新引用,所以需要一个write-barrier来实现通知。//在`object.field=value`之后调用。write_barrier(object,field_offset,value){if(color(object)==black&&color(value)==white){set_color(value,gray);marking_worklist.push(值);}}下图是增量标记的示意图。Lazycleanup所有的对象都处理完了,所以不是死就是活,堆上有多少空间可以变得空闲已经成定局。这个时候,我们不能急于释放那些空间,耽误清理进程也不成问题。因此,垃圾收集器不会一次清理所有页面,而是根据需要一个一个地清理,直到所有页面都清理干净。OrinocoV8将新一代GC称为Orinoco。在Orinoco下,GC算法效率更高。Orinoconewgeneration关于Orinoco在newgeneration其实更容易理解,因为它只是增加了几个工作线程来帮助处理,如图:Orinocooldgenerationparallelmarkingparallelmarkingparallelmarking是由main线程和工作线程。程序会阻塞。其数据结构如图:Markingworklist负责确定分配给其他工作线程的工作负载,决定了性能和本地线程的平衡。V8使用基于内存段的方法来平衡每个线程的工作量,避免线程同步。费时费力,尽量多干活。也就是说,内存被分成多个段供每个线程工作。并发标记并发标记是由工作线程标记,主线程继续运行,程序不会阻塞。并发标记允许标记行为与应用程序同时进行,很可能会发生数据竞争,所以当数据竞争发生时,主线程需要与工作线程同步。大多数数据竞争行为可以通过轻量级的原子级内存访问来实现同步,但一些特殊场景需要对整个对象进行独占访问。V8使用一个Bailoutworklist来处理整个被独占的对象,由主线程处理,如图:Merging是基于parallelmarking和concurrentmarking的。v8最终的垃圾回收机制如图所示:步骤如下:从根对象开始扫描,将对象填充到标记工作列表中将并发标记任务分发给工作线程工作线程通过协作帮助主线程更快地完成标记耗尽标记工作列表。有时,主线程也通过processingbailoutworklist和markingworklist参与marking。如果标记工作列表为空,则主线程完成垃圾回收。结束前,主线程重新扫描根,可能会找到其他的白色节点。这些白色节点会在工作线程的帮助下通过parallelmarkaccurateGC引用GC不得不提GC的确切类型,这也是V8引擎效率更高的原因。以下内容转自文章。虽然ECMAScript中没有指定整数类型,Numbers都是IEEE浮点数,但在CPU上与浮点数相关的运算通常比整数快。类型操作较慢。大多数JavaScript引擎在底层实现中引入整数类型来提高for循环和数组索引等场景的性能,并使用某些技术将指针和整数(可能还有浮点数)“压缩”成相同的数据结构以节省空间。在V8中,对象按照4字节(32位机器)或8字节(64位机器)对齐,所以对象的地址可以被4或8整除,也就是说地址的二进制表示是最后2位或者3位会是0,也就是说所有指针的这些位都可以腾出来使用。如果另一种类型数据的最后一位也被保留作他用,则可以通过判断最后一位是0还是1来直接区分这两种类型。然后,这种另一种类型的数据可以直接填充到前几位中无需向下指针读取其实际内容。在V8的上下文中,这种结构被称为小整数(SMI,smallinteger),它是一种标记,一种在语言实现中具有悠久历史的常用技术。V8保留了所有word的最后一位(word,32位机器4字节,64位机器8字节)来标记这个word中内容的类型,1为指针,0为整数,这样给定一个word在内存中,它可以通过查看最后一位来快速判断它包含的指针是否是一个整数,并且可以直接将整数存储在字中,而不需要先通过指针间接引用,节省空间。由于V8可以通过查看字的最后一位来快速区分指针和整数,因此在GC期间,V8可以跳过所有整数并更快地沿着指针扫描堆中的对象。因为在GC的过程中,V8能够准确地区分它遍历的每一块内存内容的类型,所以V8的垃圾收集器是准确的。与此相对的是保守GC,即垃圾收集器由于某些设计而无法确定内存中内容的类型。只能保守的假设它们都是指针,然后进行验证,以免不小心回收了不该回收的内存。因此,数据可能会被误认为是指针,仍然引用了一些对象,无法回收,浪费内存。同时,由于保守的垃圾收集器对区分指针和数据没有充分的信心,它不能保证可以安全地修改指针,也不能使用需要移动对象和更新指针的算法。MemoryObservation&GCLogGC日志示例中的图片来自:Areyourv8garbagecollectionlogsspeakingtoyou?JoyeeCheung-AlibabaCloud(AlibabaGroup)option--trace_gc--trace_gc_nvp--trace_gc_verbose三方工具,由于某些原因,我只是在开发测试阶段开启easy-monitor,观察是否有内存泄漏,然后使用heapdump+chromedevtools定位泄漏的具体原因。其实业界最好的就是对接alinode,但是企业对接相对困难,道理大家都明白~我也推荐一些这方面的好资料:《Node.js 调试指南》思考Nodejs性能监控和一些可能原因内存泄漏代码(代码这里就不贴了,网上例子会更详细):全局变量闭包(包括commonjs规范,本质上是闭包生成)缓存总结关于内存和GC,相应需要编码时考虑到的细节与客户端不同,需要对每一个资源进行精心安排。参考V8——你需要了解的垃圾回收机制浅谈V8引擎的垃圾回收浅谈V8引擎中的垃圾回收机制V8GCLog解读(一):Node.js应用背景与GC基础解读V8GC日志(二):堆内外内存的划分和GC算法Orinoco:新生代垃圾收集V8中的并发标记V8之旅:垃圾收集器你的v8垃圾收集日志是在跟你说话吗?JoyeeCheung-阿里云(阿里巴巴集团)