作者:Kevin S翻译:疯狂的技术宅
绑定到字节码
对于现代 Web 程序,浏览器首先看到的 JavaScript 通常不是前端程序员写的。相反,它很可能是由 webpack 之类的工具产生的捆绑包,而且可能是一个相当大的捆绑包,其中包含 UI 框架,例如 React,各种 polyfills(在较旧的浏览器中模拟新平台功能的库),以及在 npm 上找到的各种软件包。浏览器的 JavaScript 引擎面临的第一个挑战是将一大堆文本转换为可以在虚拟机上执行的指令。正是由于它需要解析代码,而且用户正在等待 JavaScript 进行交互,所以它的执行速度必须很快才行。
在高级方面,JavaScript 引擎像其他语言编译器一样去解析代码。首先,输入的文本流被分解为名为 token 的块。每个 token 代表语法结构中的一个有意义的单元,类似于自然语言中的单词和标点符号。然后,将这些 token 输入到自上而下的解析器中,生成生成表示程序的树结构。语言设计师和编译器工程师喜欢把这种树结构称为 AST(抽象语法树)。然后就可以通过分析生成的 AST 来生成称为字节码的虚拟机指令列表。
生成 AST 的过程是 JavaScript 引擎更直接的工作之一。不过它也可能很慢。还记得本文开始时所提到的一大堆代码吗? JavaScript 引擎必须在用户能够开始与站点进行交互之前解析整个捆绑包并构建语法树。对于初始页面加载,其中大部分代码可能是不必要的,甚至根本无法执行其中的某些代码!
不过好在编译器工程师发明了各种技巧来加快处理速度。首先,某些引擎在后台线程中解析代码,从而释放主 UI 线程用于其他计算。其次,现代引擎将通过使用名为“延迟解析”或“延迟编译”的技术,尽可能地延迟内存中语法树的创建。
它的工作方式是这样的:如果引擎看到一个可能在一段时间内不执行的函数定义,它会对函数体进行快速的“丢弃”解析。这种一次性分析能够发现可能隐藏在代码内的所有语法错误,但不会生成 AST。稍后,当第一次调用该函数时,会再次解析这段代码。这次,引擎将生成执行所需的完整 AST 和字节码。在 JavaScript 的世界中,执行两次有时比执行一次更快!
但是,最好的优化是使我们完全绕开所有耗时处理的优化。对于 JavaScript 编译,这意味着完全跳过了解析步骤。一些 JavaScript 引擎会尝试缓存生成的字节码,以备以后用户再次访问该网站时进行重用。这并不那么简单。随着网站的更新,JavaScript 包可能会经常发生改变,浏览器必须仔细权衡序列化字节码的成本与缓存带来的性能提升之间的关系。
运行时的字节码
现在有了字节码,就可以开始执行了。在当今的 JavaScript 引擎中,在解析过程中生成的字节码首先被送到名为解释器的虚拟机中。解释器有点像用软件实现的 CPU。它一次查看一条字节码指令,然后决定要执行的实际机器指令以及下一步要执行的指令。
JavaScript 编程语言的结构和行为在名为 ECMA-262 的文档中进行了定义。其中结构部分被称为“语法”,行为部分为“语义”。编程语言的语义几乎都是由伪代码编写的算法定义的。假设我们是编译器工程师,正在实现带符号的右移运算符(>>
)以下则是规格说明(以下脚本引自 ECMA-262 ):
ShiftExpression : ShiftExpression >> AdditiveExpression
- Let lref be the result of evaluating ShiftExpression.
- Let lval be ? GetValue(lref).
- Let rref be the result of evaluating AdditiveExpression.
- Let rval be ? GetValue(rref).
- Let lnum be ? ToInt32(lval).
- Let rnum be ? ToUint32(rval).
- Let shiftCount be the result of masking out all but the least significant 5 bits of rnum, that is, compute rnum & 0x1F.
- Return the result of performing a sign-extending right shift of lnum by shiftCount bits. The most significant bit is propagated. The result is a signed 32-bit integer.
前六个步骤将操作数( >>
两侧的值)转换为32位整数,然后执行实际的移位操作。
但是如果真的完全按照规范中的描述去实现算法,那么做出来的解释器会很慢。下面以从 JavaScript 对象获取属性值的简单操作为例。
从概念上讲,JavaScript 中的对象就像字典一样。每个属性均以字符串名作为关键字。对象也可以有原型对象。
如果某个对象没有给定字符关键字的条目,那么就需要在原型中寻找该键。不断重复这个操作,直到找到所需的关键字或到达原型链的末尾为止。
这就导致了每次想从对象中获取属性值时,可能要做很多工作。
JavaScript 引擎中用于加速动态属性查找的策略称为内联缓存。内联缓存最早是在 1980 年代为 Smalltalk 语言所开发的。其基本思想是,先前属性查找操作的结果可以直接存储在生成的字节码指令中。
为了了解它的工作原理,让我们闭上眼睛,想象 JavaScript 引擎是一座充满魔法的大型图书馆。当我们走进去时,会注意到里面塞满了到处飞来飞去的书(即对象)。每个对象都有一个可识别的形状,这个形状便确定了其属性的存储位置。
假设我们正在按照书单上所记录的一系列字节码指令执行程序。下一条指令告诉我们从某个对象获取名为 x
的属性的值。你抓住该对象,找出 x
的存储位置,然后发现它已存储在该对象的第二个数据插槽中。
你会发现,具有相同形状的所有对象在其第二个数据插槽中都有 x
属性。拿出你的笔,在字节码书单上做一个注释,标记出对象的形状和 x
属性的位置。下次再看到这个标记时,只需检查对象的形状就行了。如果形状与你在字节码注释中所标记的形状匹配,不需要检查对象就可以准确知道数据的位置。这样你就实现了单态内联缓存。
但是,如果对象的形状与我们的字节码注释不匹配怎么办?这时可以通过制作一张小表格,并把看到的每种形状作为一行记录的方式来解决这个问题。当每看到一个新形状时,就把它作为一行添加到表中。这样就实现了一个多态内联缓存。它的速度不如单态缓存快,并且在书单上会占用更多的空间,但是如果行数不多,效果会非常好。
如果最后生成的表太大,就要把它删除掉,并做个注释来提醒自己不要再纠结这个指令的内联缓存了。用编译器的术语来说,实现了一个复态调用点(megamorphic callsite)。
一般来说单态代码非常快,多态代码差不多一样快,而复态代码则往往很慢。
- 单态:快如疾风
- 多态:动若脱兔
- 复态:慢似乌龟
即时编译( JIT)
解释器的优点在于可以快速开始执行代码,对于仅运行一两次的代码,这种“软件 CPU”的执行速度还是可以接受的。但是对于“热代码”(运行数百、上千甚至几数百万次的函数)来说,我们真正想要的是直接在实际硬件上执行机器指令。这时就需要即时(JIT)编译了。
当 JavaScript 函数由解释器执行时,会收集关于这个函数被调用的频率以及调用参数的各种统计信息。如果函数经常使用相同类型的参数执行,那么引擎可能会将函数的字节码转换为机器代码。
下面再次进入前面想象出来的 JavaScript 引擎,也就是那个充满魔法的图书馆。当程序开始执行时,你应该从贴有标签的架子拿出字节码书单。对于每个函数,大约有一行。按照每行上的说明进行操作时,你可以记录执行每一行的次数。另外还要注意在执行说明时所遇到的对象的形状。这时你就是分析解释器(profiling interpreter)。
当你看到下一个字节码行时,会注意到该字节码“很热”,因为你已经执行了几十次,并且认为加快它的运行速度。你有两个助手可以随时为你翻译。第一个助手可以将字节码快速转换为机器代码。他生成的代码质量很好,简洁明了,但效率却不如预期。第二个助手工作更加细心,尽管会花费更长的时间,但是产生的代码经过了高度优化,使速度尽可能的更快。
在编译器方面,我们将这些不同的助手称为 JIT编译层。不同的引擎有不同的层数,这取决于它们要进行的权衡和取舍。
你决定将字节码发送到第一个助手哪里有。经过一段时间的处理后,通过用仔细记录的笔记,他会产生一个包含机器指令的新书单,并将其与原始字节码版本一起放在正确的书架上。下次需要执行该函数时,可以用这个更快的指令集。
但问题是,助手在翻译我们的书单时做出了很多假设。也许他认为变量将始终包含一个整数。如果这些假设无效会导致什么结果?
这时就必须进行所谓的 bailout 操作。拿出出原始的字节码书单,并弄清楚应该从哪条指令开始执行。机器代码书单会送到第二个助手那里,然后再次开始前面的过程。
超越无限
当今的高性能 JavaScript 引擎已经远远超越了 1990 年代 Netscape Navigator 和 Internet Explorer 中的相对简单的解释器,而且还在继续发展。新功能正在被逐渐添加到 JavaScript 语言中。常见的编码模式已得到优化。 WebAssembly 也已经成熟,正在开发更丰富的标准模块库。作为开发人员,我们可以期望现代 JavaScript 引擎能够快速、高效的执行,只要控制捆绑包的大小,并且能确保不要让对性能至关重要的代码过于动态化。
本文首发微信公众号:前端先锋
欢迎扫描二维码关注公众号,每天都给你推送新鲜的前端技术文章
欢迎继续阅读本专栏其它高赞文章:
- 深入理解Shadow DOM v1
- 一步步教你用 WebVR 实现虚拟现实游戏
- 13个帮你提高开发效率的现代CSS框架
- 快速上手BootstrapVue
- JavaScript引擎是如何工作的?从调用栈到Promise你需要知道的一切
- WebSocket实战:在 Node 和 React 之间进行实时通信
- 关于 Git 的 20 个面试题
- 深入解析 Node.js 的 console.log
- Node.js 究竟是什么?
- 30分钟用Node.js构建一个API服务器
- Javascript的对象拷贝
- 程序员30岁前月薪达不到30K,该何去何从
- 14个最好的 JavaScript 数据可视化库
- 8 个给前端的顶级 VS Code 扩展插件
- Node.js 多线程完全指南
- 把HTML转成PDF的4个方案及实现
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。