阅读 209

V8垃圾回收机制

前言

代际假说(The Generational Hypothesis)

  1. 大部分对象都是"朝生夕死"的
  2. 不死的对象,会活的更久

垃圾回收算法

  • 垃圾回收的流程
    1. 以某种方式区分存活对象和垃圾数据
    2. 回收垃圾数据
    3. 内存碎片整理(可选)
复制代码

引用计数法

  • 回收没有被引用的对象

遇到循环引用时会导致内存泄漏

    function example () {
        var obj1 = {
            property1: {
                subproperty: 20
            }
        };
        var obj2 = obj1.property1;
        obj2.property1 = obj1;
        return 'some random text';
    }
    example();
复制代码
  • example函数调用前

image-20210515114554358.png

  • example函数调用后,由于两个对象相互引用,无法将其作为垃圾数据回收掉,但实际上我们已经调用完函数,而此时就会造成内存泄漏

image-20210515114457752.png

标记-清除(Mark-Sweep)

  • 由于引用计数法可能会出现上述描述的问题,因此延申出了Mark-Sweep算法,该算法主要是一个【可达性】

即沿着GC Root(全局对象...)BFS遍历扫描可以访问到数据为存活对象,反之无法从GC root查询到的对象都将被清除掉

image-20210515093037998.png

   function marry (man,woman) {
       woman.husband = man;
       man.wife = woman;
       return {
           father: man;
           mother: woman;
       };
   }
   
   let family = marry({
       name: "John"
   },{
       name: "Ann"
   })
复制代码

image-20210515090820064.png

    delete family.father
    delete family.mother.husband
复制代码

image-20210515091042052.png

标记-整理(Mark-Compact)

标记部分操作与Mark-Sweep相同,而之后是将所有可达的对象调整到一端,然后清除这一端之外的内存
(保留调整后的一端,这里面都是可达的对象,而且现在内存变得连续了)

image-20210516124327589.png

半空间收集器的变体Scavenge算法

半空间收集器:将区域划分为两个相等的区域 from space和to space,等from space区域满时将存活对象有序移动(Evacuation)到to space中(防止内存碎片的产生),角色交换,重复过程
Scavenge算法区分了young generation(nursery和intermediate) 、old generation,如下图,第二次GC还存活的对象【晋升】到老年代

generational-gc.png

  • 第一次GC

evacuates.svg

  • 第二次GC

Promotion strategy.svg

栈和堆的数据结构

  • 函数执行栈
    function main() {
        func1();
    }
    function func1() {
        func2();
        func3();
    }
    function func2() {};
    function func3() {};
    main();
复制代码

  • 代码执行过程中栈内存和堆内存的使用情况如下
    class Employee {
        constructor(name, salary, sales) {
            this.name = name;
            this.salary = salary;
            this.sales = sales;
        }
    }

    const BONUS_PERCENTAGE = 10;

    function getBonusPercentage (salary) {
        const percentage = (salary * BONUS_PERCENTAGE) / 100;
        return percentage;
    }

    function findEmployeeBonus (salary, noOfSales) {
        const bonusPercentage = getBonusPercentage(salary);
        const bonus = bonusPercentage * noOfSales;
        return bonus;
    }

    let john = new Employee("John", 5000, 5);
    john.bonus = findEmployeeBonus(john.salary, john.sales);
    console.log(john.bonus);
复制代码

V8垃圾回收机制

阐述V8两个不同的垃圾回收器

  • 垃圾数据是如何产生的 ?
    window.text = new Object();
    window.text.a = new Array(100);
    window.text.a = new Object();
复制代码

下图中的Array就是产生的垃圾数据
垃圾数据是指堆中的数据,栈通过偏移指针就可以覆盖了,不需要专门的垃圾回收器来处理

  • V8的垃圾回收分为了副垃圾回收器和主垃圾回收器
    • 副垃圾回收器处理新生代,采用Scavenge垃圾回收算法
    • 主垃圾回收器处理老年代,通过Mark-Sweep和Mark-Compact两种垃圾回收策略配合使用

    内存碎片过多时需要通过Mark-Compact来消除内存碎片

案例

  • 分析如下代码产生了哪些垃圾数据
    function strToArray (str) {
      const len = str.length; // 10
      let arr = new Array(str.length);

      for(let i = 0; i < len; ++i) {
        arr[i] = str.charCodeAt(i); // 转为ASCII来存放
      }
      return arr; // 这个返回可有可无...
    }

    function foo () {
      let i = 0;
      let str = "test V8 GC";

      while (i ++ < 1e5) {
        strToArray(str);
      }
    }

    foo();
复制代码
  • 产生了哪些垃圾数据

    执行foo函数后,每一次while循环都会产生一个垃圾数据

strToArray调用后根据str参数生成的数组arr

image-20210517113216260.png

  • V8的垃圾回收器是如何回收这些垃圾数据
    1. 首先进入新生区的work
    2. work快满时通过Scavenge算法将存活对象转入到idle
    3. 然后work区域清除
    注释:因为从GC Root触发无法达到上述产生的每一个垃圾数据,所以经过第一次Scavenge算法后久可以清除掉它们
复制代码

