JavaScript-V8引擎的垃圾回收

前言:

我们知道,JavaScript之所以能在浏览器环境和NodeJS环境运行,都是因为有V8引擎在幕后保驾护航。从编译、内存分配、运行以及垃圾回收等整个过程,都离不开它。

本篇主要是从V8引擎的垃圾回收机制入手,讲解一下在JavaScript代码执行的整个生命周期中V8引擎是采取怎样的垃圾回收策略来减少内存占比的。对这方面有一定的了解之后,能增强我们在写代码过程中对减少内存占用,避免内存泄漏的主观意识,也许能够帮助你写出更加健壮和对V8引擎更加友好的代码。

为什么要有垃圾回收:

在V8引擎逐行执行JavaScript代码的过程中,当遇到函数的情况时,会为其创建一个函数执行上下文(Context)环境并添加到调用堆栈的栈顶,函数的作用域(handleScope)中包含了该函数中声明的所有变量,当该函数执行完毕后,对应的执行上下文从栈顶弹出,函数的作用域会随之销毁,其包含的所有变量也会统一释放并被自动回收。试想如果在这个作用域被销毁的过程中,其中的变量不被回收,即持久占用内存,那么必然会导致内存暴增,从而引发内存泄漏导致程序的性能直线下降甚至崩溃,因此内存在使用完毕之后理当归还给操作系统以保证内存的重复利用。

在C语言和C++语言中,我们如果想要开辟一块堆内存的话,需要先计算需要内存的大小,然后自己通过malloc函数去手动分配,在用完之后,还要时刻记得用free函数去清理释放,否则这块内存就会被永久占用,造成内存泄露。但是我们在写JavaScript的时候,却没有这个过程,因为人家已经替我们封装好了,V8引擎会根据你当前定义对象的大小去自动申请分配内存。

不需要我们去手动管理内存了,所以自然要有垃圾回收,否则的话只分配不回收,岂不是没多长时间内存就被占满了吗,导致应用崩溃。垃圾回收的好处是不需要我们去管理内存,把更多的精力放在实现复杂应用上,但坏处也来自于此,不用管理了,就有可能在写代码的时候不注意,造成循环引用等情况,导致内存泄露。


V8引擎的内存限制:

默认情况下,V8引擎在64位系统下最多只能使用约1.4GB的内存,在32位系统下最多只能使用约0.7GB的内存,在这样的限制下,必然会导致在node中无法直接操作大内存对象,比如将一个2GB大小的文件全部读入内存进行字符串分析处理,即使物理内存高达32GB也无法充分利用计算机的内存资源,那么为什么会有这种限制呢?这个要回到V8引擎的设计之初,起初只是作为浏览器端JavaScript的执行环境,在浏览器端我们其实很少会遇到使用大量内存的场景,因此也就没有必要将最大内存设置得过高。但这只是一方面,其实还有另外两个主要的原因:

  • JS单线程机制:作为浏览器的脚本语言,JS的主要用途是与用户交互以及操作DOM,那么这也决定了其作为单线程的本质,单线程意味着执行的代码必须按顺序执行,在同一时间只能处理一个任务。试想如果JS是多线程的,一个线程在删除DOM元素的同时,另一个线程对该元素进行修改操作,那么必然会导致复杂的同步问题。既然JS是单线程的,那么也就意味着在V8执行垃圾回收时,程序中的其他各种逻辑都要进入暂停等待阶段,直到垃圾回收结束后才会再次重新执行JS逻辑。因此,由于JS的单线程机制,垃圾回收的过程阻碍了主线程逻辑的执行。

  • 垃圾回收机制:垃圾回收本身也是一件非常耗时的操作,假设V8的堆内存为1.5G,那么V8做一次小的垃圾回收需要50ms以上,而做一次非增量式回收甚至需要1s以上,可见其耗时之久,而在这1s的时间内,浏览器一直处于等待的状态,同时会失去对用户的响应,如果有动画正在运行,也会造成动画卡顿掉帧的情况,严重影响应用程序的性能。因此如果内存使用过高,那么必然会导致垃圾回收的过程缓慢,也就会导致主线程的等待时间越长,浏览器也就越长时间得不到响应。

