什么是 V8
V8 是 JavaScript 虚拟机的一种,可以简单的把它理解成一个翻译程序:
把人类能理解的 编程语言 JavaScript 翻译成机器能够理解的 机器语言
现在市场上很多 JavaScript 引擎,比如 SpiderMonkey,V8,JavaScriptCore。
谷歌开发的 V8 是当下应用最广泛的 JavaScript 虚拟机
V8 出现之前,所有的虚拟机用的都是解释执行的方式,这是 js 运行慢的一个原因
V8 率先引入了即时编译 JIT 的双轮驱动设计,这是一种权衡策略,混合编译执行和解释执行两种方法,给 js 的执行效率提高了很多
V8 的出现给 js 虚拟机技术推向新的高度
单纯使用 js 调用 web api,但是不了解虚拟机内部是如何工作的,那么遇到很多问题可能得不到解决
比如项目占用内存高,页面响应速度慢,nodejs 执行任务被阻塞问题,都和 V8 的运行机制有关系,如果我们熟悉 V8 的工作机制,就能系统性的解决这些问题
V8 的主要功能,结合 js 的语言特性和本质执行编译它,通过学习 V8,也会对 js 语言的本质和设计思想有比较深的感觉。这些设计思想是更加级别的工具,掌握了它,提升了语言使用和架构的能力
怎么学习 V8
V8 主要是执行 js 代码,那我们先要了解 js 的基本特性和设计思想
js 借鉴了很多优秀语言的特性,比如
- C 语言的基本语法
- Java 的类型系统和内存管理
- Scheme 函数作为一等公民 first class function
- Self 基于原型 prototype 的继承机制
不过 js 也存在很多问题,历史原因很多设计不合理的地方还保留着。我们后面会慢慢认识到
V8 是 js 的实现,在学习 V8 的时候,格外注意 js 这些独特设计思想背后的实现
比如为了实现一等公民,采取了基于对象的策略
为了实现原型链,V8 给每个对象增加了 __proto__
属性
深入分析 js 后,我们可以学习 v8 执行 js 的整体流程了,叫做 v8 的编译流水线
流水线看上去不复杂,但是涉及到了 JIT,延迟解析,隐藏类,内联缓存等等,这些影响着 js 代码是否可以正常运行,以及它的效率
比如 v8 使用 隐藏类 hide class,这是把 js 的动态类型转化成静态类型的技术,消除动态类型执行过慢的问题。如果我们熟悉 v8 的工作机制,可以在编写 js 的时候,充分利用好隐藏类这种强大的特别来编写更加高效的代码
v8 实现了 js 的惰性解析,为了加速代码的启动速度,通过对惰性解析工作机制的学习,优化代码来更加适应,提高程序的效率
为了充分理解 v8 是怎么运行的,我们还要了解另外两个特性:事件循环系统和垃圾回收机制
事件循环系统和 js 的异步编程密切相关,js 是单线程的,所有 js 代码都在一个线程上执行,同时发送多个 js 执行请求,就需要排队,进行异步编程
v8 的事件循环系统会调度这些排队任务,保证 js 代码被 v8 有序的执行,事件循环系统是 v8 的心脏,驱动了 v8 的持续工作
js 是自动回收垃圾的语言,执行回收垃圾的时候会占用主线程的资源,如果我们频繁的触发垃圾回收,无疑会阻塞主线程。我们需要直到 v8 是怎么分配内存的,数据如何被回收的,打通这个链路,建立完整的系统。如果下次还出现内存的问题,就知道如何排查了
上面是 v8 学习的路径
- 先从 js 的设计思想开始,讨论主要的特性,以及 v8 是怎么实现的
- 然后分析 v8 的编译流水线,穿插内存分配的知识,因为函数变量,变量声明,参数传递或者参数的返回值都涉及到了内存分配
- 介绍事件循环系统和垃圾回收机制
v8 是如何执行一段 js 代码的
什么是 v8
v8 是 谷歌开源的 js 引擎,目前用在 chrome 浏览器和 nodejs 中,功能是执行易于人类理解的 js 语言
v8 是如何执行 js 代码的呢
分为编译和执行两个阶段
首先把 js 转成低级中间代码或者机器能够理解的机器码,在执行转换后的代码和输出结果
可以把它看作虚拟出来的计算机,也叫做虚拟机。虚拟机通过模拟实际计算机的各种功能来实现代码的运行
比如模拟实际计算机的 CPU,堆栈,寄存器等,虚拟机还有自己一套指令系统
对于 js 来说,v8 是它的整个世界。v8 执行 js 的时候不需要当心操作系统的不同,不必担心系统架构不同的计算机,只需要按照虚拟机的规范写代码就可以来
我们看看为什么要对 js 高级语言进行编译,以及看看编译后是怎么运行的
高效代码为什么需要先编译再执行
我们先从 CPU 是如何执行机器代码说起。
CPU 可以看作一个很小的运算机器,通过二进制的指令和 CPU 进行通信,比如我给 CPU 发出了 1000100111011000 的二进制指令,告诉寄存器把数据移动到另外一个寄存器,处理器执行这条指令的时候,按照指令的意思去实现相关操作
为了完成复杂的工作工程师给 CPU 提供了一堆的指令,实现各种功能,这些就叫做指令集,也叫做机器语言
CPU 只能识别二进制语言,对于程序员来说二进制语言难以阅读理解。我们把二进制语言指令集转成人类可以识别和记忆的符号,就叫做汇编指令集
CPU 能识别汇编语言吗?不能
所以我们写汇编语言的话,还需要一个汇编编译器。把汇编代码编程为机器码
虽然汇编语言做了一层抽象,但是依然繁琐复杂。可能一个简单的功能都需要编写很多的汇编语言。主要原因如下:
-
不同的 CPU 有不同的指令集,只要是机器语言或者汇编语言实现功能,需要为每个结构的 CPU 编写特定的汇编语言。非常繁琐枯燥
-
编写硬件的时候还需要直到处理器架构相关的硬件知识,需要使用寄存器,内存,操作 CPU 等等。程序员一般只想处理业务逻辑,不想过多的了解处理器架构相关的细节
所以我们需要一个能应用不同 CPU 架构的语言,专心处理业务逻辑。比如 c c++ java js 等等,这些高级语言应运而生
处理器也不能直接识别 js 代码。需要两种方式执行
-
解释执行 把输入的源代码通过解释器编译成中间代码,解释器执行代码输出结果
-
编译执行 源代码转成中间代码,再把中间代码转成机器码,通常编译出来的机器代码是二进制文件存放,需要执行的时候直接打开二进制代码文件就好了。还可以用虚拟机把编译后的机器代码放到内存中,直接在内存执行
V8 是怎么执行 js 代码的
v8 混合 编译执行和解释执行,把这种混合技术叫做 JIT
这是一种权衡的策略,因为它们各有优缺点
解释执行启动快,但是执行慢
编译执行启动慢,但是执行快
最左边 v8 启动的时候需要准备一些基础环境,包括了堆空间,栈空间,全局执行上下文,全局作用域,消息循环系统,内置函数等等
- 全局执行上下文 包括了执行过程中的全部信息,比如内置函数,全局变量
- 全局作用域包含一些全局变量,执行过程中都要放到内存
- v8 采用经典的栈堆管理内存模式,所以需要初始化内存中的堆和栈
- v8 系统活起来还需要初始化消息循环系统,包括了消息驱动器和消息队列,不断接受消息并决定怎么去处理
v8 之后开始接收代码,不过对于 v8 来说他们只是一段字符串,需要结构化它。结构化指的是信息经过处理以后分解成相互关联的组成部分,各个部门有明确的层次结构,方便使用和维护
v8 源代码结构化后,便形成了 ast 抽象语法树,它是 v8 理解的数据结构
生成 ast 的同时会生成对应的作用域,作用域中存放着变量
有了 ast 和作用域就可以去生成字节码了,它是介于源码和机器码之间的代码,与特定的机器类型无关,解释器可以直接运行字节码,或者通过编译器把它编译成二进制在进行执行
生成了字节码,解释器就登场了,按照顺序执行字节码,输出结果
而在执行字节码的同时,如果发现有重复执行的代码,标为热点代码
热点代码会被扔给优化编译器,优化编译器会把它转成二进制,然后再对编译后的二进制进行优化,二进制代码的执行效率会非常高
下次在执行这段代码的时候,v8 会选择二进制代码进行运行
不过 js 是非常灵活的代码,对象的结构和属性都可以随时变更,优化编译器只能对固定的结构进行优化,当对象的结构发生变化了优化代码就变成了无效代码,所以优化编译器也需要去进行反优化操作,下次执行的时候就会回退到解释器进行运行
跟踪一段实际代码的运行
1
var test = 'cody';
首先被解析结构化为 ast,我们看下这里的 ast 长什么样子
要看中间生成的结构可以用 v8 提供的 d8 工具查看,我们执行下
1
d8 --print-ast test.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VARIABLE (0x7ff0e3022298) (mode = VAR, assigned = true) "test"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 11
. . . INIT at 11
. . . . VAR PROXY unallocated (0x7ff0e3022298) (mode = VAR, assigned = true) "test"
. . . . LITERAL "GeekTime"
它是 js 源代码结构化的表示,ast 其实是个属性结构,直观理解可以转成一棵树
ast 和代码结构也是一一对应的,后续的操作都会直接或者间接的基于它来实现
生成 ast 的同时生成了作用域,
1
d8 --print-scopes test.js
我们看看变成了下面的样子
1
2
3
4
5
6
7
8
9
Global scope:
global { // (0x7fd974022048) (0, 24)
// will be compiled
// 1 stack slots
// temporary vars:
TEMPORARY .result; // (0x7fd9740223c8) local[0]
// local vars:
VAR test; // (0x7fd974022298)
}
上面是一个全局的作用域,test 被加入到了作用域空间
有了作用域和 ast 就可以解释器生成字节码了
同样用 v8 打印看看
1
d8 --print-bytecode test.js
得到了下面的结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[generated bytecode for function: (0x2b510824fd55 <SharedFunctionInfo>)]
Parameter count 1
Register count 4
Frame size 32
0x2b510824fdd2 @ 0 : a7 StackCheck
0x2b510824fdd3 @ 1 : 12 00 LdaConstant [0]
0x2b510824fdd5 @ 3 : 26 fa Star r1
0x2b510824fdd7 @ 5 : 0b LdaZero
0x2b510824fdd8 @ 6 : 26 f9 Star r2
0x2b510824fdda @ 8 : 27 fe f8 Mov <closure>, r3
0x2b510824fddd @ 11 : 61 32 01 fa 03 CallRuntime [DeclareGlobals], r1-r3
0x2b510824fde2 @ 16 : 12 01 LdaConstant [1]
0x2b510824fde4 @ 18 : 15 02 02 StaGlobal [2], [2]
0x2b510824fde7 @ 21 : 0d LdaUndefined
0x2b510824fde8 @ 22 : ab Return
Constant pool (size = 3)
0x2b510824fd9d: [FixedArray] in OldSpace
- map: 0x2b51080404b1 <Map>
- length: 3
0: 0x2b510824fd7d <FixedArray[4]>
1: 0x2b510824fd1d <String[#8]: GeekTime>
2: 0x2b51081c8549 <String[#4]: test>
Handler Table (size = 0)
Source Position Table (size = 0)
解释器开始执行这段代码,如果有重复的标记为热点代码,交给编译器优化
我们可以通过这样去查看哪些被优化了
1
d8 --trace-opt test.js
如果要查看哪些代码被反复优化,使用
1
pt --trace-deopt test.js
不过上面的代码太简单了没有触发优化
总结
- v8 是 chrome 开发的 js 引擎,也是虚拟机,模拟计算机各种功能来实现代码的编译和执行
- 由于计算机只认得二进制,所以执行高级语言的时候第一种方法是转成二进制的机器码再让计算机执行,第二种方法是安装解释器,由解释器来解释执行
- 解释器启动快,执行慢;编译器启动慢,执行快,v8 在启动时使用解释器执行,但是某个代码频繁执行的话采用优化编译器把它编译成更加高效的机器代码
整个流程是:
- 初始化环境
- 解释代码生成 ast 和作用域
- 根据 ast 和作用域生成字节码
- 解释执行字节码
- 监听热点代码
- 优化热点代码为二进制机器语言
- 反优化生成的二进制机器语言
js 是一门动态语言,有些被优化的代码可能被 js 调整了结构,导致了之前的优化失效了,那么编译器会采取反优化的操作