GC垃圾回收与V8引擎如何执行Javascipt代码过程

基础概念

Javascript中的垃圾

  • js中的内存管理是自动的
  • 内存不再被引用时是垃圾
  • 对象不能从根上访问时是垃圾

Javascript中的可达对象

  • 可以访问到的对象就是可达对象(引用,作用域链)
  • 可达的标准就是从根出发是否可以被找到
  • Javascipt中的根就可以理解为全队变量对象

GC算法是什么

GC的垃圾是什么:程序中不再需要使用的对象,程序中不能再被访问到的对象

  • GC是一种机制,垃圾回收器完成的具体工作
  • 工作内容就是查找垃圾释放空间、回收空间
  • 算法就是工作时查找和回收所遵循的规则

常用GC算法

引用计数法
核心思想就是内部通过一个引用计数器,维护当前对象的引用数,判断当前引用数是否为0来判断是否为垃圾对象,当引用数为0,GC开始工作,将其所在内存空间进行垃圾回收。当某一个引用关系发生改变时,引用计数器主动改变引用计数值,当为0时,GC开始工作,回收空间。
优缺点:

  • 发现垃圾时立即回收
  • 减少程序卡顿时间
  • 无法回收循环引用的对象(a指向b,b指向a)
  • 时间开销大(需要维护数值变化,当对象多时,维护会变慢,相对于其他算法,时间开销更大)

标记清除法
核心思想,分标记和清楚两个阶段。第一阶段遍历所有对象标记活动对象(可达对象),第二阶段遍历所有对象清除没有标记的对象,并把有标记对象身上的标记进行抹除,回收相应空间。最终,把回收空间放在空闲列表中,方便程序后面继续申请空间
优缺点

  • 相对于引用计数法,可以对循环引用对象进行回收
  • 空间碎片化,回收空间地址可能是不连续的
  • 不会立即回收垃圾对象

标记整理算法
可以理解为标记清楚算法的增强,标记阶段和标记清除法一样,清楚阶段会先执行整理,然后移动对象位置,使空闲地址连续.
优缺点:

  • 减少碎片化空间
  • 不会立即回收垃圾对象
  • 移动对象位置,回收效率慢

V8垃圾回收策略

V8内存有上限,64位操作系统不超过1.5G,32位系统不超过800M。
采用分代回收思想,内存分为新生代、老生代,针对不同对象采用不同算法(新生代采用具体GC算法,老生代采用具体的算法)。
GC算法常用的有分代回收、空间复制、标记清除、标记整理、标记增量
新生代存储区对象回收:
小空间存储新生代对象(64位系统32M,32位系统16M),新生代指的是存活时间较短的对象。回收过采用复制算法和标记整理,小空间分为From空间(使用空间),To(空闲空间),两个空间等大,当使用空间时,都存储在From空间,当内存使用达到一定程度,触发GC操作,标记整理算法标记From空间,整理后将活动对象拷贝至To空间,然后将From与To交换空间然后使用。
回收细节

  • 拷贝过程中可能出现晋升(新生代中某个使用的对象老生代也有使用),如果一轮GC操作之后新生代对象还活着,就将新生代内对象移动至老生代区
  • To空间使用率超过25%,变成使用状态时,新生代区域会不够用,这时会移动至老生代区

老生代存储区对象回收:
老生代对象存放在右侧老生代区域,64位系统1.4G,32位系统700M。老生代对象就是指存活时间较长的对象(全局对象,闭包中放置的变量数据)
主要采用标记清除,标记整理,标记增量算法。首先使用标记清除完成垃圾空间的回收,这时存在垃圾碎片化的问题。当新生代对象实现晋升时,老生代区域空间不够用,此时触发标记整理进行空间优化,把碎片空间进行整理(老生代区域主要使用算法),最后还会采用增量标记进行效率优化(程序在运行时会有暂停,多次暂停会存在时间空档,在多个时间空闲间隙,进行遍历对象进行标记、完成清楚)。

新老生代回收对比

  • 新生代区域垃圾回收使用空间换时间(空间小)
  • 老生代区域垃圾回收不合适复制算法

V8演进历史

2008 年 V8 发布了第一个版本。当时 V8 架构比较激进,直接将 JavaScript 代码编译为机器码并执行,所以执行速度很快,但在该架构中,V8 只有 Codegen 一个编译器,对代码的优化很有限。
GC垃圾回收与V8引擎如何执行Javascipt代码过程_第1张图片
2010 年,V8 发布了 Crankshaft 编译器,JavaScript 函数通常会先被 Full-Codegen 编译,如果后续该函数被多次执行,那么就用 Crankshaft 再重新编译,生成更优化的代码,之后使用优化后的代码执行,进一步提升性能。
GC垃圾回收与V8引擎如何执行Javascipt代码过程_第2张图片
但是 Crankshaft 对代码的优化有限,所以 2015 年 V8 中加入了 TurboFan。
GC垃圾回收与V8引擎如何执行Javascipt代码过程_第3张图片
V8 依旧是直接将源码编译为机器码的架构,这种架构存在的核心问题:内存消耗特别大。尤其是在移动设备上,通过 Full-Codegen 编译出的机器码几乎占整个 Chrome 浏览器的三分之一。
2016 年 V8 加入了 Ignition 解释器,重新引入字节码,旨在减少内存使用。
GC垃圾回收与V8引擎如何执行Javascipt代码过程_第4张图片
2017 年,V8 正式发布全新编译 pipeline,即用 Ignition 和 TurboFan 的组合,来编译执行代码,从 V8 5.9 版开始,早期的 Full-Codegen 和 Crankshaft 编译器不再用来执行 JavaScript。
GC垃圾回收与V8引擎如何执行Javascipt代码过程_第5张图片
其中,最核心的是三个模块:

  • 解析器(Parser)
  • 解释器(Ignition)
  • 优化编译器(TurboFan)