V8的垃圾回收策略:

V8的垃圾回收策略主要是基于分代式垃圾回收机制,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。

V8内存结构分配:

由于V8最开始就是为JavaScript在浏览器执行而打造的,不太可能遇到使用大量内存的场景,所以它可以申请的最大内存就没有设置太大,在64位系统下大约为1.4GB,在32位系统下大约为700MB。

通过process.memoryUsage()来查看内存分配:

在这里插入图片描述

  • rss(resident set size):所有内存占用,包括指令区和堆栈
  • heapTotal:V8引擎可以分配的最大堆内存,包含下面的 heapUsed
  • heapUsed:V8引擎已经分配使用的堆内存
  • external:V8管理C++对象绑定到JavaScript对象上的内存

垃圾回收机制:

如何判断是否可以回收:
  1. 标记清除:

当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。

  • 垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。
  • 然后,它会去掉运行环境中的变量以及被环境中变量所引用的变量的标记。
  • 此后,依然有标记的变量就被视为准备删除的变量,原因是在运行环境中已经无法访问到这些变量了。
  • 最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。

目前,IE、Firefox、Opera、Chrome和Safari的JavaScript实现使用的都是标记清除式的垃圾回收策略(或类似的策略),只不过垃圾收集的时间间隔互有不同。

  1. 引用计数:
    引用计数的垃圾收集策略不太常见。含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量改变了引用对象,则该值引用次数减1。当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占用的内存。Netscape Navigator 3.0是最早使用引用计数策略的浏览器,但很快它就遇到了一个严重的问题:循环引用。
function foo () {
    var objA = new Object();
    var objB = new Object();
    
    objA.otherObj = objB;
    objB.anotherObj = objA;
}

objA和objB通过各自的属性相互引用,也就是说,这两个对象的引用次数都是2。

在采用标记清除策略的实现中,由于函数执行后,这两个对象都离开了作用域,因此这种相互引用不是问题。

但在采用引用次数策略的实现中,当函数执行完毕后,objA和objB还将继续存在,因为它们的引用次数永远不会是0。

加入这个函数被重复多次调用,就会导致大量内存无法回收。为此,Netscape在Navigator 4.0中也放弃了引用计数方式,转而采用标记清除来实现其垃圾回收机制。

V8垃圾回收策略:

在V8引擎的堆结构组成中,其实除了新生代和老生代外,还包含其他几个部分,但是垃圾回收的过程主要出现在新生代和老生代:

  • 新生代(new_space):大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来。

  • 老生代(old_space):新生代中的对象在存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区和老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针。

  • 大对象区(large_object_space):存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区。

  • 代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域。

  • map区(map_space):存放Cell和Map,每个区域都是存放相同大小的元素,结构简单。

内存结构图如下所示:
在这里插入图片描述

新生代:

在V8引擎的内存结构中,新生代主要用于存放存活时间较短的对象。新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。

  1. 分配方式:
    新生代存的都是生存周期短的对象,分配内存也很容易,只保存一个指向内存空间的指针,根据分配对象的大小递增指针就可以了,当存储空间快要满时,就进行一次垃圾回收。

  2. 算法:
    新生代采用Scavenge垃圾回收算法,在算法实现时主要采用Cheney算法。Cheney算法将内存一分为二,叫做semispace,一块处于使用状态,一块处于闲置状态。

Scavenge算法是一种典型的牺牲空间换取时间的算法,对于老生代内存来说,可能会存储大量对象,如果在老生代中使用这种算法,势必会造成内存资源的浪费,但是在新生代内存中,大部分对象的生命周期较短,在时间效率上表现可观,所以还是比较适合这种算法。

