You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

162 lines
13 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 13 | 字节码V8为什么又重新引入字节码
你好,我是李兵。
在第一节课我们就介绍了V8的编译流水线我们知道V8在执行一段JavaScript代码之前需要将其编译为字节码然后再解释执行字节码或者将字节码编译为二进制代码然后再执行。
所谓字节码是指编译过程中的中间代码你可以把字节码看成是机器代码的抽象在V8中字节码有两个作用
* 第一个是解释器可以直接解释执行字节码;
* 第二个是优化编译器可以将字节码编译为二进制代码,然后再执行二进制机器代码。
虽然目前的架构使用了字节码不过早期的V8并不是这样设计的那时候V8团队认为这种“先生成字节码再执行字节码”的方式多了个中间环节多出来的中间环节会牺牲代码的执行速度。
于是在早期V8团队采取了非常激进的策略直接将JavaScript代码编译成机器代码。其执行流程如下图所示
![](https://static001.geekbang.org/resource/image/6a/68/6a9f1a826b924eb74f0ab08a18528a68.jpg "早期V8执行流水线")
观察上面的执行流程图我们可以发现早期的V8也使用了两个编译器
1. 第一个是**基线编译器**它负责将JavaScript代码编译为**没有优化**过的机器代码。
2. 第二个是**优化编译器**,它负责将一些热点代码(执行频繁的代码)**优化**为执行效率更高的机器代码。
了解这两个编译器之后接下来我们再来看看早期的V8是怎么执行一段JavaScript代码的。
1. 首先V8会将一段JavaScript代码转换为抽象语法树(AST)。
2. 接下来基线编译器会将抽象语法树编译为未优化过的机器代码然后V8直接执行这些未优化过的机器代码。
3. 在执行未优化的二进制代码过程中如果V8检测到某段代码重复执行的概率过高那么V8会将该段代码标记为HOT标记为HOT的代码会被优化编译器优化成执行效率高的二进制代码然后就执行该段优化过的二进制代码。
4. 不过如果优化过的二进制代码并不能满足当前代码的执行这也就意味着优化失败V8则会执行反优化操作。
以上就是早期的V8执行一段JavaScript代码的流程不过最近发布的V8已经抛弃了直接将JavaScript代码编译为二进制代码的方式也抛弃了这两个编译器进而使用了字节码+解释器+编译器方式,也就是我们在第一节课介绍的形式。
早期的V8之所以抛弃中间形式的代码直接将JavaScript代码编译成机器代码是因为机器代码的执行性能非常高效但是最新版本却朝着执行性能相反的方向进化那么这是出于什么原因呢
## 机器代码缓存
当JavaScript代码在浏览器中被执行的时候需要先被V8编译早期的V8会将JavaScript编译成未经优化的二进制机器代码然后再执行这些未优化的二进制代码通常情况下编译占用了很大一部分时间下面是一段代码的编译和执行时间图
![](https://static001.geekbang.org/resource/image/d5/bb/d5b8e781606efa91362c856656de3ebb.jpg)
从图中可以看出编译所消耗的时间和执行所消耗的时间是差不多的试想一下如果在浏览器中再次打开相同的页面当页面中的JavaScript文件没有被修改那么再次编译之后的二进制代码也会保持不变 这意味着编译这一步白白浪费了CPU资源因为之前已经编译过一次了。
这就是Chrome浏览器引入二进制代码缓存的原因通过把二进制代码保存在内存中来消除冗余的编译重用它们完成后续的调用这样就省去了再次编译的时间。
V8 使用两种代码缓存策略来缓存生成的代码。
* 首先是V8第一次执行一段代码时会编译源JavaScript代码并将编译后的二进制代码缓存在内存中我们把这种方式称为内存缓存in-memory cache)。然后通过JavaScript源文件的字符串在内存中查找对应的编译后的二进制代码。这样当再次执行到这段代码时V8就可以直接去内存中查找是否编译过这段代码。如果内存缓存中存在这段代码所对应的二进制代码那么就直接执行编译好的二进制代码。
* 其次V8除了采用将代码缓存在内存中策略之外还会将代码缓存到硬盘上这样即便关闭了浏览器下次重新打开浏览器再次执行相同代码时也可以直接重复使用编译好的二进制代码。
![](https://static001.geekbang.org/resource/image/a6/60/a6f2ea6df895eb6940a9db95f54fa360.jpg "二进制代码缓存")
实践表明在浏览器中采用了二进制代码缓存的方式初始加载时分析和编译的时间缩短了20%40%。
## 字节码降低了内存占用
所以在早期Chrome做了两件事来提升JavaScript代码的执行速度
* 第一,将运行时将二进制机器代码缓存在内存中;
* 第二,当浏览器退出时,缓存编译之后二进制代码到磁盘上。
很明显采用缓存是一种典型的以空间换时间的策略以牺牲存储空间来换取执行速度我们知道Chrome的多进程架构已经非常吃内存了而Chrome中每个页面进程都运行了一份V8实例V8在执行JavaScript代码的过程中会将JavaScript代码转换为未经优化的二进制代码你可以对照下图中的JavaScript代码和二进制代码的
![](https://static001.geekbang.org/resource/image/21/cb/214d4c793543d08e16f86abd82a9accb.jpg)
从上图我们可以看出二进制代码所占用的内存空间是JavaScript代码的几千倍通常一个页面的JavaScript几M大小转换为二进制代码就变成几十M了如果是PC应用多占用一些内存也不会太影响性能但是在移动设备流行起来之后V8过度占用内存的问题就充分暴露出来了。因为通常一部手机的内存不会太大如果过度占用内存那么会导致Web应用的速度大大降低。
在上一节我们介绍过V8团队为了提升V8的启动速度采用了惰性编译其实惰性编译除了能提升JavaScript启动速度还可以解决部分内存占用的问题。你可以先参看下面的代码
![](https://static001.geekbang.org/resource/image/a1/58/a197b9a6f9136adf7724e8f528ca3158.jpg)
根据惰性编译的原则当V8首次执行上面这段代码的过程中开始只是编译最外层的代码那些函数内部的代码如下图中的黄色的部分会推迟到第一次调用时再编译。
为了解决缓存的二进制机器代码占用过多内存的问题早期的Chrome并没有缓存函数内部的二进制代码只是缓存了顶层次的二进制代码比如上图中红色的区域。
但是这种方式却存在很大的不确定性比如我们多人开发的项目通常喜欢将自己的代码封装成模块在JavaScript中由于没有块级作用域ES6之前所以我们习惯使用立即调用函数表达式(IIFEs),比如下面这样的代码:
* **test\_module.js**
```
var test_module = (function () {
var count_
function init_(){count_ = 0}
function add_(){count_ = count_+1}
function show_(){console.log(count_)}
return {
init: init_,
add: add_,
show:show_
}
})()
```
* **app.js**
```
test_module.init()
test_module.add()
test_module.show()
test_module.add()
test_module.show()
```
上面就是典型的闭包代码它将和模块相关的所有信息都封装在一个匿名立即执行函数表达式中并将需要暴漏的接口数据返回给变量test\_module。如果浏览器只缓存顶层代码那么闭包模块中的代码将无法被缓存而对于高度工程化的模块来说这种模块式的处理方式到处都是这就导致了一些关键代码没有办法被缓存。
所以采取只缓存顶层代码的方式是不完美的没办法适应多种不同的情况因此V8团队对早期的V8架构进行了非常大的重构具体地讲抛弃之前的基线编译器和优化编译器引入了字节码、解释器和新的优化编译器。
那么为什么通过引入字节码就能降低V8在执行时的内存占用呢要解释这个问题我们不妨看下面这张图
![](https://static001.geekbang.org/resource/image/27/4b/27d30dbb95e3bb3e55b9bc2a56e14d4b.jpg)
从图中可以看出字节码虽然占用的空间比原始的JavaScript多但是相较于机器代码字节码还是小了太多。
有了字节码,无论是解释器的解释执行,还是优化编译器的编译执行,都可以直接针对字节来进行操作。由于字节码占用的空间远小于二进制代码,所以浏览器就可以实现缓存所有的字节码,而不是仅仅缓存顶层的字节码。
虽然采用字节码在执行速度上稍慢于机器代码,但是整体上权衡利弊,采用字节码也许是最优解。之所以说是最优解,是因为采用字节码除了降低内存之外,还提升了代码的启动速度,并降低了代码的复杂度,而牺牲的仅仅是一点执行效率。接下来我们继续来分析下,采用字节码是怎么提升代码启动速度和降低复杂度的。
## 字节码如何提升代码启动速度?
我们先看引入字节码是怎么提升代码启动速度的。下面是启动JavaScript代码的流程图
![](https://static001.geekbang.org/resource/image/9e/5b/9e441845eb4af12642fe5385cdd1b05b.jpg)
从图中可以看出生成机器代码比生成字节码需要花费更久的时间但是直接执行机器代码却比解释执行字节码要更高效所以在快速启动JavaScript代码与花费更多时间获得最优运行性能的代码之间我们需要找到一个平衡点。
解释器可以快速生成字节码,但字节码通常效率不高。 相比之下,优化编译器虽然需要更长的时间进行处理,但最终会产生更高效的机器码,这正是 V8 在使用的模型。它的解释器叫 Ignition就原始字节码执行速度而言是所有引擎中最快的解释器。V8 的优化编译器名为 TurboFan最终由它生成高度优化的机器码。
## 字节码如何降低代码的复杂度?
早期的V8代码无论是基线编译器还是优化编译器它们都是基于AST抽象语法树来将代码转换为机器码的我们知道不同架构的机器码是不一样的而市面上存在不同架构的处理器又是非常之多你可以参看下图
![](https://static001.geekbang.org/resource/image/bc/e9/bc8ede549e0572689cadd6f2c21f31e9.jpg)
这意味着基线编译器和优化编译器要针对不同的体系的CPU编写不同的代码这会大大增加代码量。
引入了字节码,就可以统一将字节码转换为不同平台的二进制代码,你可以对比下执行流程:
![](https://static001.geekbang.org/resource/image/0b/5d/0b207ca6b427bf6281dce67d4f96835d.jpg)
因为字节码的执行过程和CPU执行二进制代码的过程类似相似的执行流程那么将字节码转换为不同架构的二进制代码的工作量也会大大降低这就降低了转换底层代码的工作量。
## 总结
这节课我们介绍了V8为什么要引入字节码。早期的V8为了提升代码的执行速度直接将JavaScript源代码编译成了没有优化的二进制的机器代码如果某一段二进制代码执行频率过高那么V8会将其标记为热点代码热点代码会被优化编译器优化优化后的机器代码执行效率更高。
不过随着移动设备的普及V8团队逐渐发现将JavaScript源码直接编译成二进制代码存在两个致命的问题
* 时间问题:编译时间过久,影响代码启动速度;
* 空间问题:缓存编译后的二进制代码占用更多的内存。
这两个问题无疑会阻碍V8在移动设备上的普及于是V8团队大规模重构代码引入了中间的字节码。字节码的优势有如下三点
* 解决启动问题:生成字节码的时间很短;
* 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;
* 代码架构清晰采用字节码可以简化程序的复杂度使得V8移植到不同的CPU架构平台更加容易。
## 思考题
今天留给你一个开放的思考题你认为V8虚拟机中的机器代码和字节码有哪些异同欢迎你在留言区与我分享讨论。
感谢你的阅读,如果你觉得这一讲的内容对你有所启发,也欢迎把它分享给你的朋友。