v8-debug 与 v8 编译流程

·  阅读 801

一 什么是 V8

1. V8 是什么

V8 是一个由 Google 开发的开源 JavaScript 引擎,目前用在 Chrome 浏览器和 Node.js 中,其核心功能是执行 JavaScript 代码。其主要核心流程分为编译执行两步。首先需要将 JavaScript 代码转换为低级中间代码或者机器能够理解的机器代码,然后再执行转换后的代码并输出执行结果。

可以把 V8 看成是一个虚构出来的计算机,也称为虚拟机,虚拟机通过模拟实际计算机的各种功能来实现代码的执行,如模拟实际计算机的 CPU、堆栈、寄存器等,虚拟机还具有它自己的一套指令系统。当 V8 执行 JavaScript 代码时,不需要担心现实中不同操作系统的差异,也不需要担心不同体系结构计算机的差异,只需要按照虚拟机的规范写好代码即可。

2. V8 运行环境

V8 必须运行在宿主环境。原因在于,V8 自身没有主线程,由宿主环境(程序)启动,并提供主线程,堆栈,全局对象,以及其他 V8 运行所需的条件。

3. V8 的调试工具

V8 在 Google 的 git 上开源,并在 github 维护有一份镜像。V8 有一个官方文档网站,提供了 V8 的简介,以及下载和编译方法。

随 V8 一起开源的工具中,有一个名为 d8 的工具。它用来调试 V8。使用 d8 必须从官网下载源码并编译。NPM 上有一个实现了 v8 debugger protocol 的第三方包 —— v8-debug。使用它可以更简单的调试 V8。

二 使用 jsvu 安装 JavaScript 引擎

1. 安装 jsvu

jsvu(JavaScript Engine Version Updater) 是一个 NPM 包。提供了交互式命令行,来安装最新的(默认)或指定版本的 JavaScript 引擎。其 NPM 主页中提供了下载和使用方法。

npm install --global jsvu
复制代码

安装完成后,会创建一个全局命令 jsvu,在任何目录下运行该命令,均可开始安装 JavaScript 引擎。

2. jsvu 使用

(1)选择系统版本

image.png

(2)选择要安装的 JavaScript 引擎

使用方向键控制光标,空格来控制是否选中(*为选中)。默认全选,全部安装。这里只需要安装 v8,v8-debug 即可。选择完毕后,回车确认,开始安装。

v8 和 v8-debug 都需要安装,因为有些参数只有 v8-debug 支持

(3)切换目录

使用 jsvu 安装的 JavaScript 引擎,会默认安装在用户家目录下的 .jsvu 目录下(. 开头的目录是隐藏的)。Windows 用户可以使用下面的命令:

cd c:/%HOMEPATH%/.jsvu
复制代码

%HOMEPATH% 是 Windows 的系统变量,只能在 Windows 系统使用,表示当前用户家目录

3. 引擎的两种运行方式

(1)直接输入命令和文件名,来运行指定文件

# 在当前目录创建 test.js 并运行
v8 test.js
复制代码

(2)-e 选项,直接运行代码

# 直接运行 JavaScript 代码
v8 -e "console.log('aaa')"
# aaa
复制代码

4. 输出 v8, v8-debug 帮助文档

因为在使用的时候,需要频繁使用这两个工具的参数和选项,因此,我们必须有一份帮助文档来查阅。通常,命令行工具的 -h 或者 --help 选项都是查阅帮助信息的,但是这两个工具的帮助信息过多,因此,我们可以把输出重定向到文件,这样我们在用文件工具查看就方便多了。

touch v8-help.txt

v8 --help >> v8-help.txt

touch v8-debug.txt

v8-debug --help >> v8-debug-help.txt
复制代码

三 v8-debug 如何使用

下面我会举两个例子,来说明 v8-debug 如何使用,如何加深我们对 JavaScript 的理解。

1. 严格模式

在 JavaScript 中,严格模式与普通模式有许多区别,其中一个就是,全局环境的 this 指向不同。非严格模式下,全局环境 this 只想宿主对象(浏览器环境就是 window),严格模式下,全局环境的 this 为 undefined。

那么如何来验证这个结论呢?Chrome 控制台当然也可以,v8-debug 也能做到这件事。首先,我们新建一个测试文件

