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代码过程
文章图片

2010 年,V8 发布了 Crankshaft 编译器,JavaScript 函数通常会先被 Full-Codegen 编译,如果后续该函数被多次执行,那么就用 Crankshaft 再重新编译,生成更优化的代码,之后使用优化后的代码执行,进一步提升性能。
GC垃圾回收与V8引擎如何执行Javascipt代码过程
文章图片

但是 Crankshaft 对代码的优化有限,所以 2015 年 V8 中加入了 TurboFan。
GC垃圾回收与V8引擎如何执行Javascipt代码过程
文章图片

V8 依旧是直接将源码编译为机器码的架构,这种架构存在的核心问题:内存消耗特别大。尤其是在移动设备上,通过 Full-Codegen 编译出的机器码几乎占整个 Chrome 浏览器的三分之一。
2016 年 V8 加入了 Ignition 解释器,重新引入字节码,旨在减少内存使用。
GC垃圾回收与V8引擎如何执行Javascipt代码过程
文章图片

2017 年,V8 正式发布全新编译 pipeline,即用 Ignition 和 TurboFan 的组合,来编译执行代码,从 V8 5.9 版开始,早期的 Full-Codegen 和 Crankshaft 编译器不再用来执行 JavaScript。
GC垃圾回收与V8引擎如何执行Javascipt代码过程
文章图片

其中,最核心的是三个模块:
  • 解析器(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代码过程
文章图片

整个解析过程可分为两部分。
  • 词法分析:将字符流转换为 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代码过程
文章图片

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 = https://www.it610.com/article/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 ,且直接来源于函数参数。
这样做的好处是,我们可以直接将变量加载到寄存器上,不再需要从内存中访问对象属性了,提升了执行效率的同时还减少了内存使用。
小结
【GC垃圾回收与V8引擎如何执行Javascipt代码过程】V8 执行 JavaScript 的原理,大致分为三个步骤:
  • 解析器将 JavaScript 源码解析为 AST,解析过程分为词法分析和语法分析,V8 通过预解析提升解析效率;
  • 解释器 Ignition 根据 AST 生成字节码并执行。这个过程中会收集执行反馈信息,交给 TurboFan 进行优化编译;
  • TurboFan 根据 Ignition 收集的反馈信息,将字节码编译为优化后的机器码,后续 Ignition 用优化机器码代替字节码执行,进而提升性能。

    推荐阅读