image-20210517120256088.png

  • 站在内存空间和主线程的角度来优化这段代码
    1. 因为每次循环调用strToArray都会申请一个新的数组,因此我们可以在foo中先新建一个数组,然后调用strToArray将该数组传递进去
	(js传递是传递值的),这样最终foo调用完后,仅会产生1个垃圾数据
    2. 从优化执行栈的角度考虑,调用foo函数进入循环,每次调用strToArr函数,那么此时执行栈就会存在两个函数的上下文,而如果将strToArray函数的逻辑写入到foo函数的循环中,那么执行栈就可以减少一个函数上下文(虽然减少一个感觉没什么用,哈哈哈,但还是分析一下)
    * 在递归函数的时候,采用尾调用优化执行栈可以有效防止`爆栈`,但现在浏览器好像不支持
复制代码
  • 优化执行栈和堆后的代码
    function foo () {
      let i = 0;
      let str = "test V8 GC";
      const len = str.length; // 10
      let arr = new Array(str.length);

      while (i ++ < 1e5) {
        for(let i = 0; i < len; ++i) {
          arr[i] = str.charCodeAt(i); // 转为ASCII来存放
        }
      }
    }

    foo();
复制代码

垃圾回收的优化策略

全停顿(Stop The World)

JavaScript是单线程的,在原来执行JS基础上穿插垃圾回收的操作,如果垃圾回收的操作需要的时间比较久,那么用户就会很明显的感觉到页面在卡顿

如:页面正在执行某个JavaScript动画,而因为此时垃圾回收器正在工作,就会导致这个动画在200毫秒内无法执行
复制代码

stopTheWorld.svg

并行回收(Parallel)

GC的时候【主线程和辅助线程同时】开始进行垃圾回收

parallel.svg

增量回收(Incremental)

  • 如果面对大对象(window 、DOM等),通过并行回收策略来执行老生代的垃圾回收,时间依然会很久,因此V8引入了增量回收策略。【每次执行一个完整的垃圾回收过程的一小部分工作】

Incremental.svg

  • 实现难点

一 . 垃圾回收暂停时需要记录它执行到哪里,重启时才能继续从记录点开始执行

二 . 垃圾回收暂停时,执行的JavaScript代码改变了被标记好的垃圾数据,那么垃圾回收器重启时需要能正确处理它

  • 在没有引入增量算法之前,V8在Mark-Sweep的Mark中使用黑色和白色来区分数据,而这样会面临一个问题:暂停后执行JavaScript若改变了已经被标记好的垃圾数据,垃圾回收器重启时无法正确处理它

    • 初始所有的数据均为白色
    • Mark阶段:从GC root出发,将所有能访问到的数据标记为黑色,黑色为存活数据
    • Sweep阶段:垃圾回收器将回收白色的垃圾数据
  • 因此实现 三色标记法 的变体来解决了这个问题

    * 最初:黑色集为空,灰色集是直接从根引用的对象集,白色集包括其它对象
    三色标记法算法流程:
    	1. 从灰色集中选择一个对象,然后将其移入黑色集
    	2. 将其引用的每个白色对象移至灰色组
    	3. 重复步骤1和2,直至灰色集为空
    
    * 所有无法从根到达的对象都将被添加到白色集合中,并且对象只能从白色移动到灰色,从灰色移动到黑色,该算法可能会出现黑色对象引用灰色对象,灰色对象引用白色对象,而不可能会出现黑色对象引用白色对象(三色不变式)
复制代码

Animation_of_tri-color_garbage_collection.gif

  • V8采用强三色不变性:当使黑色节点指向白色节点时,就会通过 写屏障机制 来使得 该白色节点变为灰色
  • 灰色集为空 -> 主线程将完成垃圾回收 -> 主线程重新扫描GC root,尽可能发现更多的白色节点,而白色节点的标记工作由辅助线程并行完成
1. 如果断开F,且此时标记列表为空 
2. 主线程将完成垃圾回收 
3. 主线程重新扫描GC root,如果此时Black集中存在没有扫描到的节点,
   那么就将其从Black集转入White集中(用同样的BFS来对比即可)
复制代码

并发回收(Concurrent)

该种方式可以使得主线程完全执行JavaScript代码,从而避免了全等待,导致用户体验不佳,但实现起来比较困难

  • 垃圾回收的辅助线程和JS主线程同时修改一个对象,这时候就需要加入读/写的锁机制了
  • 堆的内容随时可能会被改变,从而使得我们之前所做的工作无效

Concurrent.svg

Idle-time GC

提供给开发者的API:requestIdleCallback

浏览器每16ms渲染一帧

payer

副垃圾回收器采用的垃圾回收优化策略

  • 并行回收

主垃圾回收器采用的垃圾回收优化策略

  • 并行回收 、并发回收 、增量回收
  1. 并发回收策略进行Mark(标记工作都是在辅助线程中完成的)
  2. 完成标记后,再采用并行回收策略,主线程和辅助线程同时进行清理工作,而且该过程会配合增量回收策略,使得清理任务穿插各中JavaScript任务之间执行
复制代码

image-20210519115558963.png

思考

  • 在使用 JavaScript 时,如何避免内存泄漏?
  1. 挂载到全局对象上的需要避免循环引用
  	window.b = new Object();
  	window.a = new Object();
  	window.b.name = a;
  	window.a.name = b;
  /*
  	b = null;
        window.a.name = null;
      此时垃圾回收器会将b对象标记为垃圾数据
      
          a = null;
      window.b.name = null;
      此时垃圾回收器会将a对象标记为垃圾数据
  */
复制代码

image-20210519203900917

  1. 静态作用域链保存的闭包所指向的对象比较大或多时,
    而面对eval这些动态作用域链所形成的闭包比较大时,
    也需要及时引用函数的变量及时设置为null,然垃圾回收器将其回收

参考文章

文章分类
前端
文章标签