最近在温习 JavaScript 反混同相干内容,正好给执行原理补个档,之前旧版的随旧博客一起遗失了,这里从新写了一篇。
预计之后还会有 JavaScript 执行原理·补 和 JavaScript 混同 / 反混同,缓缓写吧。
JavaScript
JavaScript 是一门解释型语言,与 C/Golang 等动态编译语言不同。动态编译型语言通过编译器间接将代码转化为机器码,而后运行在机器上;JS 是先通过编译产生字节码,而后在虚拟机上运行字节码(这点与 Java&JVM 很类似),性能虽不迭动态编译型语言,但取得了更多的灵活性。
前置常识
JS Engine 引擎
为什么须要 JS Engine🤔️?
JS Engine 其实就能够了解为上文中所说的虚拟机。机器底层的 CPU 只能执行指令集中的指令(即对应的汇编代码),无奈间接辨认高级语言。JS Engine 能够将 JS 代码编译为字节码,而后执行代码,同时还提供了分配内存和垃圾回收的性能,极大水平上加重了开发人员的工作量,何乐而不为。
从实质上来讲,JS Engine 就是一段程序,用于实现上述性能。
互联网中最常见、应用最宽泛的 JS Engine 是 Google V8。Google V8 是用 C++ 编写的开源高性能 JS Engine(同时也是 WebAssembly Engine),目前已被用于 Chrome 浏览器、Node.js、MongoDB 等多个出名我的项目。Chrome 占据了寰球浏览器市场 60% 的份额,而 Node.js 未然成为服务器端 JS 代码的执行规范,由此可见 V8 应用之宽泛。
除此之外,还有一些常见的 JS Engine:
- 由 Mozilla 为 Firefox 开发的 SpiderMonkey
- 为 Safari 浏览器提供反对的 JavaScriptCore
- 为 IE 提供反对的 Chakra
本篇接下来的 JS Engine 都默认为 Google V8。
JS Runtime 运行时
咱们能够把 JS Runtime 了解为一栋房子,JS 代码都须要在这栋房子中运行。而这栋房子由很多局部独特组成,包含 JS Engine、内部 API 和回调队列(callback queue)。有时也把 JS 用到的 core lib 外围库看作 Runtime 的一部分。
以 Chrome 浏览器的 JS Runtime 举例,浏览器的 Runtime 由对应的 JS Engine、Web API 和回调队列组成。JS Engine 在上文中有讲,不再赘述;Web API 是浏览器提供给 Engine 的一系列接口,并不是 JS 的一部分,目标是不便操纵数据和加强浏览器的性能,罕用的 Web API 包含 DOM、Web Worker 等;回调队列包含筹备好执行的回调函数,回调队列确保回调以先进先出(FIFO)办法执行,并在堆栈为空时将其传递到堆栈中。
与浏览器 Runtime 不同的是,Node.js 没有 Web API,而是有叫作 C++ 绑定和线程池的其余局部。
咱们能够这样说,Chrome 和 Node.js 中的 JS 代码都依赖于 V8 运行,但它们运行在不同的 Runtime 中。
在开始之前,咱们要晓得,V8 是一个非常复杂的我的项目,有超过 100w 行代码。咱们能够依据性能将 V8 Engine 分为不同的子模块,其中最重要的四个子模块别离是:
- Parser:解析器,将 JS 源码转换为形象语法树(AST);
- Ignition:解释器,将 AST 转换为字节码(Bytecode),而后解释执行 Bytecode,同时收集 TurboFan 优化编译所需的信息,比方函数参数的类型;
- TurboFan:编译器,利用 Ignitio 所收集的类型信息,将 Bytecode 转换为优化后的汇编代码;
- Orinoco:垃圾回收模块,负责将程序不再须要的内存空间回收;
前三个子模块能够代表 JS 运行的次要三个阶段:解析、解释、编译,最初运行。接下来,我会以 V8 Engine 为例,讲讲 JS 代码到底是如何从编写到运行的。
Parser 解析
Parser 的性能是将 JS 源码转化为 AST。更确切地说,源码是先通过词法剖析转化为 Token,而后 Toekn 通过语法分析转换为 AST。
Lexing 词法剖析
词法剖析 这个过程会将由字符组成的字符串分解成代码块,这些代码块被称为“词法单元”(Token)。从数据方面来说,这一步是字符流(char stream)转换为标记流(token stream)。
咱们编写一个 main.js
文件,写入以下内容:
const a = 3;
以这段代码为例,会被分为 const
、a
、=
、3
、;
词法单元。space
是否被当作词法单元,取决于其在以后编程语言中是否有意义,JS 中的空格是无意义,不会被当作词法单元。
以下为通过词法剖析的 JSON 格局的 Tokens:
[
{
"type": "Keyword",
"value": "const"
},
{
"type": "Identifier",
"value": "a"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "Numeric",
"value": "3"
},
{
"type": "Punctuator",
"value": ";"
}
]
分词(Tokenizing)和词法剖析(Lexing)的区别在于:词法单元的辨认是通过有状态还是无状态的形式进行的,无状态的为分词。这里“状态”的有无则是依据数据的处理过程是否须要思考其余相干信息决定的。
在 JS 解析的过程中,咱们须要思考以后词法单元与后面的词法单元之间的关系,因而,JS 的解析是一个有状态的过程,咱们就会将字符流转换为 token 的这一过程称为 Lexing 而非 Tokenizing。
Parsing 语法分析
语法分析 是将标记流转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,咱们称其为形象语法树(AST)。
上文中的 Tokens 通过语法分析失去的 JSON 格局的 AST 如下:
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 3,
"raw": "3"
}
}
],
"kind": "const"
}
],
"sourceType": "script"
}
解析的过程就这么多,最终成果就是失去源码对应的 AST。
Esprima 站点提供了在线解析 JS 代码的性能,输出 JS 代码就能够失去通过 Lexing 的 Tokens 和 通过 Parsing 的 AST,能够入手尝试一下。
除此之外,AST explorer 站点也能够在线将 JS 代码转化为 AST。这个网站是基于 acorn.js 实现的,acorn.js 是一个 JavaScript Parser,你能够通过 npm
等包管理器下载并应用它。以下是一段应用 acorn.js 将指定代码转化为 AST 的案例。指定解析 console.log(1);
并以 JSON 格局输入:
const {Parser} = require('acorn')
const javascriptCode = `
console.log(1);
`;
const ast = Parser.parse(javascriptCode, { ecmaVersion: 2024});
console.log(JSON.stringify(ast));
Ignition 解释
Ignition 的次要性能是负责将 AST 转换为字节码(Bytecode)并解释执行。
Bytecode 是一种蕴含执行程序、由一序列 op 代码 / 数据对组成的二进制文件,是一种两头码(IR),是机器码的一种形象。它不面向任何机器,只面向以后虚拟机(这里是 V8 引擎)。
咱们常说的字节码多是指 Java 字节码,实际上很多动静编译解释的语言都有字节码,比方 Python/JavaScript/Ruby。
晚期的 V8 间接将 AST 编译为机器码,而后在机器中执行,因为这样的效率更高。但机器码会占用过多的零碎内存,相比之下,Bytecode 占用的内存空间远比机器码少得多,Google 团队遂抉择以工夫换空间,转换为当初的 AST->Bytecode-> 机器码。
Node.js 提供了很多对于 V8 引擎的选项,比方 --print-bytecode
用于获取源码对应的字节码。
先编写这样一段 JS 代码并保留为 main.js 文件:
function ma5hr00m() {console.log(1);
}
ma5hr00m();
而后应用 node --print-bytecode main.js
即可失去代码的 Bytecode,会输入十分十分多的内容,能够查找到 ma5hr00m()
函数对应的 Bytecode:
[generated bytecode for function: ma5hr00m (0x3ae97a693191)]
Bytecode length: 19
Parameter count 1
Register count 3
Frame size 24
Bytecode age: 0
24 S> 0x3ae97a6940ee @ 0 : 21 00 00 LdaGlobal [0], [0]
0x3ae97a6940f1 @ 3 : c3 Star1
32 E> 0x3ae97a6940f2 @ 4 : 2d f9 01 02 GetNamedProperty r1, [1], [2]
0x3ae97a6940f6 @ 8 : c4 Star0
0x3ae97a6940f7 @ 9 : 0d 01 LdaSmi [1]
0x3ae97a6940f9 @ 11 : c2 Star2
32 E> 0x3ae97a6940fa @ 12 : 5e fa f9 f8 04 CallProperty1 r0, r1, r2, [4]
0x3ae97a6940ff @ 17 : 0e LdaUndefined
40 S> 0x3ae97a694100 @ 18 : a9 Return
Constant pool (size = 2)
0x3ae97a694099: [FixedArray] in OldSpace
- map: 0x2a087bbc0211
- length: 2
0: 0x2a087bbc5ce9
1: 0x398ad9accb91
Handler Table (size = 0)
Source Position Table (size = 10)
0x3ae97a694109
咱们能够简略剖析其中 Bytecode 字段对应的指令:
- 从全局加载一个值到寄存器中;
- 将寄存器 1 的值存储到一个地位;
- 从一个对象中获取属性并存储到寄存器 0;
- ……
这看起来很像咱们学习过的汇编语言,或者说 Bytecode 就是形象后的汇编,它仅面向虚拟机 CPU 而不是其余任何机器的 CPU,这就阐明 V8 不须要为不同的 CPU 生成不同的代码,只需生成 Bytecode,这样也在某种程度上简化了 V8 的编译流程,进步了其可拓展性。
TurboFan 编译
TurboFan 负责将 Bytecode 转换为优化后的机器码。
后面说过,Ignition 子模块曾经能够实现 AST->Bytecode 并解释执行,那为什么 V8 还须要 TurboFan 来编译 Bytecode 呢?
后面说过,JavaScript 是一门解释型的语言。解释型语言的特点是:
- 运行时逐行被解释器解释并执行;
- 每次运行程序时都须要将源代码翻译成机器代码并执行;
解释型语言的益处就是灵便且启动速度快,在 Web 利用场景下,这种策略是优于编译型语言的。但随着时代提高,Web 利用越来越宏大,解释型语言执行速度慢的毛病也逐步凸显进去。在 V8 呈现之前,所有 JS Engine 都采纳着解释执行的形式。
而 V8 率先引入了即时编译(JIT)的设计,这种设计混合了解释执行和编译执行两种策略,极大地晋升了 JS 的执行速度。之后其它 JS Engine 也是迅速跟进。
JIT 即时编译
Just-in-Time 技术是解释执行和编译执行二者之间衡量之后的产物。简略地说,V8 在启动过程中采纳解释执行的策略,如果某段代码的超过某个阈值,V8 就会把这段代码编译成执行效率更高的机器码。
不同 Engine 在实现 JIT 的伎俩上存在区别,但总体思维是统一的,就是在 Engine 外部增加一个监视器。监视器负责监督代码的运行状况,记录代码一共运行了多少次、代码如何运行等。监视器会依据代码反复次数以及运行某段代码占用的执行时长,对代码进行标记,标记共分为 warm
、hot
和very hot
三个等级。
举个例子阐明:
function arraySum(arr) {
let sum = 0;
for (var i = 0; i < arr.length; i++) {sum += arr[i];
}
}
这段代码刚开始运行时,监视器监督着所有代码的运行状况。
如果同一行代码反复执行几次,监视器会把这段代码标记为warm
。
被标记为 warm
的代码段会被 JIT 传递给 基线编译器 进行编译,并将后果贮存。warm
代码段的每一行都会被编译成一个 stub
,同时给这个stub
调配一个以“行号 + 变量类型”的索引。此时并没有应用编译的代码替换本来的代码。
如果监视器监督到,某行某个变量应用了同样的变量类型在此屡次执行同样的代码,就会将 warm
降级为hot
,并把已编译的代码 push 给浏览器,浏览器接管后就会用编译后的代码替换本来的代码。
如果 hot
代码段反复次数十分多以至于占用了大部分执行工夫,就标记为very hot
。
被标记为 very hot
的代码段会被 JIT 传递给 优化编译器 进行编译,生成一个更疾速和高效的代码版本,并且存储。例如下面这段代码,sum
和arr[i]
两个数并不保障都是整数(JS 动静类型),每次 +=
执行前都须要先判断一遍数据类型,如果这段代码反复执行了十分屡次且每次这两个变量都是 INT 型,那这段代码被丢给优化编译器进行编译时,就会被优化为优先进行 INT 类型的判断,以节约工夫。
如果上述通过优化的代码呈现问题,某次执行数据判断的后果不是 INT 型,那这段通过优化编译失去的代码就会被间接抛弃,执行过程回到解释器 / 基线解释器,这个过程被称为 去优化。
要留神,JIT 与虚拟机执行策略的抉择无关,而和语言自身无关。除了 JS,其余解释型语言也会引入 JIT 技术,以进步执行效率。
参阅文章
- JavaScript 语法解析、AST、V8、JIT
- JavaScript 深入浅出第 4 课:V8 引擎是如何工作的,by 寒雁 Talk
- WebAssembly 系列(二)JavaScript Just-in-time (JIT) 工作原理,by 知乎
- JS 中的 JIT 与根本执行逻辑,by 稀土掘金