function add (x, y{
    const num = 100
    let result = x + y + 100
    return `RESULT: ${result}`
}
console.log(add(2030))
复制代码

然后我们在 v8-debug-help.txt 这个文件中可以找到,--trace 选项用来输出调用栈信息,使用该选项运行文件,有如下输出:

v8-debug --trace test.js

   1: ~+0(this=0x029b0828334d <JSGlobal Object>) {
   2:  ~add+0(this=0x029b0828334d <JSGlobal Object>, 20, 30) {
   2:  } -> 0x029b08108e59 <String[11]: "RESULT: 150">
RESULT: 150
   1: } -> 0x029b080023b5 <undefined>
复制代码

我们可以从输出看出来,add 函数的 this 指向 <JSGlobal Object>,即当前运行环境的宿主对象。

那么,如果用严格模式来运行,结果是什么呢?从帮助文档中,可以查找到,--use-strict 选项用来开启严格模式,我们可以使用这个选项,而不必手动在代码头部添加 use strict;,输出如下:

v8-debug --trace --use-strict test.js
   1: ~+0(this=0x03d10828334d <JSGlobal Object>) {
   2:  ~add+0(this=0x03d1080023b5 <undefined>, 20, 30) {
   2:  } -> 0x03d108108e55 <String[11]: "RESULT: 150">
RESULT: 150
   1: } -> 0x03d1080023b5 <undefined>
复制代码

可以明显看到,add 函数的 this,指向了 undefined(全局环境不受影响,依然是 <JSGlobal Object>)。

2. 惰性编译

(1)什么是惰性编译

V8 在执行一段 JavaScript 代码时,不会一次将所有代码全部编译。而是将这段代码顶层部分编译,所有的函数声明,仅在执行到该函数时才进行编译。这样的编译策略被叫做惰性编译。

使用惰性编译的策略,是为了保证 JavaScript 初始时的运行性能。

(2)如何查看惰性编译

惰性编译是默认开启的,在帮助文档有这样一句:

  --lazy (use lazy compilation)
        type: bool  default: true
复制代码

--lazy 是一个布尔类型的选项,默认为 true,即默认开启惰性编译。那么如何关闭呢?

在帮助文档的开头,有对各种选项如何使用的介绍:

The following syntax for options is accepted (both '-' and '--' are ok):
  --flag        (bool flags only)
  --no-flag     (bool flags only)
  --flag=value  (non-bool flags only, no spaces around '=')
  --flag value  (non-bool flags only)
复制代码

可以看出,如果是布尔类型的选项,需要添加 no,前缀,也就是使用 --no-lazy 选项来关闭惰性编译。

那么我们该如何查看惰性编译开启和关闭的区别呢?想要看懂区别,首先我们需要对 JavaScript 的运行机制,和 V8 编译流水线有一个基本的认识。

四 V8 编译流水线

1. V8 编译流程

先上图:

图片6.png

上图展示了 V8 引擎从启动,到编译,到执行结束的整个流程。

2. V8 启动

启动阶段可以分为如下几个子过程:

(1)启动宿主环境

严格来说,这一步与 V8 是无关的,但是 V8 自身是没有启动能力的,也没有运行的主线程,一切的开始,都始于宿主环境启动。

(2)启动 V8 运行时环境

当我们想执行一段 JavaScript 代码时,只需要将代码丢给 V8 虚拟机,V8 便会执行并返回结果。

其实在执行 JavaScript 代码之前,V8 就准备好了代码的运行时环境。这个环境包括了

  • 栈空间和堆空间
  • 全局执行上下文
  • 全局作用域
  • 消息队列与事件循环系统
  • 以及全局函数,WebAPI 等内置资源

(3)构造堆栈空间

栈空间

栈空间用来管理 JavaScript 调用。每一段 JavaScript 代码(一段脚本,或一个函数),V8 在编译阶段会生成执行这段代码的执行环境,也叫做执行上下文。JavaScript 使用栈来管理执行上下文。编译代码时入栈,执行完成后出栈。

栈空间的最大特点是内存连续,因此栈空间的操作效率非常高。但因为内存中很难分配到一大段连续的空间,因此栈空间通常很小。

堆空间

如果有一些内存占用比较大的数据,或者不需要存储在连续空间中的数据,栈空间就不适合使用。于是 V8 使用了堆空间。

堆空间是一种树形的存储结构,用来存储对象类型的离散的数据。JavaScript 除了原生类型以外,其他的对象类型,诸如函数,数组,浏览器的 window 对象,document 对象等,都是存储在堆空间。

(4)全局执行上下文与全局作用域

V8 初始化了基础的存储空间(栈空间,堆空间)之后,接着会初始化全局执行上下文和全局作用域。

(5)构造事件循环与消息队列

V8 是寄生在宿主环境中的,本身没有自己的主线程,而是使用宿主环境提供的主线程,V8 自身,与 V8 执行的代码,都运行在宿主环境的主线程。

只有一个主线程是不够的,当一个线程的代码执行完成后,线程就自动退出了,下次使用时又要重新启动线程,初始化数据。严重影响运行效率。

因此,主线程需要一个消息队列,存放 V8 内部的,页面响应的,JavaScript 触发的各种任务;还需要一个事件循环,不断地从消息队列中取出任务来执行。

3. 编译解析阶段

初始环境准备好以后,就进入了编译阶段。这里有两件事需要做:词法分析,语法分析。

词法分析

词法分析用于将代码拆分为 “最小的,不可分割的单位”,它被叫做 “token”。比如关键字 for, if,和一些直接量,如 123 这样的数字。

语法分析

语法分析用于将已经拆分后的 token ,生成抽象语法树(AST)。之后,再根据 AST 生成执行上下文和作用域。

4. 解释执行

有了 AST,执行上下文,作用域,就可以依据这些,由解释器生成字节码,并执行字节码。到这里,执行结束就会输出结果了。

5. 监控热点代码

在上面解释执行的同时,会有一个监控监控器来监控代码的执行情况。一些频繁执行的代码会被识别为热点代码,这些热点代码会被编译器编译为机器码。

五 执行上下文与作用域

1. 执行上下文

任何一个函数在编译阶段都会创建执行所需要的环境,这个环境叫做执行上下文。V8 不断地执行函数,就会不断地创建执行上下文,这些执行上下文使用栈结构维护,我们称之为执行上下文栈,也被叫做调用栈。

执行上下文是在编译阶段就被确定了的,一个执行上下文会包含以下内容:

  • 变量环境,var,function 变量存放在变量环境
  • let,const 存放在词法环境
  • this 指针域
  • 内部存在一个 “outer” 指针,指向上层作用域

2. 举个例子理解执行上下文

示例代码如下:

function bar({
    var mName = 'bar-name'
    let test1 = 100
    if (true) {
        let mName = 'inner-name'
        console.log(test)  // 执行到此的调用栈
    }
}

function foo({
    var mName = 'foo-name'
    let test = 2
    {
        let test = 3
        bar()
    }
}

var mName = 'global-name'
let test = foo()
复制代码

当代码执行到 console.log(test) 这一行时,其调用栈如下图所示:

图片7.png

需要注意的是,执行上下文是在编译阶段创建并入栈的。即便代码没有执行。但 let,var 这些变量的声明都已经被提升,只是 let,const,这些词法环境的变量,只提升声明,不提升初始化,因此,V8 限制了访问,这被称为“暂时性死区”。

执行上下文以函数来划分,一个函数在编译时,内部所有的块级作用域变量都会被提升

3. 作用域

语言的作用域大致分为两类:

第一种,动态作用域

动态作用域,即对环境变量的访问能力与运行时的执行上下文有关,因为运行时执行上下文是动态的,因此叫动态作用域,又因为与执行上下文有关,因此也叫做上下文作用域。

第二种,静态作用域

静态作用域,即对环境变量的访问能力与声明时的执行上下文有关。因为声明时的执行上下文可以在编译阶段由词法规则确定,因此又叫做词法作用域,因为是编译阶段就确定的,因此是静态的,也叫做静态作用域。

JavaScript 使用的就是静态作用域,也叫做词法作用域,即,变量的作用域取决于变量声明时的上下文,而不是执行时的上下文

4. 举个例子理解词法作用域

还是上文的示例代码,执行到 console.log(test) 这一句,访问 test 变量时,作用域关系如下图所示:

图片8.png

我们可以看到,访问变量 test 时,经过了如下步骤:

  • 第一步,查看当前的执行上下文的词法环境是否存在
  • 第二步,如果词法环境没有,再去当前上下文的变量环境,查看是否存在
  • 第三步,如果变量环境也没有,再去 outer 指向的上层作用域对应的执行上下文中,查看是否存在,依此类推

经过上面三步,整个查询过程就形成了一个 “作用域链”。

我们看到,作用域链和调用栈并不严格相等。例如 bar 函数的上层作用域并不是调用方 foo 函数,而是全局上下文。这就是因为 JavaScript 是词法作用域,bar 内部的变量的作用域,只与声明时的位置有关,bar 函数在全局环境声明,那自然上层作用域是全局上下文。

六 从作用域来看惰性编译

1. 作用域与惰性编译的关系

至此,我们简要介绍了一下 V8 的编译流水线,执行上下文,词法作用域的概念。一句话总结这三者的关系,

基于惰性编译的原则,V8 在遇到全局代码,或一段函数声明时,会开始编译其内部的全部顶层代码(不包含内部的函数声明),在这个编译过程中,生成了执行上下文和词法作用域。

换句话说,假如我们要输出编译阶段的词法作用域内容,那么这个输出就与编译次数相关,而编译次数又与是否是惰性编译有关,因为惰性编译下,函数声明会在执行时编译;非惰性编译下,代码是一次性编译的。

2. 通过作用域查看惰性编译

我们回到最初的示例代码:

function add (x, y{
    const num = 100
    let result = x + y + 100
    return `RESULT: ${result}`
}
console.log(add(2030))
复制代码

--print-scopes 选项可以用来输出编译阶段的作用域情况。

我们运行两次命令,考察是否开启惰性编译带来的区别:

# 开启惰性编译(默认开启)
λ v8-debug --print-scopes test.js
Inner function scope:
function add () { // (0000021602C762E0) (12, 102)
  // 2 heap slots
  // local vars:
  LET result;  // (0000021602C785B0) never assigned
  VAR x;  // (0000021602C784D8) never assigned
  VAR y;  // (0000021602C78520) never assigned
  CONST num;  // (0000021602C78568) never assigned
}
Global scope:
global { // (0000021602C760C8) (0, 129)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0000021602C76710) local[0]
  // local vars:
  VAR add;  // (0000021602C76550)
  // dynamic vars:
  DYNAMIC_GLOBAL console;  // (0000021602C767D0) never assigned

  function add () { // (0000021602C762E0) (12, 102)
    // lazily parsed
    // 2 heap slots
  }
}
Global scope:
function add (x, y) { // (0000021602C762E0) (12, 102)
  // will be compiled
  // 2 stack slots
  // local vars:
  LET result;  // (0000021602C76790) local[1], never assigned, hole initialization elided
  VAR x;  // (0000021602C76558) parameter[0], never assigned
  VAR y;  // (0000021602C76600) parameter[1], never assigned
  CONST num;  // (0000021602C766A8) local[0], never assigned, hole initialization elided
}
RESULT: 150
复制代码

再来看一下关闭惰性编译以后,编译阶段的作用域情况:

# 关闭惰性编译
λ v8-debug --print-scopes --no-lazy test.js
Global scope:
global { // (0000027D3AF9DF88) (0, 129)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0000027D3AF9EB78) local[0]
  // local vars:
  VAR add;  // (0000027D3AF9E9B8)
  // dynamic vars:
  DYNAMIC_GLOBAL console;  // (0000027D3AF9EC38) never assigned

  function add (x, y) { // (0000027D3AF9E1A0) (12, 102)
    // will be compiled
    // 2 stack slots
    // local vars:
    CONST num;  // (0000027D3AF9E568) local[0], never assigned, hole initialization elided
    VAR y;  // (0000027D3AF9E4C0) parameter[1], never assigned
    LET result;  // (0000027D3AF9E650) local[1], never assigned, hole initialization elided
    VAR x;  // (0000027D3AF9E418) parameter[0], never assigned
  }
}
RESULT: 150
复制代码

我们可以明显看到,关闭惰性编译以后,作用域输出变 “短” 了。但是经过前面的分析,我们已经可以认识到,这并不是说关闭惰性编译后,加载的资源少了,而是编译阶段变少了。

开启惰性编译时,全局环境,add 函数,一共需要编译两次次。自然有两次输出,而关闭了以后,只编译一次,这一次把所有的环境的作用域全部描述完了。

我们应该清楚的是,作用域没有变化,而是编译次数,编译阶段变了

至此,我们能够看出,想要发挥 v8-debug 这个工具的作用,还是需要我们对 JavaScript 有一定理解。同时,该工具也能作为一个辅助,帮助我们验证自己的理解。

分类:
前端
标签:
分类:
前端
标签: