重学JS-1.3-知识点:V8引擎

1,346 阅读10分钟

本文正在参与技术专题征文Node.js进阶之路,点击查看详情

V8是一个由Google开发的开源JavaScript引擎,用于Chrome、Node.js等环境中,作用是将JS代码编译为不同CPU(Intel, ARM以及MIPS等)对应的汇编代码。

本文将帮助你理解V8引擎的工作流程,从而帮助你学会如何写出更容易被优化的JS代码。

V8思维导图

JavaScript是解释型语言

理解V8浏览器,首先得知道JavaScript是解释型语言。

为了使计算机硬件能理解源代码,我们需要将其转换成二进制指令(机器码)。

按照转换时机的不同,我们把高级语言分为编译型语言(静态语言)解释型语言(动态语言)

编译型语言(静态语言)

提前将所有源代码一次转换成二进制指令,生成一个可执行程序。比如C语言、C++、Golang等。其转换工具成为编译器

解释型语言(动态语言)

一边执行一边编译,需要哪些源码就转换哪些源码,不会生成可执行程序。比如 Python、JavaScript、PHP、Shell、MATLAB 等,使用的转换工具称为解释器

语言类型

编程语言的跨平台

编译型语言一般是不能跨平台的,也就是不能在不同的操作系统之间随意切换。

相比于编译型语言,解释型语言几乎都能跨平台,解释型语言之所以能够跨平台,是因为有了解释器这个中间层。在不同的平台下,解释器会将相同的源代码转换成不同的机器码,解释器帮助我们屏蔽了不同平台之间的差异。

Java 和 C# 是一种比较奇葩的存在,它们是半编译半解释型的语言,源代码需要先转换成一种中间文件(字节码文件),然后再将中间文件拿到虚拟机中执行。Java 引领了这种风潮,它的初衷是在跨平台的同时兼顾执行效率;C# 是后来的跟随者,但是 C# 一直止步于 Windows 平台,在其它平台鲜有作为。

总结

参考编译型语言和解释型语言的区别

类型原理优点缺点
编译型语言通过专门的编译器,将所有源代码一次性转换成特定平台(Windows、Linux 等)执行的机器码(以可执行文件的形式存在)。编译一次后,脱离了编译器也可以运行,并且运行效率高。可移植性差,不够灵活。
解释型语言由专门的解释器,根据需要将部分源代码临时转换成特定平台的机器码。跨平台性好,通过不同的解释器,将相同的源代码解释成不同平台下的机器码。一边执行一边转换,效率很低。

JavaScript引擎是做什么的?

JavaScirpt引擎的作用是将JS代码编译为不同CPU(Intel, ARM以及MIPS等)对应的汇编代码

JavaScript引擎的工作也不只是编译代码,它还要负责执行代码分配内存以及垃圾回收

主流的JS引擎

目前主流的JS引擎有以下这些:

其中最流行的是谷歌的V8引擎,除了Chrome等浏览器,Node.js、Electron(桌面应用框架)也是用的V8引擎。

什么是V8引擎?

2008年,V8引擎和Chrome在同一天开源,V8是C++实现的。

经过不断优化,V8引擎的性能也在不断提升,关于V8引擎的演变过程,可以参考这篇文章《深入理解JS引擎》,其中有很详细的图解。

V8引擎是怎么工作的?

现在的V8引擎,是怎么工作的呢?

V8引擎是由许多子模块构成的,其中最重要的是这4个模块。

  • Parser:负责将JavaScript源码转换为Abstract Syntax Tree (AST)
  • Ignition:interpreter,即解释器,负责将AST转换为Bytecode,解释执行Bytecode;同时收集TurboFan优化编译所需的信息,比如函数参数的类型;
  • TurboFan:compiler,即编译器,利用Ignitio所收集的类型信息,将Bytecode转换为优化的汇编代码;
  • Orinoco:garbage collector,垃圾回收模块,负责将程序不再需要的内存空间回收;

Parser和Ignition负责V8的编译和执行,这是我们了解的编译型语言的执行方式,那为什么又会有TurboFan这个编译器呢?

什么是JIT?

我们需要先了解一下JIT(Just in Time)即时编译。

在运行C、C++以及Java等程序之前,需要进行编译,不能直接执行源码;但对于JavaScript来说,我们可以直接执行源码(比如:node server.js),它是在运行的时候先编译再执行,这种方式被称为即时编译(Just-in-time compilation),简称为JIT。因此,V8也属于JIT编译器。

实现JIT编译器的系统通常会不断地分析正在执行的代码,并确定代码的某些部分,在这些部分中,编译或重新编译所获得的加速将超过编译该代码的开销。

JIT编译是两种传统的机器代码翻译方法——提前编译(AOT)和解释——的结合,它结合了两者的优点和缺点。大致来说,JIT编译将编译代码的速度与解释的灵活性、解释器的开销以及额外的编译开销(而不仅仅是解释)结合起来。

AOT-ahead of time和just in time相对应。

除了V8引擎,Java虚拟机、PHP 8也用到了JIT。

什么是字节码?

Ignition会先将JavaScript转换为字节码(Bytecode),而不是机器能直接执行的机器码(Machine Code)。为什么需要这一步呢?