其中处于激活状态的区域我们称为From空间,未激活(inactive new space)的区域我们称为To空间。这两个空间中,始终只有一个处于使用状态,另一个处于闲置状态。我们的程序中声明的对象首先会被分配到From空间,当进行垃圾回收时,如果From空间中尚有存活对象,则会被复制到To空间进行保存,非存活的对象会被自动回收。当复制完成后,From空间和To空间完成一次角色互换,To空间会变为新的From空间,原来的From空间则变为To空间。

基于以上算法,我们可以画出如下的流程图:

  • 假设我们在From空间中分配了三个对象A、B、C
    在这里插入图片描述

  • 当程序主线程任务第一次执行完毕后进入垃圾回收时,发现对象A已经没有其他引用,则表示可以对其进行回收
    在这里插入图片描述

  • 对象B和对象C此时依旧处于活跃状态,因此会被复制到To空间中进行保存
    在这里插入图片描述

  • 接下来将From空间中的所有非存活对象全部清除
    在这里插入图片描述

  • 此时From空间中的内存已经清空,开始和To空间完成一次角色互换
    在这里插入图片描述

  • 当程序主线程在执行第二个任务时,在From空间中分配了一个新对象D
    在这里插入图片描述

  • 任务执行完毕后再次进入垃圾回收,发现对象D已经没有其他引用,表示可以对其进行回收
    在这里插入图片描述

  • 对象B和对象C此时依旧处于活跃状态,再次被复制到To空间中进行保存
    在这里插入图片描述

  • 再次将From空间中的所有非存活对象全部清除
    在这里插入图片描述

  • From空间和To空间继续完成一次角色互换
    在这里插入图片描述
    Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的体现。Scavenge的缺点是只能使用堆内存的一半,这是由划分空间和复制机制所决定的。

由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模的应用到所有的垃圾回收中。但我们可以看到,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。

晋升:

当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,采用新的算法进行管理。

对象从新生代移动到老生代的过程叫作晋升。

对象晋升的条件主要有以下两个:

  • 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间移动到老生代空间中,如果没有,则复制到To空间。总结来说,如果一个对象是第二次经历从From空间复制到To空间,那么这个对象会被移动到老生代中。
    在这里插入图片描述

  • 当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代中。设置25%这个阈值的原因是当这次Scavenge回收完成后,这个To空间会变为From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。
    在这里插入图片描述

老生代:

在老生代中,存活对象占较大比重,如果继续采用Scavenge算法进行管理,就会存在两个问题:

  • 由于存活对象较多,复制存活对象的效率会很低。
  • 采用Scavenge算法会浪费一半内存,由于老生代所占堆内存远大于新生代,所以浪费会很严重。

所以,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。

我们可能听说过一种算法叫做引用计数,该算法的原理比较简单,就是看对象是否还有其他引用指向它,如果没有指向该对象的引用,则该对象会被视为垃圾并被垃圾回收器回收:

// 创建了两个对象obj1和obj2,其中obj2作为obj1的属性被obj1引用,因此不会被垃圾回收
let obj1 = {
    obj2: {
        a: 1
    }
}

// 创建obj3并将obj1赋值给obj3,让两个对象指向同一个内存地址
let obj3 = obj1;

// 将obj1重新赋值,此时原来obj1指向的对象现在只由obj3来表示
obj1 = null;

// 创建obj4并将obj3.obj2赋值给obj4
// 此时obj2所指向的对象有两个引用:一个是作为obj3的属性,另一个是变量obj4
let obj4 = obj3.obj2;

// 将obj3重新赋值,此时本可以对obj3指向的对象进行回收,但是因为obj3.obj2被obj4所引用,因此依旧不能被回收
obj3 = null;

// 此时obj3.obj2已经没有指向它的引用,因此obj3指向的对象在此时可以被回收
obj4 = null;

经过一系列操作后最终对象会被垃圾回收,但是一旦我们碰到循环引用的场景,就会出现问题:

