美文网首页
v8垃圾回收 - 2023-02-18

v8垃圾回收 - 2023-02-18

作者: 勇敢的小拽马 | 来源:发表于2023-02-17 13:01 被阅读0次

    V8引擎垃圾回收策略:

    • V8的垃圾回收策略主要是基于分代式垃圾回收机制,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。
    • 在新生代的垃圾回收过程中主要采用了Scavenge算法;在老生代采用Mark-Sweep(标记清除)和Mark-Compact(标记整理)算法。

    V8的内存结构

    在V8引擎的堆结构组成中,其实除了新生代老生代外,还包含其他几个部分,但是垃圾回收的过程主要出现在新生代和老生代,所以对于其他的部分我们没必要做太多的深入,有兴趣的小伙伴儿可以查阅下相关资料,V8的内存结构主要由以下几个部分组成:

    • 新生代(new_space):大多数的对象开始都会被分配在这里,这个区域相对较小但是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另一半用于在垃圾回收时将需要保留的对象复制过来。
    • 老生代(old_space):新生代中的对象在存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区老生代数据区,前者包含大多数可能存在指向其他对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其他对象的指针。
    • 大对象区(large_object_space):存放体积超越其他区域大小的对象,每个对象都会有自己的内存,垃圾回收不会移动大对象区。
    • 代码区(code_space):代码对象,会被分配在这里,唯一拥有执行权限的内存区域。
    • map区(map_space):存放Cell和Map,每个区域都是存放相同大小的元素,结构简单(这里没有做具体深入的了解,有清楚的小伙伴儿还麻烦解释下)。
    image.png

    新生代区:

    新生代区主要采用Scavenge算法实现,它将新生代区划分为激活区(new space)又称为From区和未激活区(inactive new space)又称为To区
    程序中生命的对象会被存储在From空间中,当新生代进行垃圾回收时,处于From区中的尚存的活跃对象会复制到To区进行保存,然后对From中的对象进行回收,并将From空间和To空间角色对换,即To空间会变为新的From空间,原来的From空间则变为To空间。

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

    流程图

    • 假设我们在From空间中分配了三个对象A、B、C
      image.png
    • 当程序主线程任务第一次执行完毕后进入垃圾回收时,发现对象A已经没有其他引用,则表示可以对其进行回收


      image.png
    • 对象B和对象C此时依旧处于活跃状态,因此会被复制到To空间中进行保存
      image.png
    • 接下来将From空间中的所有非存活对象全部清除
      image.png
    • 此时From空间中的内存已经清空,开始和To空间完成一次角色互换
      image.png
    • 当程序主线程在执行第二个任务时,在From空间中分配了一个新对象D
      image.png
    • 任务执行完毕后再次进入垃圾回收,发现对象D已经没有其他引用,表示可以对其进行回收


      image.png
    • 对象B和对象C此时依旧处于活跃状态,再次被复制到To空间中进行保存
      image.png
    • 再次将From空间中的所有非存活对象全部清除
      image.png
    • From空间和To空间继续完成一次角色互换
      image.png

    通过以上的流程图,我们可以很清楚地看到,Scavenge算法的垃圾回收过程主要就是将存活对象在From空间和To空间之间进行复制,同时完成两个空间之间的角色互换,因此该算法的缺点也比较明显,浪费了一半的内存用于复制。

    对象晋升:

    当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升

    对象晋升的条件主要有以下两个(满足其一即可):

    • 对象是否经历过一次Scavenge算法
    • To空间的内存占比是否已经超过25%

    默认情况下,我们创建的对象都会分配在From空间中,当进行垃圾回收时,在将对象从From空间复制到To空间之前,会先检查该对象的内存地址来判断是否已经经历过一次Scavenge算法,如果地址已经发生变动则会将该对象转移到老生代中,不会再被复制到To空间。

    之所以有25%的内存限制是因为To空间在经历过一次Scavenge算法后会和From空间完成角色互换,会变为From空间,后续的内存分配都是在From空间中进行的,如果内存使用过高甚至溢出,则会影响后续对象的分配,因此超过这个限制之后对象会被直接转移到老生代来进行管理。

    老生代

    在老生代中,因为管理着大量的存活对象,如果依旧使用Scavenge算法的话,很明显会浪费一半的内存,因此已经不再使用Scavenge算法,而是采用新的算法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(标记清除)分为标记清除两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:

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

    以下几种情况都可以作为根节点:

    • 全局对象
    • 本地函数的局部变量和参数
    • 当前嵌套调用链上的其他函数的变量和参数
    image.png

    但是Mark-Sweep算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,因为我们所清理的对象的内存地址可能不是连续的,所以就会出现内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收,而这次垃圾回收其实是没必要的,因为我们确实有很多空闲内存,只不过是不连续的。

    ** Mark-Compact(标记整理)**
    为了解决这种内存碎片的问题,Mark-Compact(标记整理)算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存,我们可以用如下流程图来表示:

    • 假设在老生代中有A、B、C、D四个对象


      image.png
    • 在垃圾回收的标记阶段,将对象A和对象C标记为活动的
      image.png
    • 在垃圾回收的整理阶段,将活动的对象往堆内存的一端移动
      image.png
    • 在垃圾回收的清除阶段,将活动对象左侧的内存全部回收
      image.png

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

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

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

    相关文章

      网友评论

          本文标题:v8垃圾回收 - 2023-02-18

          本文链接:https://www.haomeiwen.com/subject/jdwmkdtx.html