V8是如何执行 JavaScript 代码的

当 V8 执行 JavaScript 源码时,首先解析器会把源码解析为抽象语法树(Abstract Syntax Tree),解释器(Ignition)再将 AST 翻译为字节码,一边解释一边执行。
在此过程中,解释器会记特定代码片段的运行次数,如果代码运行次数超过某个阈值,那么该段代码 就被标记为热代码(hot code),并将运行信息反馈给优化编译器(TurboFan)。
优化编译器根据反馈信息,优化并编译字节码,最终生成优化后的机器码,这样当该段代码再次执行时,解释器就直接使用优化机器码执行,不用再次解释,大大提高了代码运行效率。
这种在运行时编译代码的技术也被称为 JIT(即时编译),通过JIT可以极大提升 JavaScript 代码的执行性能。

解析器(Parser)如何把源码转换成 AST

要让 V8 执行我们编写的源码,就要将源码转换成 V8 能理解的格式。V8 会先把源码解析为一个抽象语法树(AST),这是用来表示源码的树形结构的对象,这个过程称为解析(Parsing),主要由 V8 的 Parser 模块实现。然后, V8 的解释器会把 AST 编译为字节码,一边解释一边执行。
解析和编译过程的性能非常重要,因为 V8 只有等编译完成后才能运行代码(现在我们先关注 V8 中解析过程的实现)。
GC垃圾回收与V8引擎如何执行Javascipt代码过程_第6张图片

整个解析过程可分为两部分。

  • 词法分析:将字符流转换为 tokens,字符流就是我们编写的一行行代码,token 是指语法上不能再分割的最小单位,可能是单个字符,也可能是字符串,图中的 Scanner 就是 V8 的词法分析器。
  • 语法分析:根据语法规则,将 tokens 组成一个有嵌套层级的抽象语法结构树,这个树就是 AST,在此过程中,如果源码不符合语法规范,解析过程就会终止,并抛出语法错误。图中的 Parser 和 Pre-Parser 都是 V8 的语法分析器。

词法分析

在 V8 中,Scanner 负责接收 Unicode 字符流,并将其解析为 tokens,提供给解析器使用。比如 var a = 1; 这行代码,经过词法分析后的 tokens 就是下面这样:

[
    {
        "type": "Keyword",
        "value": "var"
    },
    {
        "type": "Identifier",
        "value": "a"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Numeric",
        "value": "1"
    },
    {
        "type": "Punctuator",
        "value": ";"
    }
]

可以看到, var a = 1; 这样一行代码包括 5 个tokens:

语法分析

接下来, V8 的解析器会通过语法分析,根据 tokens 生成 AST, var a = 1; 这行代码生成的 AST 的 JSON 结构如下所示:
AST在线转换网站

{
  "type": "Program",
  "start": 0,
  "end": 10,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 10,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 9,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 5,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 8,
            "end": 9,
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "var"
    }
  ],
  "sourceType": "module"
}

但是,对于一份 JavaScript 源码,如果所有源码在执行前都要完全经过解析才能执行,那必然会面临以下问题。
代码执行时间变长:一次性解析所有代码,必然会增加代码的运行时间。
消耗更多内存:解析完的 AST,以及根据 AST 编译后的字节码都会存放在内存中,必然会占用更多内存空间。
占用磁盘空间:编译后的代码会缓存在磁盘上,占用磁盘空间。
所以,现在主流 JavaScript 引擎都实现了延迟解析(Lazy Parsing)

延迟解析

延迟解析的思想很简单:在解析过程中,对于不是立即执行的函数,只进行预解析(Pre Parser),只有当函数调用时,才对函数进行全量解析。
进行预解析时,只验证函数语法是否有效、解析函数声明、确定函数作用域,不生成 AST,而实现预解析的,就是 Pre-Parser 解析器。
以一段代码为例:

function foo(a, b) {
    var res = a + b;
    return res;
}
var a = 1;
var c = 2;
foo(1, 2);