首先了解什么是字节码?

字节码(英语:Bytecode)通常指的是已经经过编译,但与特定机器代码无关,需要解释器转译后才能成为机器代码的中间代码。字节码通常不像源码一样可以让人阅读,而是编码后的数值常量、引用、指令等构成的序列。

字节码的优点是总结来说就是:

  • 不针对特定CPU架构
  • 比原始的高级语言转换成机器语言更快

利用字节码,可以实现Compile Once,Run anywhere(一次编译到处运行)。

V8引擎的JIT

具体到V8引擎,JIT的工作流程是这样的:

JavaScript源码被Parser解析成AST后,Ignition解释器一边解释执行代码,一边收集信息(包括函数的执行次数)。如果行数被调用多次,它就有可能被识别为热代码(Hot Code),同时将运行信息反馈给优化编译器TurboFan,TurboFan 根据反馈信息,会优化并编译字节码,最后生成优化的机器码。

下图总结了这个过程,更多具体的解释,可以观看参考文章中的视频,或者看下文的解释。

ignition-turbofan-pipeline

解析器Parser生成抽象语法树

解释器的解析过程分为:词法分析和语法分析两个阶段。

这部分的知识,学习过编译原理就能理解。

贴一个可以看AST结构的网站:astexplorer.net/

V8引擎在解析阶段的一个优化是惰性解析(Lazy Parsing),简单来说就是对不是立即执行的函数,只进行Pre-Parser预解析(只验证语法是否有效、解析函数声明、确定行数作用域,而不完全解析)。

解释器Ignition转换为字节码

解释器,负责将AST转换为字节码,解释执行字节码。

同时Ignition会收集TurboFan优化编译所需的信息,比如函数参数的类型。

node命令提供了很多V8引擎的选项,我们可以通过这些选项,查看V8引擎的工作过程中各个阶段的产物。

我们新建一个实验代码。

// test.js
function load(obj){
    return obj.x;
}

load({x:4, a:7});

运行下面的node命令,打印出Ignition生成的字节码。

node --print-bytecode test.js

bytecode-1

简单理解下,LdaNamedProperty是加载属性,然后Returm返回。

编译器TurboFan优化代码执行流程

TurboFan:compiler,即编译器,利用Ignitio所收集的类型信息,将字节码转换为优化的机器码;

我们再回到上面的图。

ignition-turbofan-pipeline

上图中,绿色的线,是TurboFan利用Ignition收集的信息,对识别为热代码的字节码转换为优化后的机器码。

那什么时候会进行优化呢?分为下面几种情况:

  • 如果函数没有被调用,则V8不会去编译它。
  • 如果函数只被调用1次,则Ignition将其编译Bytecode就直接解释执行了。TurboFan不会进行优化编译,因为它需要Ignition收集函数执行时的类型信息。这就要求函数至少需要执行1次,TurboFan才有可能进行优化编译。
  • 如果函数被调用多次,则它有可能会被识别为热代码,且Ignition收集的类型信息证明可以进行优化编译的话,这时TurboFan则会将字节码编译为优化后的机器码,以提高代码的执行性能。

上图中,红色的线,是“去优化(Deoptimize)”的过程,如果TurboFan生成的优化机器码,对需要执行的代码不适用,会把优化的机器码,重新转换成字节码来执行。

看下面这个例子。

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

add(1, 2);
add(2, 2);
add("1", "2");

add函数的参数之前是整数,后来又变成了字符串。生成的优化机器码已经假定add函数的参数是整数,那当然是错误的,于是需要进行去优化

我们可以执行下面的node命令来打印TurboFan生成的机器码。

node --print-code --print-opt-code test.js

TurboFan基于类型对字节码进行优化和去优化的例子,可以看下这个视频:Franziska Hinkelmann: JavaScript engines - how do they even? | JSConf EU

TurboFan的优化手段很多,除了上面视频基于类型做优化,还有包括内联(inlining)和逃逸分析(Escape Analysis)等,参考这篇文章深入理解JS引擎

内联就是将相关联的函数进行合并。比如:

function add(a, b) {
  return a + b
}
function foo() {
  return add(2, 4)
}

内联优化后:

function fooAddInlined() {
  var a = 2
  var b = 4
  var addReturnValue = a + b
  return addReturnValue
}

// 因为 fooAddInlined 中 a 和 b 的值都是确定的,所以可以进一步优化
function fooAddInlined() {
  return 6
}

逃逸分析就是分析对象的生命周期是否仅限于当前函数,如果是的话会对其进行优化。比如:

function add(a, b){
  const obj = { x: a, y: b }
  return obj.x + obj.y
}

逃逸分析优化后:

function add(a, b){
  const obj_x = a
  const obj_y = b
  return obj_x + obj_y
}

总体流程

V8引擎工作流程

参考文章

编译型语言和解释型语言的区别

JavaScript深入浅出第4课:V8引擎是如何工作的?

认识 V8 引擎

深入理解JS引擎

V8是如何执行JavaScript代码的?

JIT(just-in-time) 即时编译

JIT 为什么能大幅度提升性能?

视频:Franziska Hinkelmann: JavaScript engines - how do they even? | JSConf EU