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

说说V8引擎的垃圾回收

时间:2023-04-04 00:47:23 Node.js

前言我们知道,JavaScript之所以能够运行在浏览器环境和NodeJS环境,都是因为背后的V8引擎。它离不开编译、内存分配、运行、垃圾回收的整个过程。在写这篇文章之前,我也在网上看了很多博客,其中有一些英文的原创内容,所以想通过这篇文章做一个总结。加上了自己的思考和手作流程图~~希望这篇文章能对你有所帮助,本文也会收录到我自己的个人网站中。为什么会有垃圾收集?在C语言和C++语言中,如果我们要开辟一块堆内存,需要先计算出所需内存的大小,然后通过malloc函数手动分配。使用完之后,一定要时刻记得使用free函数进行清理和释放,否则这块内存会被永久占用,造成内存泄漏。但是我们写JavaScript的时候没有这个过程,因为他们已经帮我们封装好了,V8引擎会根据你当前定义的对象的大小自动申请内存分配。不需要我们手动管理内存,自然需要垃圾回收。否则只会分配而不会回收。会不会时间长了内存就满了,导致应用程序崩溃。垃圾回收的好处是我们不需要管理内存,把更多的精力放在实现复杂的应用上,但是坏处也来自于此。如果我们不需要去管理它,可能会在写代码的时候不注意,造成循环引用等情况,从而导致内存泄漏。内存结构分配由于V8最初是为了让JavaScript在浏览器中执行而创建的,不太可能遇到大量使用内存的场景,所以它可以申请的最大内存并没有设置太大,而是在64位系统中大约1.4GB,在32位系统中大约700MB。在NodeJS环境下,我们可以通过process.memoryUsage()查看内存分配情况。process.memoryUsage返回一个包含Node进程内存使用信息的对象。该对象包含四个字段,含义如下:rss(residentsetsize):所有内存使用量,包括指令区和栈heapTotal:V8引擎可以分配的最大堆内存,包括如下heapUsedheapUsed:堆内存V8引擎已分配和使用外部:V8管理由C++对象绑定到JavaScript对象的内存。上面所有的内存单元都是字节。如果想扩展Node可用的内存空间,可以使用Buffer等堆外内存。我不会在这里详细介绍。如果你有兴趣,你可以阅读一些资料。下面是Node的整体架构图,可以帮助大家理解以上内容:Node标准库:是我们日常使用的标准库,比如Http、Buffer模块NodeBindings:是JS与C++,封装了V8和Libuv的细节,向上层提供基础的API服务第三??层是支撑Node.js运行的关键,由C/C++实现:1.V8是开发的JavaScript引擎Google,它提供了JavaScript运行时环境。可以说是Node.js2.Libuv是专门为Node.js开发的封装库,提供跨平台的异步I/O能力3.C-ares:提供异步DNS相关能力4.http_parser、OpenSSL、zlib等:提供包括http解析、SSL、数据压缩等能力垃圾回收机制如何判断是否可以回收1.1标记clear当一个变量进入环境时(比如在函数中声明一个变量),它将变量标记为“进入环境”。从逻辑上讲,进入环境的变量占用的内存永远不会被释放,因为只要执行流进入相应的环境,它们就可能被使用。当变量离开环境时,它被标记为“离开环境”。变量可以用任何方式标记。例如,您可以通过翻转特殊位来记录变量何时进入环境,或者使用“进入环境”的变量列表和“脱离环境”的变量列表来跟踪哪个变量发生了变化。如何标记变量并不重要,关键是采用什么策略。(1)垃圾收集器会在运行时标记所有存储在内存中的变量(当然可以使用任何标记方法)。(2)然后,移除运行环境中的变量和环境中变量引用的变量。(3)之后,仍然被标记的变量被认为是要删除的变量,因为它们已经无法再访问这些变量。(4)最后垃圾回收器完成内存清理,销毁标记值并回收它们占用的内存空间。目前,IE、Firefox、Opera、Chrome和Safari的JavaScript实现都使用标记和清除垃圾收集策略(或类似的东西),但间隔不同。活动对象是上面的根。如果不知道活动对象,可以先查资料。当一个对象及其关联对象不再被当前根通过引用关系引用时,该对象将被垃圾回收。1.2引用计数引用计数的垃圾回收策略不太常见。这意味着要跟踪每个值被引用的次数。当声明一个变量,并为该变量赋一个引用类型值时,该值的引用计数为1。如果将相同的值赋给另一个变量,则该值的引用计数加1。反之,如果包含对这个值的引用的变量改变了引用对象,值引用计数减1。当对这个值的引用次数变为0时,意味着没有办法访问这个值,所以它占用的内存空间可以被恢复。这样垃圾收集器下次运行时,就会释放引用计数为0的值占用的内存。NetscapeNavigator3.0是第一个使用引用计数策略的浏览器,但很快就遇到了一个严重的问题:循环引用。循环引用是指对象A包含指向对象B的指针,对象B也包含对对象A的引用。看一个例子:functionfoo(){varobjA=newObject();varobjB=new对象();objA.otherObj=objB;objB.anotherObj=objA;}本例中objA和objB通过各自的属性相互引用,也就是说两个对象的引用数都是2。在mark-and-sweep实现中,这个交叉引用不是问题,因为在函数执行后两个对象都超出了范围。但是在使用引用计数策略的实现中,当函数执行时,objA和objB会继续存在,因为它们的引用计数永远不会为0。如果重复调用这个函数,将不会回收大量内存。为此,Netscape也放弃了Navigator4.0中的引用计数方式,转而采用mark和clear来实现其垃圾回收机制。还应该注意的是,我们大多数人一直在编写循环引用代码。看下面这个例子,相信大家都这样写过:varel=document.getElementById('#el');el.onclick=function(event){console.log('elementwasclicked');}我们绑定一个元素点击事件的匿名函数,我们可以通过事件参数获取对应元素el的信息。想想看,这只是一个循环引用吗?el有一个属性onclick,它引用了一个函数(其实是一个对象),而函数中的参数引用了el,所以引用el的次数总是2。即使当前页面关闭,也无法进行垃圾回收.如果这样的写法很多,就会造成内存泄漏。我们可以在页面卸载的时候清除事件引用,这样就可以回收了:varel=document.getElementById('#el');el.onclick=function(event){console.log('元素被点击');}//...//...//页面卸载时清除绑定的事件window.onbeforeunload=function(){el.onclick=null;}V8垃圾回收策略自动垃圾回收的算法有很多,由于不同的对象有不同的生命周期,所以不可能只用一种回收策略来解决问题,效率会很低。因此V8采用分代回收的策略,将内存分为新生代和老年代两代。新生代中的对象是存活时间短的对象,老年代中的对象是存活时间长或常驻内存的对象。新生代和老年代使用不同的垃圾回收算法来提高效率。先分配给新生代(如果新生代内存空间不够,直接分配给老年代),满足一定条件后,新生代中的对象会被移动到老年代.这个过程也叫推广,我在后面的插图中会详细解释。分代内存32位系统默认新生代内存大小为16MB,老年代内存大小为700MB。64位系统下,新生代内存大小为32MB,老年代内存大小为1.4GB。新生代平均分为两个相等的内存空间,称为半空间,每个内存大小为8MB(32位)或16MB(64位)。新生代1.分配方式新生代存放的是生命周期短的对象,容易分配内存。只保存一个指向内存空间的指针,指针根据分配对象的大小递增。当存储空间即将满时,进行垃圾回收。2.算法新生代采用Scavenge垃圾回收算法,算法实现主要采用Cheney算法。切尼算法将内存分成两部分,称为半空间,一部分在使用中,另一部分处于空闲状态。使用中的半空间称为From空间,空闲状态的半空间称为To空间。我画了一组详细的流程图,接下来我会结合流程图详细讲解切尼算法是如何工作的。垃圾收集在下面统称为GC(GarbageCollection)。步骤1。From空间中分配了三个对象A、B和C。第2步。GC进来,判断没有其他对象B的引用,可以回收。对象A和C仍然是活动对象。第三步。将活动对象A和C从From空间复制到To空间step4。清除From空间中的所有内存step5。交换Fromspace和Tospacestep6。在From空间中添加2个新对象D和Estep7。下一轮GC进来,发现对象D没有被引用,于是标记为step8。将From空间中的活动对象A、C、E复制到To空间step9。清除From空间step10中的所有内存。继续交换From空间和To空间,开始下一轮。通过上面的流程图,我们可以清楚的看到,交换From和To就是让活动对象一直在一个半空间,另一个半空间一直空闲。由于Scavenge只复制存活的对象,而且只有少量存活的对象生命周期短,所以在时间效率上有极好的表现。Scavenge的缺点是只能使用一半的堆内存,这是分区空间和拷贝机制决定的。由于Scavenge是一种典型的牺牲空间换取时间的算法,不能大规模应用于所有的垃圾回收。但是我们可以看到Scavenge非常适合应用在新生代,因为新生代对象的生命周期很短,正好适合这个算法。3.Promotion当一个对象在多次复制中存活下来,它就被认为是一个长寿命的对象。生命周期较长的对象将被移至老年代并使用新算法进行管理。将对象从年轻代移动到老年代的过程称为提升。对象提升的条件主要有两个:当一个对象从From空间复制到To空间时,会检查其内存地址,判断该对象是否经历过Scavenge回收。如果经历过,对象就会从From空间移动到老年代空间,如果没有经历过,就复制到To空间。综上所述,如果一个对象第二次从From空间复制到To空间,那么这个对象就会被移动到老年代。从From空间复制一个对象到To空间时,如果To空间已经使用超过25%,则直接将对象提升到老年代。之所以设置25%的阈值是因为Scavenge回收完成后,To空间会变成From空间,接下来的内存分配会在这个空间进行。如果比例过高,会影响后续的内存分配。老年代1.简介在老年代中,幸存的对象占了很大的比例。如果继续使用Scavenge算法进行管理,会出现两个问题:由于存活对象较多,复制存活对象的效率会很低。使用Scavenge算法会浪费一半的内存。由于老年代占用的堆内存远大于新生代,浪费会很严重。因此,V8在老年代主要采用Mark-Sweep和Mark-Compact的结合方式进行垃圾回收。2.Mark-SweepMark-Sweep是标记去除的意思,分为标记和清除两个阶段。与Scavenge不同,Mark-Sweep不会将内存一分为二,因此不会出现浪费一半空间的行为。Mark-Sweep在标记阶段遍历堆内存中的所有对象,对活对象进行标记。在后续的清除阶段,只清除没有标记的对象。也就是说,Scavenge只复制活对象,而Mark-Sweep只清除死对象。活对象只占年轻代的一小部分,死对象只占老年代的一小部分,这就是为什么这两种回收方式都能得到高效处理的原因。我们看一下流程图:step1。老年代有对象A、B、C、D、E、F。第2步。GC进入标记阶段,将A、C、E标记为存活对象。回收死掉的B、D、F对象占用的内存空间可以看出,Mark-Sweep最大的问题是经过一次清理回收后,内存空间会出现不连续。这种内存碎片可能会导致后续内存分配出现问题。如果出现需要分配大内存的情况,因为剩余的碎片空间不足以完成分配,就会提前触发垃圾回收,这种回收是不必要的。2.Mark-Compact为了解决Mark-Sweep的内存碎片问题,提出了Mark-Compact。Mark-Compact是标记和整理的意思,是在Mark-Sweep的基础上演变而来的。Mark-Compact对活体对象进行标记后,会将活体对象移动到内存空间的一端。移动完成后,会直接清理边界外的所有内存。如下图所示:step1.老年代有对象A、B、C、D、E、F(同Mark-Sweep)。——扫)step3。GC进入排序阶段,将所有存活的对象移动到内存空间的一侧,灰色部分为移动后腾出的空间。步骤4。GC进入清理阶段,一次性回收边界另一侧的所有内存3.两者的结合V8的回收策略中结合使用了Mark-Sweep和Mark-Conpact。由于Mark-Conpact需要移动物体,所以它的执行速度不可能很快。在权衡方面,V8主要使用Mark-Sweep,只有在空间不足以分配从新生代提升的对象时才使用Mark-Sweep。袖珍的。总结V8的垃圾回收机制分为新生代和老年代。新一代主要使用Scavenge进行管理。主要实现是Cheney算法,将内存平均分成两份。已用空间称为From,空闲空间称为To。新对象先分配到From空间,待空间快满时存活。对象被复制到To空间,然后From内存空间被清空。此时From空间和To空间交换,继续分配内存。当这两个条件都满足时,对象就会从新生代晋升到老年代。老年代主要使用Mark-Sweep和Mark-Compact算法,一种用于标记去除,一种用于标记整理。两者的区别在于Mark-Sweep在垃圾回收后会产生内存碎片,而Mark-Compact在清除前会进行一个排序步骤,将存活的对象移到一侧,然后清除边界另一侧的内存,这样就可以释放所有内存了,但是问题是速度会比较慢。在V8中,老年代由Mark-Sweep和Mark-Compact共同管理。以上就是本文的全部内容。在写作过程中,参考了很多中外文章。参考书有朴大的《深入浅出NodeJS》和《JavaScript高级程序设计》。这里不讨论具体的算法实现,有兴趣的朋友可以继续深入研究。最后,感谢您阅读到这里。文中如有歧义或错误,请给我留言~~欢迎关注我的参考链接https://medium.com/@_lrlna/ga...http://alinode.aliyun。com/blo...http://www.ruanyifeng.com/blo...https://segmentfault.com/a/11...