由于 Scanner 是按字节流从上往下一行行读取代码的,所以 V8 解析器也是从上往下解析代码。当 V8 解析器遇到函数声明 foo 时,发现它不是立即执行,所以会用 Pre-Parser 解析器对其预解析,过程中只会解析函数声明,不会解析函数内部代码,不会为函数内部代码生成 AST。
然后 Ignition 解释器会把 AST 编译为字节码并执行,解释器会按照自上而下的顺序执行代码,先执行 var a = 1; 和 var a = 2; 两个赋值表达式,然后执行函数调用 foo(1, 2) ,这时 Parser 解析器才会继续解析函数内的代码、生成 AST,再交给 Ignition 解释器编译执行。

解释器(Ignition)如何将 AST翻译为字节码并执行

在 V8 架构的演进中,我提到 V8 为了解决内存占用问题引入了字节码。如图所示,通常一个几 KB 的文件,转换为机器码可能就是几十兆,这会消耗巨大的内存空间。
GC垃圾回收与V8引擎如何执行Javascipt代码过程_第7张图片
V8 的字节码是对机器码的抽象,语法与汇编有些类似,你可以把 V8 字节码看作一个个指令,这些指令组合到一起实现我们编写的功能,V8 定义了几百个字节码,你可以在 V8 解释器的头文件中查看所有字节码,bytecode.h
Ignition 解释器在执行字节码时,主要使用通用寄存器和累加寄存器(accumulator register),函数参数和局部变量都保存在通用寄存器中,累加寄存器用于保存中间结果。
如果安装了 Node.js ,可以通过 node --print-bytecode index.js 命令查看 JavaScript 文件生成的字节码,该命令会输出很多信息,专注于文件末尾的字节码。

node --print-bytecode index.js

优化编译器(TurboFan)的工作原理

V8 为了提升 JavaScript 的执行性能,在优化编译方面做了很多工作,其中最主要有内联和逃逸分析两种算法。

function add(x, y) {
  return x + y;
}
function three() {
  return add(1, 2);
}

如果不经优化,直接编译该段代码,则会分别生成两个函数的机器码。但为了进一步提升性能,TurboFan 优化编译器首先会对以上两个函数进行内联,然后再编译。
由于函数 three 内部的行为就是求 1 和 2 的和,所以上面的代码就等价于下面的:

function three_add_inlined() {
  var x = 1;
  var y = 2;
  var add_return_value = x + y;
  return add_return_value;
}

更进一步,由于函数 three_add_inlined 中 x 和 y 的值都是确定的,所以 three_add_inlined 还可以进一步优化,直接返回结果 3 :

function three_add_const_folded() {
  return 3;
}

这样最终编译生成的机器码相比优化前,就少非常多了,执行效率自然也高很多。
通过内联,可以降低复杂度、消除冗余代码、合并常量,并且内联技术通常也是逃逸分析的基础。

逃逸分析(Escape Analysis)

分析对象的生命周期是否仅限于当前函数。

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  distance(that) {
    return Math.abs(this.x - that.x)
         + Math.abs(this.y - that.y);
  }
}
function manhattan(x1, y1, x2, y2) {
  const a = new Point(x1, y1);
  const b = new Point(x2, y2);
  return a.distance(b);
}

我们定义了一个 Point 类,用于表示某个点的坐标,类中有个 distance 方法,用来计算两个点之前的曼哈顿距离。
然后我们在 manhattan 函数中 new 了两个点, a 和 b ,并计算 a b 的曼哈顿距离。TurboFan 首先会通过内联,将 manhattan 函数转换为下面这样的函数:

function manhattan_inlined(x1, y1, x2, y2) {
  const a = {x:x1, y:y1};
  const b = {x:x2, y:y2};
  return Math.abs(a.x - b.x)
       + Math.abs(a.y - b.y);
}

再接下来就会对 manhattan_inlined 中的对象进行逃逸分析。两类对象会被认定为“未逃逸”

  • 对象在函数内部定义;
  • 对象只作用域函数内部,如:没有被返回、没有传递应用给其他函数等。

在 manhattan_inlined 中,变量 a b 都是函数内的普通对象,所以它们都是“未逃逸”对象。那么我们就可以对函数中的对象进行替换,使用标量替换掉对象:

function manhattan_scalar_eplacement(x1, y1, x2, y2) {
  var a_x = x1;
  var a_y = y1;
  var b_x = x2;
  var b_y = y2;
  return Math.abs(a_x - b_x)
       + Math.abs(a_y - b_y);
}

这样函数内就不再有对象定义,取而代之的是 a_x a_y b_x b_y ,且直接来源于函数参数。
这样做的好处是,我们可以直接将变量加载到寄存器上,不再需要从内存中访问对象属性了,提升了执行效率的同时还减少了内存使用。

小结

V8 执行 JavaScript 的原理,大致分为三个步骤:

  • 解析器将 JavaScript 源码解析为 AST,解析过程分为词法分析和语法分析,V8 通过预解析提升解析效率;
  • 解释器 Ignition 根据 AST 生成字节码并执行。这个过程中会收集执行反馈信息,交给 TurboFan 进行优化编译;
  • TurboFan 根据 Ignition 收集的反馈信息,将字节码编译为优化后的机器码,后续 Ignition 用优化机器码代替字节码执行,进而提升性能。

你可能感兴趣的