js编译原理
1.首先明白几个基础概念
高级语言和低级语言 汇编语言 机器语言
-
机器语言:机器由 0 和 1 组成的二进制码,对于人类来说是很难记忆的,还要考虑不同 CPU 平台的兼容性。
-
汇编语言:用更容易记忆的英文缩写标识符代替二进制指令,但还是需要开发人员有足够的硬件知识。
-
高级语言:更简单抽象且不需要考虑硬件,但是需要更复杂、耗时更久的翻译过程才能被执行。
2.高级语言中的解释性语言和编译性语言
- 解释性语言: 不需要事先编译,可以由解释器一边解释一边执行,启动较快,但是执行比较慢
- 编译性语言:编译器一次编译完成,编译后文件可以多次执行,如c c++
javaScript的语言是解释性语言。
我们知道 JavaScript 是一门高级语言,并且是动态类型语言,我们在定义一个变量时不需要关心它的类型,并且可以随意的修改变量的类型。
而在像 C++这样的静态类型语言中,我们必须提前声明变量的类型并且赋予正确的值才行。也正是因为 JavaScript 没有像 C++那样可以事先提供足够的信息供编译器编译出更加低级的机器代码,它只能在运行阶段收集类型信息,然后根据这些信息进行编译再执行,所以 JavaScript 也是解释型语言。
所以 javaScript想要被执行就需要快速解析并且执行 JavaScript 脚本的程序,就是我们常说的JavaScript引擎也就是V8引擎。这里我们给出 V8 引擎的概念:
V8 是 Google 基于 C++ 编写的开源高性能 Javascript 与 WebAssembly 引擎。用于 Google Chrome(Google 的开源浏览器) 以及 Node.js 等。
3.cpu 是如何运行机器指令的
那高级语言被编译成机器语言后是如何在cpu中被执行的呢?
3.1 首先看被编译成机器语言是什么样子
int main()
{
int x = 1;
int y = 2;
int z = x + y;
return z;
}}
先来看一下以上代码被转换为机器语言是什么样子。下图左侧是用十六进制表示的二进制机器码,中间部分是汇编代码,右侧是指令的含义。
3.2 其次cpu执行机器语言的流程
代码被编译成机器语言后是如何被CPU执行的 1.首先把程序装进内存中
2.系统会把二进制代码中第一条指令写入pc寄存器中
3.CPU根据pc寄存器中的地址,从内存中取出指令
4.将下一条指令的地址更新道pc寄存器中
5.分析当前取出指令,并识别出不同的类型的指令,以及各种获取操作数的方法。
6.加载指令:从内存中复制指定长度的内容到通用寄存器中,并覆盖寄存器中原来的内容。
存储指令:将寄存器中的内容复制到内存某个位置,并覆盖掉内存中的这个位置上原来的内容。
上图中 movl 指令后面的 %ecx 就是寄存器地址,-8(%rbp) 是内存中的地址,这条指令的作用是将寄存器中的值拷贝到内存中
7.复制两个寄存器中的内容到 ALU 中,也可以是一块寄存器和一块内存中的内容到 ALU 中,ALU 将两个字相加,并将结果存放在其中的一个寄存器中,并覆盖该寄存器中的内容。
8.行指令完毕,进入下一个 CPU 时钟周期
4.V8 的编译大致分为几个流程
我们已经对机器语言如何被CPU执行有个大致认知,接下来我们应该了解js是如何被编译的
4.1编译的流水线大致分为多少部分
1.初始化基本环境(运行环境)
2.解析源码生成AST语法树 和作用域
3.根据作用域和AST语法树生成字节码
4.解释执行字节码,然后监听热点的字节码
4.2完整的代码是如何编译的
4.2.1初始化基础环境
V8 执行 Js 代码是离不开宿主环境的,V8 的宿主可以是浏览器,也可以是 Node.js。下图是浏览器的组成结构,其中渲染引擎就是平时所说的浏览器内核,它包括网络模块,Js 解释器等。当打开一个渲染进程时,就为 V8 初始化了一个运行时环境。
空间、全局执行上下文、消息循环系统、宿主对象及宿主 API 等。V8 的核心是实现了 ECMAScript 标准,此外还提供了垃圾回收器等内容。
4.2.2解析源码生成AST语法树和作用域(parser)
基础环境准备好后,V8会接收到要执行的代码,但是这个对于V8来说就是字符串,V8需要把字符串 结构化一下转为AST语法树(这个过程成为解析parser)
function add(x, y) {
var z = x+y
return z
}
console.log(add(1, 2))
V8 会使用解析器parser把上边代码解析成 抽象语法树AST
[generating bytecode for function: add]
--- AST ---
FUNC at 12
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "add"
. PARAMS
. . VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VAR (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. DECLS
. . VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. . VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 31
. . . INIT at 31
. . . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. . . . ADD at 32
. . . . . VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . . . . VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. RETURN at 37
. . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
除了生成AST语法树之外还生成了add函数的作用域
Global scope:
function add (x, y) { // (0x7f9ed7849468) (12, 47)
// will be compiled
// 1 stack slots
// local vars:
VAR y; // (0x7f9ed7849790) parameter[1], never assigned
VAR z; // (0x7f9ed7849838) local[0], never assigned
VAR x; // (0x7f9ed78496e8) parameter[0], never assigned
}
在解析期间,所有函数体中声明的变量和函数参数,都被放进作用域中,如果是普通变量,那么默认值是 undefined,如果是函数声明,那么将指向实际的函数对象。在执行阶段,作用域中的变量会指向堆和栈中相应的数据。
(在这里还可以有解析的过程 和 函数声明和提升 变量声明, 堆栈的创立和指向)
4.2.3根据AST语法树和作用域生成字节码(Ignation)
生成了作用域和 AST 之后,V8 就可以依据它们来生成字节码了。AST 之后会被作为输入传到字节码生成器 (BytecodeGenerator),这是 Ignition 解释器中的一部分,用于生成以函数为单位的字节码。
4.2.4解释执行字节码
和 CPU 执行二进制机器代码类似:使用内存中的一块区域来存放字节码;使通用寄存器用来存放一些中间数据;PC 寄存器用来指向下一条要执行的字节码;栈顶寄存器用来指向当前的栈顶的位置。
1.StackCheck 字节码指令就是检查栈是否达到了溢出的上限。 2.Ldar 表示将寄存器中的值加载到累加器中。 3.Add 表示寄存器加载值并将其与累加器中的值相加,然后将结果再次放入累加器 4.Star 表示 把累加器中的值保存到某个寄存器中。 5.Return 结束当前函数的执行,并将控制权传回给调用方。返回的值是累加器中的值。 4.2.5即时编译(JIT) 热点代码(hotspot):一个代码重复执行多次 在解释器 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。这种字节码配合解释器和编译器的技术被称为即时编译(JIT)。
5.V8的优化策略
js为了提升解析和执行的js 的速度做了很多优化主要如下 1.重新引入字节码 2.延迟解析 3.隐藏类 4.快属性和慢属性 5.内联缓存
5.1重新引入字节码
早期的 V8 团队认为先生成字节码再执行字节码的方式会降低代码的执行效率,于是直接将 JavaScript 代码编译成机器代码。这样做带来的问题有两点,一是需要较长的编译时间,二是产生的二进制机器码需要占用较大的内存空间。
使用字节码虽然浪费了一点执行效率,但是节省了内存空间,并且缩短了编译的时间。 使用字节码也降低了V8 的复杂度,可以使 V8 移植到不同的CPU的平台更加容易,这是因为统一将字节码转换为不同平台的二进制代码要比编译器编写不同 CPU 体系的二进制代码更加容易。
5.2延时解析
延迟解析:是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码。
V8 执行 JavaScript 代码需要经过编译和执行两个阶段。 编译过程: V8将js转为字节码或者二进制机器码的阶段 执行阶段: 解释器执行字节码或者CPU直接执行二进制机器码的阶段
V8 并不会一次性将所有的 JavaScript 解析为中间代码,这主要是基于以下两点: 1.如果一次性执行所有的js代码,过多的代码会增加编译的时间,严重影响到js首次执行的时间,使用户感觉到卡顿 2.编译后的字节码或者二进制机器码会一直存放在内存当中,执行所有的代码,这字节码和二进制码一直占用内存。
5.3隐藏类
先看下隐藏类的工作原理 let point = {x:100,y:200} 当 V8 执行到这段代码时,会先为 point 对象创建一个隐藏类,在 V8 中,把隐藏类又称为 map,每个对象都有一个 map 属性,其值指向内存中的隐藏类。隐藏类描述了对象的属性布局,它主要包括了属性名称和每个属性所对应的偏移量,比如 point 对象的隐藏类就包括了 x 和 y 属性,x 的偏移量是 4,y 的偏移量是 8。
有了隐藏类之后,那么当 V8 访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,有了偏移量和属性类型,V8 就可以直接去内存中取出对应的属性值,而不需要经历一系列的查找过程,那么这就大大提升了 V8 查找对象的效率。
隐藏类: 其实就是将对象有个map属性,这个属性可以将属性名对应的偏移值保存下来,当获取这个属性可以直接取出这个值,而不用一系列查找。
5.4快属性和慢属性
5.4.1首先明白对象中的属性排列方式
在 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。 数字属性称为排序属性,在 V8 中被称为 elements。 字符串属性就被称为常规属性,在 V8 中被称为 properties。
function Foo() {
this[100] = 'test-100'
this[1] = 'test-1'
this["B"] = 'bar-B'
this[50] = 'test-50'
this[9] = 'test-9'
this[8] = 'test-8'
this[3] = 'test-3'
this[5] = 'test-5'
this["A"] = 'bar-A'
this["C"] = 'bar-C'
}
var bar = new Foo()
for(key in bar){
console.log(`index:${key} value:${bar[key]}`)
}
打印出来的结果如下:
index:1 value:test-1
index:3 value:test-3
index:5 value:test-5
index:8 value:test-8
index:9 value:test-9
index:50 value:test-50
index:100 value:test-100
index:B value:bar-B
index:A value:bar-A
index:C value:bar-C
5.4.2 其次明白对象中属性数量发生变化,内存结构的变化
首先给出结论 当对象中的属性过多时,或者存在反复添加或者删除属性的操作,那么 V8 就会将线性的存储模式(快属性)降级为非线性的字典存储模式(慢属性),这样虽然降低了查找速度,但是却提升了修改对象的属性的速度。
function Foo(property_num,element_num) {
//添加排序属性
for (let i = 0; i < element_num; i++) {
this[i] = `element${i}`
}
//添加常规属性
for (let i = 0; i < property_num; i++) {
let ppt = `property${i}`
this[ppt] = ppt
}
}
var bar = new Foo(10,10)
将 Chrome 开发者工具切换到 Memory 标签,然后点击左侧的小圆圈就可以捕获以上代码的内存快照,最终截图如下所示: 将创建的对象属性的个数调整到 20 个
5.5延时解析
在看下这个代码
function loadX(o) {
o.y = 4
return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3 ,y:6}
for (var i = 0; i < 90000; i++) {
loadX(o)
loadX(o1)
}
通常 V8 获取 o.x 的流程是这样的:查找对象 o 的隐藏类,再通过隐藏类查找 x 属性偏移量,然后根据偏移量获取属性值,在这段代码中 loadX 函数会被反复执行,那么获取 o.x 流程也需要反复被执行。为了提升对象的查找效率。V8 执行的策略就是使用内联缓存 (Inline Cache),简称为 IC。
IC 会为每个函数维护一个反馈向量 (FeedBack Vector),反馈向量记录了函数在执行过程中的一些关键的中间数据。然后将这些数据缓存起来,当下次再次执行该函数时,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程。
V8 会在反馈向量中为每个调用点分配一个插槽(Slot),比如 o.y = 4 和 return o.x 这两段就是调用点 (CallSite),因为它们使用了对象和属性。每个插槽中包括了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、隐藏类 (map) 的地址、还有属性的偏移量,比如上面这个函数中的两个调用点都使用了对象 o,那么反馈向量两个插槽中的 map 属性也都是指向同一个隐藏类的,因此这两个插槽的 map 地址是一样的。
通过内联缓存策略,就能够提升下次执行函数时的效率,但是这有一个前提,那就是多次执行时,对象的形状是固定的,如果对象的形状不是固定的,这意味着 V8 为它们创建的隐藏类也是不同的。面对这种情况,V8 会选择将新的隐藏类也记录在反馈向量中,同时记录属性值的偏移量,这时,反馈向量中的一个槽里就会出现包含了多个隐藏类和偏移量的情况,如果超过 4 个,那么 V8 会采取 hash 表的结构来存储。
6. 所以这里总结一个回答的流程
什么是V8?
V8是一个JS引擎,由谷歌开发,用于谷歌浏览器及node.js。我们可以简单地把V8理解成一个翻译程序,即将JS代码翻译成机器能够理解地机器语言。
V8使用了即时编译(JIT)的双轮驱动的设计,这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。
我们都知道机器是无法直接理解如JS、C++等语言的代码的,需要进行编译?
我们先从 CPU 是怎么执行机器代码讲起,你可以把 CPU 看成是一个非常小的运算机器,我们可以通过二进制的指令和 CPU 进行沟通,比如我们给 CPU 发出“1000100111011000”的二进制指令,这条指令的意思是将一个寄存器中的数据移动到另外一个寄存器中,当处理器执行到这条指令的时候,便会按照指令的意思去实现相关的操作。为了能够完成复杂的任务,工程师们为 CPU 提供了一大堆指令,来实现各种功能,我们就把这一大堆指令称为指令集(Instructions),也就是机器语言。
注意,CPU 只能识别二进制的指令,但是对程序员来说,二进制代码难以阅读和记忆,于是我们又将二进制指令集转换为人类可以识别和记忆的符号,这就是汇编指令集,你可以参考下面的代码:
1000100111011000 机器指令
mov ax,bx 汇编指令
复制代码
CPU 能直接识别汇编语言吗?
“不能识别”,所以如果你使用汇编编写了一段程序,你还需要一个汇编编译器,其作用是将汇编代码编程成机器代码,具体流程可以参考下图:
虽然汇编语言对机器语言做了一层抽象,减少了程序员理解机器语言的复杂度,但是汇编语言依然是复杂且繁琐的,即便你写一个非常简单的功能,也需要实现大量的汇编代码,这主要表现在以下两点。首先,不同的CPU 有着不同的指令集,如果要使用机器语言或者汇编语言来实现一个功能,那么你需要为每种架构的 CPU 编写特定的汇编代码,这会带来巨大的、枯燥繁琐的操作,可以参看下图
和汇编语言一样,处理器也不能直接识别由高级语言所编写的代码,那怎么办?通常,有两种方式来执行这些代码。
第一种是解释执行,需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果。具体流程如下图所示:
第二种是编译执行。采用这种方式时,也需要先将源代码转换为中间代码,然后我们的编译器再将中间代码编译成机器代码。通常编译成的机器代码是以二进制文件形式存储的,需要执行这段程序的时候直接执行二进制文件就可以了。还可以使用虚拟机将编译后的机器代码保存在内存中,然后直接执行内存中的二进制代码。
以上就是基本的两种执行方式,解释执行和编译执行。对于不同的语言有着不同的处理方式。
V8 是怎么执行 JavaScript 代码的?
V8 并没有采用某种单一的技术,而是混合编译执行和解释执行这两种手段,我们把这种混合使用编译器和解释器的技术称为 JIT(Just In Time)技术。
这是一种权衡策略,因为这两种方法都各自有自的优缺点,解释执行的启动速度快,但是执行时的速度慢,而编译执行的启动速度慢,但是执行时的速度快。你可以参看下面完整的 V8 执行 JavaScript 的流程图:
重点来了
在 V8 启动执行 JavaScript 之前,它还需要准备执行 JavaScript 时所需要的一些基础环境,这些基础环境包括了“堆空间”“栈空间”“全局执行上下文”“全局作用域”“消息循环系统”“内置函数”等,这些内容都是在执行 JavaScript 过程中需要使用到的。
基础环境准备好之后,首先V8会接收到JS源代码,不过这对 V8 来说只是一堆字符串,V8 并不能直接理解这段字符串的含义,它需要结构化这段字符串。结构化,是指信息经过分析后可分解成多个互相关联的组成部分,各组成部分间有明确的层次结构,方便使用和维护,并有一定的操作规范。
V8 源代码的结构化之后,就生成了抽象语法树 (AST),我们称为 AST,AST 是便于 V8 理解的结构。
在生成 AST 的同时,V8 还会生成相关的作用域,作用域中存放相关变量。
有了 AST 和作用域之后,接下来就可以生成字节码了,字节码是介于 AST 和机器代码的中间代码。但是与特定类型的机器代码无关,解释器可以直接解释执行字节码,或者通过编译器将其编译为二进制的机器代码再执行。
生成了字节码之后,解释器会按照顺序解释执行字节码,并输出执行结果。
在解释执行字节码的过程中,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为热点代码。
当某段代码被标记为热点代码后,V8 就会将这段字节码丢给优化编译器,优化编译器会在后台将字节码编译为二进制代码,然后再对编译后的二进制代码执行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升。如果下面再执行到这段代码时,那么 V8 会优先选择优化之后的二进制代码,这样代码的执行速度就会大幅提升。
不过,和静态语言不同的是,JavaScript 是一种非常灵活的动态语言,对象的结构和属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。
总结
V8 执行一段 JavaScript 代码所经历的主要流程包括了:
-
初始化基础环境。
-
生成AST(抽象语法树)和作用域(用于存放变量)。
-
根据AST和作用域生成字节码(即中间代码)
-
解释执行字节码。
-
监听热点代码
-
将热点代码进行标记使用编译器编译成二进制的机器代码并保存起来。
-
反优化生成的二进制机器代码。
7.补充知识
- 在程序中编译是什么 2.编译器主要做了什么是如何处理 AST的 3.转译器主要做了什么是如何处理AST的 4.解释器主要做了什么 是如何处理AST的