function foo() {
    let a = {};
    let b = {};
    a.a1 = b;
    b.b1 = a;
}
foo();

例子中我们将对象a的a1属性指向对象b,将对象b的b1属性指向对象a,形成两个对象相互引用,在foo函数执行完毕后,函数的作用域已经被销毁,作用域中包含的变量a和b本应该可以被回收,但是因为采用了引用计数的算法,两个变量均存在指向自身的引用,因此依旧无法被回收,导致内存泄漏。

截至2012年所有的现代浏览器均放弃了这种算法,转而采用新的Mark-Sweep(标记清除)和Mark-Compact(标记整理)算法。在上面循环引用的例子中,因为变量a和变量b无法从window全局对象访问到,因此无法对其进行标记,所以最终会被回收。

Mark-Sweep:

Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。

与Scavenge不同,Mark-Sweep并不会将内存分为两份,所以不存在浪费一半空间的行为。Mark-Sweep(标记清除)在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收。

  • 垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在JavaScript中,window全局对象可以看成一个根节点。
  • 然后,垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾。
  • 最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。

在这里插入图片描述
可以看到,Mark-Sweep最大的问题就是,在进行一次清除回收以后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题。如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

Mark-Compact:

为了解决Mark-Sweep的内存碎片问题,Mark-Compact就被提出来了。

Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。Mark-Compact在标记完存活对象以后,会将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存。

流程图如下:

  • 假设在老生代中有A、B、C、D四个对象
    在这里插入图片描述

  • 在垃圾回收的标记阶段,将对象A和对象C标记为活动的
    在这里插入图片描述

  • 在垃圾回收的整理阶段,将活动的对象往堆内存的一端移动
    在这里插入图片描述

  • 在垃圾回收的清除阶段,将活动对象左侧的内存全部回收
    在这里插入图片描述
    至此就完成了一次老生代垃圾回收的全部过程!

由于JS的单线程机制,垃圾回收的过程会阻碍主线程同步任务的执行,待执行完垃圾回收后才会再次恢复执行主任务的逻辑,这种行为被称为全停顿(stop-the-world)。在标记阶段同样会阻碍主线程的执行,一般来说,老生代会保存大量存活的对象,如果在标记阶段将整个堆内存遍历一遍,那么势必会造成严重的卡顿。

因此,为了减少垃圾回收带来的停顿时间,V8引擎又引入了Incremental Marking(增量标记)的概念,即将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。这个理念其实有点像React框架中的Fiber架构,只有在浏览器的空闲时间才会去遍历Fiber Tree执行对应的任务,否则延迟执行,尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。

得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。

如何避免内存泄漏:

  • 尽可能少地创建全局变量
  • 手动清除定时器
  • 少用闭包
  • 清除DOM引用
  • 弱引用(WeakMap和WeakSet)

几个有用的命令:

node --expose-gc // --expose-gc 表示允许手动执行垃圾回收机制
global.gc();	// 执行一次垃圾回收
process.memoryUsage();	// 查看当前占用的内存

总结:

V8的垃圾回收机制分为新生代和老生代。

新生代主要使用Scavenge进行管理,主要实现是Cheney算法,将内存平均分为两块,使用空间叫From,闲置空间叫To,新对象都先分配到From空间中,在空间快要占满时将存活对象复制到To空间中,然后清空From的内存空间,此时,调换From空间和To空间,继续进行内存分配,当满足那两个条件时对象会从新生代晋升到老生代。

老生代主要采用Mark-Sweep和Mark-Compact算法,一个是标记清除,一个是标记整理。两者不同的地方是,Mark-Sweep在垃圾回收后会产生碎片内存,而Mark-Compact在清除前会进行一步整理,将存活对象向一侧移动,随后清空边界的另一侧内存,这样空闲的内存都是连续的,但是带来的问题就是速度会慢一些。在V8中,老生代是Mark-Sweep和Mark-Compact两者共同进行管理的。

参考:
MDN
https://juejin.cn/post/6844904016325902344

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值