浅探Google V8引擎
探析它之前,我们先抛出以下几个疑问:
为什么需要 V8 引擎呢?
V8 引擎到底是个啥?
它可以做些什么呢?
了解它能有什么收获呢?
接下来就针对以上几个问题进行详细描述。
由来
我们都知道,JS 是一种解释型语言,支持动态类型(明显不同于 Java 等这类静态语言就是在命名一个变量时不需要声明变量的类型)、弱类型、基于原型的语言,内置支持类型。而一般 JS 都是在前端执行(直接影响界面),需要能够快速响应用户,那么就要求语言本身可以被快速地解析和执行,JS 引擎就为此而问世。
这里提到了解释型语言和静态语言(编译型语言),先简单介绍一下二者:
解释型语言(JS)
每次运行时需要解释器按句依次解释源代码执行,将它们翻译成机器认识的机器代码再执行
编译型语言(Java)
运行时可经过编译器翻译成可执行文件,再由机器运行该可执行文件即可
从上面的描述中可以看到 JS 运行时每次都要根据源文件进行解释然后执行,而编译型的只需要编译一次,下次可直接运行其可执行文件,但是这样就会导致跨平台的兼容性很差,因此各有优劣。
而众多 JS 引擎(V8、JavaScriptCore、SpiderMonkey、Chakra等)中 V8 是最为出色的,加上它也是应用于当前最流行的谷歌浏览器,所以我们非常有必要去认识和了解一下,这样对于开发者也就更清楚 JS 在浏览器中到底是如何运行的了。
认识
定义
使用 C++ 开发
谷歌开源
编译成原生机器码(支持IA-32, x86-64, ARM, or MIPS CPUs)
使用了如内联缓存(inline caching)等方法来提高性能
运行速度快,可媲美二进制程序
支持众多操作系统,如 windows、linux、android 等
支持其他硬件架构,如 IA32,X64,ARM 等
具有很好的可移植和跨平台特性
运行
先来一张官方流程图:
准备
JS 文件加载(不归 V8 管):可能来自于网络请求、本地的cache或者是也可以是来自service worker,这是 V8 运行的前提(有源文件才有要解释执行的)。 3种加载方式 & V8的优化:
Cold load: 首次加载脚本文件时,没有任何数据缓存
Warm load:V8 分析到如果使用了相同的脚本文件,会将编译后的代码与脚本文件一起缓存到磁盘缓存中
Hot load: 当第三次加载相同的脚本文件时,V8 可以从磁盘缓存中载入脚本,并且还能拿到上次加载时编译后的代码,这样可以避免完全从头开始解析和编译脚本
而在 V8 6.6 版本的时候进一步改进代码缓存策略,简单讲就是从缓存代码依赖编译过程的模式,改变成两个过程解耦,并增加了可缓存的代码量,从而提升了解析和编译的时间,大大提升了性能,具体细节见V8 6.6 进一步改进缓存性能。
分析
此过程是将上面环节得到的 JS 代码转换为 AST(抽象语法树)。
词法分析
从左往右逐个字符地扫描源代码,通过分析,产生一个不同的标记,这里的标记称为 token,代表着源代码的最小单位,通俗讲就是将一段代码拆分成最小的不可再拆分的单元,这个过程称为词法标记,该过程的产物供下面的语法分析环节使用。
这里罗列一下词法分析器常用的 token 标记种类:
常数(整数、小数、字符、字符串等)
操作符(算术操作符、比较操作符、逻辑操作符)
分隔符(逗号、分号、括号等)
保留字
标识符(变量名、函数名、类名等)
TOKEN-TYPE TOKEN-VALUE\
-----------------------------------------------\
T_IF if\
T_WHILE while\
T_ASSIGN =\
T_GREATTHAN >\
T_GREATEQUAL >=\
T_IDENTIFIER name / numTickets / ...\
T_INTEGERCONSTANT 100 / 1 / 12 / ....\
T_STRINGCONSTANT "This is a string" / "hello" / ...
上面提到会逐个从左至右扫描代码然后分析,那么很明显就会想到两种方案,扫描完再分析(非流式处理)和边扫描边分析(流式处理),简单画一下他们的时序图就能发现流式处理效率要高得多,同时分析完也会释放分析过程中占用的内存,也能大大提高内存使用效率,可见该优化的细节处理。
语法分析
语法分析是指根据某种给定的形式文法对由单词序列构成的输入文本(例如上个阶段的词法分析产物-tokens stream),进行分析并确定其语法结构的过程,最后产出其 AST(抽象语法树)。
V8 会将语法分析的过程分为两个阶段来执行:
Pre-parser
跳过还未使用的代码
不会生成对应的 AST,会产生不带有变量的引用和声明的 scopes 信息
解析速度会是 Full-parser 的 2 倍
根据 JS 的语法规则仅抛出一些特定的错误信息
Full-parser
解析那些使用的代码
生成对应的 AST
产生具体的 scopes 信息,带有变量引用和声明等信息
抛出所有的 JS 语法错误
为什么要做两次解析?
如果仅有一次,那只能是 Full-parser,但这样的话,大量未使用的代码会消耗非常多的解析时间,结合实例来看下:通过 Coverage 录制的方式可以分析页面哪些代码没有用到,如下图可以看到最高有 75% 的没有被执行。
但是预解析并不是万能的,得失是并存的,很明显的一个场景:该文件中的代码全都执行了,那其实就是没必要的,当然这种情况其实还是占比远不如上面的例子,所以这里其实也是一种权衡,需要照顾大多数来达到综合性能的提升。
下面给出一个示例:
function add(x, y) {
if (typeof x === "number") {
return x + y;
} else {
return x + 'tadm';
}
}
复制上面的代码到 web1 和 web2 可以很直观的看到他们的 tokens 和 AST 结构(也可自行写一些代码体验)。
tokens
[
{
"type": "Keyword",
"value": "function"
},
{
"type": "Identifier",
"value": "add"
},
{
"type": "Punctuator",
"value": "("
},
{
"type": "Identifier",
"value": "x"
},
{
"type": "Punctuator",
"value": ","
},
{
"type": "Identifier",
"value": "y"
},
{
"type": "Punctuator",
"value": ")"
},
{
"type": "Punctuator",
"value": "{"
},
{
"type": "Keyword",
"value": "if"
},
{
"type": "Punctuator",
"value": "("
},
{
"type": "Keyword",
"value": "typeof"
},
{
"type": "Identifier",
"value": "x"
},
{
"type": "Punctuator",
"value": "==="
},
{
"type": "String",
"value": "\"number\""
},
{
"type": "Punctuator",
"value": ")"
},
{
"type": "Punctuator",
"value": "{"
},
{
"type": "Keyword",
"value": "return"
},
{
"type": "Identifier",
"value": "x"
},
{
"type": "Punctuator",
"value": "+"
},
{
"type": "Identifier",
"value": "y"
},
{
"type": "Punctuator",
"value": ";"
},
{
"type": "Punctuator",
"value": "}"
},
{
"type": "Keyword",
"value": "else"
},
{
"type": "Punctuator",
"value": "{"
},
{
"type": "Keyword",
"value": "return"
},
{
"type": "Identifier",
"value": "x"
},
{
"type": "Punctuator",
"value": "+"
},
{
"type": "String",
"value": "'tadm'"
},
{
"type": "Punctuator",
"value": ";"
},
{
"type": "Punctuator",
"value": "}"
},
{
"type": "Punctuator",
"value": "}"
}
]
AST
{
"type": "Program",
"body": [
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "add"
},
"params": [
{
"type": "Identifier",
"name": "x"
},
{
"type": "Identifier",
"name": "y"
}
],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "IfStatement",
"test": {
"type": "BinaryExpression",
"operator": "===",
"left": {
"type": "UnaryExpression",
"operator": "typeof",
"argument": {
"type": "Identifier",
"name": "x"
},
"prefix": true
},
"right": {
"type": "Literal",
"value": "number",
"raw": "\"number\""
}
},
"consequent": {
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument": {
"type": "BinaryExpression",
"operator": "+",
"left": {
"type": "Identifier",
"name": "x"
},
"right": {
"type": "Identifier",
"name": "y"
}
}
}
]
},
"alternate": {
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument": {
"type": "BinaryExpression",
"operator": "+",
"left": {
"type": "Identifier",
"name": "x"
},
"right": {
"type": "Literal",
"value": "tadm",
"raw": "'tadm'"
}
}
}
]
}
}
]
},
"generator": false,
"expression": false,
"async": false
}
],
"sourceType": "script"
}
解释
该阶段就是将上面产生的 AST 转换成字节码。
这里增加字节码(中间产物)的好处是,并不是将 AST 直接翻译成机器码,因为对应的 cpu 系统会不一致,翻译成机器码时要结合每种 cpu 底层的指令集,这样实现起来代码复杂度会非常高;还有个就是内存占用的问题,因为机器码会存储在内存中,而退出进程后又会存储在磁盘上,加上转换后的机器码多出来很多信息,会比源文件大很多,导致了严重的内存占用问题。
V8 在执行字节码的过程中,使用到了通用寄存器和累加寄存器,函数参数和局部变量保存在通用寄存器里面,累加器中保存中间计算结果,在执行指令的过程中,如果直接由 cpu 从内存中读取数据的话,比较影响程序执行的性能,使用寄存器存储中间数据的设计,可以大大提升 cpu 执行的速度。
编译
这个过程主要是 V8 的 TurboFan编译器 将字节码翻译成机器码的过程。
字节码配合解释器和编译器这一技术设计,可以称为JIT(即时编译技术),Java 虚拟机也是类似的技术,解释器在解释执行字节码时,会收集代码信息,标记一些热点代码(就是一段代码被重复执行多次),TurboFan 会将热点代码直接编译成机器码,缓存起来,下次调用直接运行对应的二进制的机器码,加快执行速度。
在 TurboFan 将字节码编译成机器码的过程中,还进行了简化处理:常量合并、强制折减、代数重新组合。
比如:3 + 4 --> 7,x + 1 + 2 --> x + 3 ......
执行
到这里我们就开始执行上一阶段产出的机器码。
而在 JS 的执行过程中,经常遇到的就是对象属性的访问。作为一种动态的语言,一个简单的属性访问可能包含着复杂的语义,比如Object.xxx
的形式,可能是属性的直接访问,也可能去调用的对象的Getter
方法,还有可能是要通过原型链往上层对象中查找。这种不确定性而且动态判断的情况,会浪费很多查找时间,所以 V8 会把第一次分析的结果放在缓存中,当再次访问相同的属性时,会优先从缓存中去取,调用 GetProperty(Object, "xxx", feedback_cache)
的方法获取缓存,如果有缓存结果,就会跳过查找过程,又大大提升了运行性能。
除了上面针对读取对象属性的结果缓存的优化,V8 还引入了 Object Shapes
(隐藏类)的概念,这里面会记录一些对象的基本信息(比如对象拥有的所有属性、每个属性对于这个对象的偏移量等),这样我们去访问属性时就可以直接通过属性名和偏移量直接定位到他的内存地址,读取即可,大大提升访问效率。
既然 V8 提出了隐藏类(两个形状相同的对象会去复用同一个隐藏类,何为形状相同的对象?两个对象满足有相同个数的相同属性名称和相同的属性顺序),那么我们开发者也可以很好的去利用它:
尽量创建形状相同的对象
创建完对象后尽量不要再去操作属性,即不增加或者删除属性,也就不会破环对象的形状
完成
到此 V8 已经完成了一份 JS 代码的读取、分析、解释、编译、执行。
总结
以上就是从 JS 代码下载到最终在 V8 引擎执行的过程分析,可以发现 V8 其实有很多实现的技术点,有着很巧妙的设计思想,比如流式处理、缓存中间产物、垃圾回收等,这里面又会涉及到很多细节,很值得继续深入研究。